From ce2070ce5aeec136fcab0a2e45114055cbf73da4 Mon Sep 17 00:00:00 2001 From: huyue Date: Fri, 24 Apr 2026 17:46:03 +0800 Subject: [PATCH] chore: initial commit --- .env.example | 60 + .env.production.example | 57 + .gitignore | 48 + README.md | 110 + backend/README.md | 51 + backend/alembic.ini | 42 + backend/alembic/env.py | 65 + backend/alembic/script.py.mako | 27 + backend/alembic/versions/.gitkeep | 0 .../versions/0b286bfebd6e_p4_product_skill.py | 160 + .../alembic/versions/2862beceb4e7_p2_rbac.py | 139 + .../alembic/versions/3099be983359_p6_llm.py | 109 + .../4911d408555c_p6b_lobster_config.py | 42 + .../7c8589fdbff1_p3_platform_account_oplog.py | 73 + .../versions/a74b3a3db72f_p5_task_schedule.py | 131 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/router.py | 50 + backend/app/api/v1/__init__.py | 0 backend/app/api/v1/auth.py | 60 + backend/app/api/v1/callbacks.py | 69 + backend/app/api/v1/channel_codes.py | 51 + backend/app/api/v1/dashboard.py | 270 ++ backend/app/api/v1/executor.py | 72 + backend/app/api/v1/health.py | 33 + backend/app/api/v1/llm.py | 153 + backend/app/api/v1/lobster_config.py | 21 + backend/app/api/v1/menus.py | 41 + backend/app/api/v1/op_logs.py | 49 + backend/app/api/v1/organizations.py | 41 + backend/app/api/v1/platform_accounts.py | 58 + backend/app/api/v1/platforms.py | 39 + backend/app/api/v1/products.py | 52 + backend/app/api/v1/roles.py | 43 + backend/app/api/v1/schedules.py | 53 + backend/app/api/v1/scripts.py | 52 + backend/app/api/v1/skills.py | 104 + backend/app/api/v1/tasks.py | 93 + backend/app/api/v1/users.py | 57 + backend/app/core/__init__.py | 0 backend/app/core/config.py | 73 + backend/app/core/constants.py | 29 + backend/app/core/crypto.py | 46 + backend/app/core/deps.py | 72 + backend/app/core/exceptions.py | 69 + backend/app/core/logging.py | 44 + backend/app/core/middleware.py | 192 + backend/app/core/security.py | 76 + backend/app/db/__init__.py | 0 backend/app/db/base.py | 25 + backend/app/db/session.py | 29 + backend/app/integrations/__init__.py | 8 + backend/app/integrations/lobster_client.py | 142 + backend/app/integrations/openai_client.py | 86 + backend/app/integrations/script_executor.py | 150 + backend/app/main.py | 61 + backend/app/models/__init__.py | 53 + backend/app/models/channel_code.py | 31 + backend/app/models/llm.py | 120 + backend/app/models/lobster_config.py | 29 + backend/app/models/menu.py | 30 + backend/app/models/op_log.py | 34 + backend/app/models/organization.py | 26 + backend/app/models/platform.py | 18 + backend/app/models/platform_account.py | 34 + backend/app/models/product.py | 31 + backend/app/models/role.py | 67 + backend/app/models/schedule.py | 59 + backend/app/models/script_template.py | 25 + backend/app/models/skill.py | 89 + backend/app/models/task.py | 92 + backend/app/models/user.py | 38 + backend/app/schemas/__init__.py | 1 + backend/app/schemas/auth.py | 64 + backend/app/schemas/channel_code.py | 33 + backend/app/schemas/common.py | 20 + backend/app/schemas/llm.py | 166 + backend/app/schemas/lobster_config.py | 38 + backend/app/schemas/menu.py | 50 + backend/app/schemas/organization.py | 40 + backend/app/schemas/platform.py | 34 + backend/app/schemas/platform_account.py | 46 + backend/app/schemas/product.py | 45 + backend/app/schemas/role.py | 49 + backend/app/schemas/schedule.py | 53 + backend/app/schemas/script_template.py | 41 + backend/app/schemas/skill.py | 78 + backend/app/schemas/task.py | 51 + backend/app/schemas/user.py | 46 + backend/app/scripts/__init__.py | 0 backend/app/scripts/seed.py | 250 + backend/app/scripts/seed_p4.py | 169 + backend/app/scripts/seed_p6.py | 121 + backend/app/services/__init__.py | 1 + backend/app/services/auth_service.py | 60 + backend/app/services/channel_code_service.py | 65 + backend/app/services/llm_service.py | 409 ++ .../app/services/lobster_config_service.py | 152 + backend/app/services/menu_service.py | 92 + backend/app/services/org_service.py | 76 + .../app/services/platform_account_service.py | 138 + backend/app/services/platform_service.py | 64 + backend/app/services/product_service.py | 125 + backend/app/services/rbac_service.py | 105 + backend/app/services/role_service.py | 164 + backend/app/services/schedule_service.py | 289 ++ .../app/services/script_template_service.py | 73 + backend/app/services/skill_service.py | 347 ++ backend/app/services/task_service.py | 397 ++ backend/app/services/user_service.py | 123 + backend/app/tasks/__init__.py | 8 + backend/app/utils/__init__.py | 0 backend/docker/Dockerfile | 23 + backend/docker/entrypoint.sh | 16 + backend/pyproject.toml | 53 + backend/tests/__init__.py | 0 backend/tests/conftest.py | 8 + backend/tests/test_auth.py | 93 + backend/tests/test_crud.py | 222 + backend/tests/test_healthz.py | 35 + backend/tests/test_lobster_config.py | 129 + backend/tests/test_p4.py | 232 + backend/tests/test_p5.py | 252 + backend/tests/test_p6.py | 193 + backend/tests/test_role_scope.py | 92 + docker-compose.prod.yml | 47 + docker-compose.yml | 98 + docker/nginx.conf | 35 + docker/nginx.prod.conf | 82 + docs/Agent任务系统_v2.1_PRD_merged.html | 3454 ++++++++++++++ docs/Agent任务系统_架构设计方案.html | 984 ++++ docs/DEPLOY.md | 155 + docs/二级域名.txt | 1 + docs/原型agent_task_system_merged.html | 176 + frontend/.gitignore | 7 + frontend/Dockerfile | 11 + frontend/README.md | 41 + frontend/index.html | 13 + frontend/package-lock.json | 4078 +++++++++++++++++ frontend/package.json | 33 + frontend/postcss.config.cjs | 6 + frontend/src/App.tsx | 5 + frontend/src/api/http.ts | 55 + frontend/src/api/modules/auth.ts | 30 + frontend/src/api/modules/channel-code.ts | 20 + frontend/src/api/modules/dashboard.ts | 29 + frontend/src/api/modules/executor.ts | 7 + frontend/src/api/modules/llm.ts | 85 + frontend/src/api/modules/lobster-config.ts | 18 + frontend/src/api/modules/menu.ts | 12 + frontend/src/api/modules/organization.ts | 13 + frontend/src/api/modules/platform-account.ts | 55 + frontend/src/api/modules/platform.ts | 11 + frontend/src/api/modules/product.ts | 32 + frontend/src/api/modules/role.ts | 12 + frontend/src/api/modules/schedule.ts | 35 + frontend/src/api/modules/script.ts | 20 + frontend/src/api/modules/skill.ts | 57 + frontend/src/api/modules/task.ts | 32 + frontend/src/api/modules/user.ts | 43 + frontend/src/api/types.ts | 500 ++ frontend/src/components/JsonSchemaForm.tsx | 101 + frontend/src/components/PermButton.tsx | 15 + frontend/src/components/PermWrap.tsx | 13 + frontend/src/layouts/BasicLayout.tsx | 44 + frontend/src/layouts/BlankLayout.tsx | 5 + frontend/src/layouts/components/HeaderBar.tsx | 83 + frontend/src/layouts/components/SideMenu.tsx | 74 + frontend/src/main.tsx | 18 + frontend/src/pages/403/index.tsx | 14 + frontend/src/pages/Placeholder.tsx | 37 + frontend/src/pages/auth/menu/index.tsx | 316 ++ frontend/src/pages/auth/org/index.tsx | 272 ++ frontend/src/pages/auth/role/index.tsx | 430 ++ frontend/src/pages/auth/user/index.tsx | 319 ++ frontend/src/pages/dashboard/index.tsx | 629 +++ frontend/src/pages/log/op/index.tsx | 164 + frontend/src/pages/log/report/index.tsx | 165 + frontend/src/pages/login/index.tsx | 102 + frontend/src/pages/platform-account/index.tsx | 306 ++ frontend/src/pages/product/channel/index.tsx | 247 + frontend/src/pages/product/list/index.tsx | 297 ++ frontend/src/pages/product/suite/index.tsx | 191 + frontend/src/pages/skill/list/index.tsx | 514 +++ frontend/src/pages/skill/log/index.tsx | 206 + frontend/src/pages/system/cron/index.tsx | 314 ++ frontend/src/pages/system/executor/index.tsx | 1158 +++++ frontend/src/pages/system/script/index.tsx | 309 ++ frontend/src/pages/task/create/index.tsx | 324 ++ frontend/src/pages/task/list/index.tsx | 397 ++ frontend/src/router/guards.tsx | 66 + frontend/src/router/index.tsx | 71 + frontend/src/router/routes.ts | 67 + frontend/src/stores/auth.ts | 76 + frontend/src/styles/index.css | 19 + frontend/src/styles/tokens.ts | 35 + frontend/src/utils/token.ts | 13 + frontend/src/vite-env.d.ts | 9 + frontend/tailwind.config.ts | 20 + frontend/tsconfig.json | 27 + frontend/tsconfig.node.json | 12 + frontend/vite.config.ts | 22 + scripts/deploy.sh | 92 + 203 files changed, 27769 insertions(+) create mode 100644 .env.example create mode 100644 .env.production.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/README.md create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/.gitkeep create mode 100644 backend/alembic/versions/0b286bfebd6e_p4_product_skill.py create mode 100644 backend/alembic/versions/2862beceb4e7_p2_rbac.py create mode 100644 backend/alembic/versions/3099be983359_p6_llm.py create mode 100644 backend/alembic/versions/4911d408555c_p6b_lobster_config.py create mode 100644 backend/alembic/versions/7c8589fdbff1_p3_platform_account_oplog.py create mode 100644 backend/alembic/versions/a74b3a3db72f_p5_task_schedule.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/router.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/api/v1/callbacks.py create mode 100644 backend/app/api/v1/channel_codes.py create mode 100644 backend/app/api/v1/dashboard.py create mode 100644 backend/app/api/v1/executor.py create mode 100644 backend/app/api/v1/health.py create mode 100644 backend/app/api/v1/llm.py create mode 100644 backend/app/api/v1/lobster_config.py create mode 100644 backend/app/api/v1/menus.py create mode 100644 backend/app/api/v1/op_logs.py create mode 100644 backend/app/api/v1/organizations.py create mode 100644 backend/app/api/v1/platform_accounts.py create mode 100644 backend/app/api/v1/platforms.py create mode 100644 backend/app/api/v1/products.py create mode 100644 backend/app/api/v1/roles.py create mode 100644 backend/app/api/v1/schedules.py create mode 100644 backend/app/api/v1/scripts.py create mode 100644 backend/app/api/v1/skills.py create mode 100644 backend/app/api/v1/tasks.py create mode 100644 backend/app/api/v1/users.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/constants.py create mode 100644 backend/app/core/crypto.py create mode 100644 backend/app/core/deps.py create mode 100644 backend/app/core/exceptions.py create mode 100644 backend/app/core/logging.py create mode 100644 backend/app/core/middleware.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/db/session.py create mode 100644 backend/app/integrations/__init__.py create mode 100644 backend/app/integrations/lobster_client.py create mode 100644 backend/app/integrations/openai_client.py create mode 100644 backend/app/integrations/script_executor.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/channel_code.py create mode 100644 backend/app/models/llm.py create mode 100644 backend/app/models/lobster_config.py create mode 100644 backend/app/models/menu.py create mode 100644 backend/app/models/op_log.py create mode 100644 backend/app/models/organization.py create mode 100644 backend/app/models/platform.py create mode 100644 backend/app/models/platform_account.py create mode 100644 backend/app/models/product.py create mode 100644 backend/app/models/role.py create mode 100644 backend/app/models/schedule.py create mode 100644 backend/app/models/script_template.py create mode 100644 backend/app/models/skill.py create mode 100644 backend/app/models/task.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/schemas/channel_code.py create mode 100644 backend/app/schemas/common.py create mode 100644 backend/app/schemas/llm.py create mode 100644 backend/app/schemas/lobster_config.py create mode 100644 backend/app/schemas/menu.py create mode 100644 backend/app/schemas/organization.py create mode 100644 backend/app/schemas/platform.py create mode 100644 backend/app/schemas/platform_account.py create mode 100644 backend/app/schemas/product.py create mode 100644 backend/app/schemas/role.py create mode 100644 backend/app/schemas/schedule.py create mode 100644 backend/app/schemas/script_template.py create mode 100644 backend/app/schemas/skill.py create mode 100644 backend/app/schemas/task.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/scripts/__init__.py create mode 100644 backend/app/scripts/seed.py create mode 100644 backend/app/scripts/seed_p4.py create mode 100644 backend/app/scripts/seed_p6.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/auth_service.py create mode 100644 backend/app/services/channel_code_service.py create mode 100644 backend/app/services/llm_service.py create mode 100644 backend/app/services/lobster_config_service.py create mode 100644 backend/app/services/menu_service.py create mode 100644 backend/app/services/org_service.py create mode 100644 backend/app/services/platform_account_service.py create mode 100644 backend/app/services/platform_service.py create mode 100644 backend/app/services/product_service.py create mode 100644 backend/app/services/rbac_service.py create mode 100644 backend/app/services/role_service.py create mode 100644 backend/app/services/schedule_service.py create mode 100644 backend/app/services/script_template_service.py create mode 100644 backend/app/services/skill_service.py create mode 100644 backend/app/services/task_service.py create mode 100644 backend/app/services/user_service.py create mode 100644 backend/app/tasks/__init__.py create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/docker/Dockerfile create mode 100755 backend/docker/entrypoint.sh create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_crud.py create mode 100644 backend/tests/test_healthz.py create mode 100644 backend/tests/test_lobster_config.py create mode 100644 backend/tests/test_p4.py create mode 100644 backend/tests/test_p5.py create mode 100644 backend/tests/test_p6.py create mode 100644 backend/tests/test_role_scope.py create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docker/nginx.conf create mode 100644 docker/nginx.prod.conf create mode 100644 docs/Agent任务系统_v2.1_PRD_merged.html create mode 100644 docs/Agent任务系统_架构设计方案.html create mode 100644 docs/DEPLOY.md create mode 100644 docs/二级域名.txt create mode 100644 docs/原型agent_task_system_merged.html create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.cjs create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/http.ts create mode 100644 frontend/src/api/modules/auth.ts create mode 100644 frontend/src/api/modules/channel-code.ts create mode 100644 frontend/src/api/modules/dashboard.ts create mode 100644 frontend/src/api/modules/executor.ts create mode 100644 frontend/src/api/modules/llm.ts create mode 100644 frontend/src/api/modules/lobster-config.ts create mode 100644 frontend/src/api/modules/menu.ts create mode 100644 frontend/src/api/modules/organization.ts create mode 100644 frontend/src/api/modules/platform-account.ts create mode 100644 frontend/src/api/modules/platform.ts create mode 100644 frontend/src/api/modules/product.ts create mode 100644 frontend/src/api/modules/role.ts create mode 100644 frontend/src/api/modules/schedule.ts create mode 100644 frontend/src/api/modules/script.ts create mode 100644 frontend/src/api/modules/skill.ts create mode 100644 frontend/src/api/modules/task.ts create mode 100644 frontend/src/api/modules/user.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/components/JsonSchemaForm.tsx create mode 100644 frontend/src/components/PermButton.tsx create mode 100644 frontend/src/components/PermWrap.tsx create mode 100644 frontend/src/layouts/BasicLayout.tsx create mode 100644 frontend/src/layouts/BlankLayout.tsx create mode 100644 frontend/src/layouts/components/HeaderBar.tsx create mode 100644 frontend/src/layouts/components/SideMenu.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/403/index.tsx create mode 100644 frontend/src/pages/Placeholder.tsx create mode 100644 frontend/src/pages/auth/menu/index.tsx create mode 100644 frontend/src/pages/auth/org/index.tsx create mode 100644 frontend/src/pages/auth/role/index.tsx create mode 100644 frontend/src/pages/auth/user/index.tsx create mode 100644 frontend/src/pages/dashboard/index.tsx create mode 100644 frontend/src/pages/log/op/index.tsx create mode 100644 frontend/src/pages/log/report/index.tsx create mode 100644 frontend/src/pages/login/index.tsx create mode 100644 frontend/src/pages/platform-account/index.tsx create mode 100644 frontend/src/pages/product/channel/index.tsx create mode 100644 frontend/src/pages/product/list/index.tsx create mode 100644 frontend/src/pages/product/suite/index.tsx create mode 100644 frontend/src/pages/skill/list/index.tsx create mode 100644 frontend/src/pages/skill/log/index.tsx create mode 100644 frontend/src/pages/system/cron/index.tsx create mode 100644 frontend/src/pages/system/executor/index.tsx create mode 100644 frontend/src/pages/system/script/index.tsx create mode 100644 frontend/src/pages/task/create/index.tsx create mode 100644 frontend/src/pages/task/list/index.tsx create mode 100644 frontend/src/router/guards.tsx create mode 100644 frontend/src/router/index.tsx create mode 100644 frontend/src/router/routes.ts create mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/src/styles/index.css create mode 100644 frontend/src/styles/tokens.ts create mode 100644 frontend/src/utils/token.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100755 scripts/deploy.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a01c6dc --- /dev/null +++ b/.env.example @@ -0,0 +1,60 @@ +# Loom 全局环境变量模板 +# 使用方法:`cp .env.example .env` 后填写真实值。 +# 仅此文件提交仓库,`.env` 永远不要入库。 + +# ===== App ===== +APP_ENV=dev +# 32 字节随机字符串,生成:openssl rand -hex 32 +APP_SECRET_KEY= +APP_BASE_URL=https://ai.aidfxn.com + +# JWT 签发参数 +JWT_ALG=HS256 +JWT_ACCESS_TTL=3600 +JWT_REFRESH_TTL=604800 + +# ===== PostgreSQL ===== +# docker-compose 自建数据库时保持默认;对接外部库时改连接串 +POSTGRES_USER=loom +POSTGRES_PASSWORD=loom +POSTGRES_DB=loom +# 本机直跑 uvicorn 用 localhost;容器内跑由 docker-compose 覆写成 postgres:5432 +DATABASE_URL=postgresql+asyncpg://loom:loom@localhost:5432/loom + +# ===== Redis / Celery ===== +# 同上:本机 localhost,容器内由 compose 覆写成 redis:6379 +REDIS_URL=redis://localhost:6379/0 +CELERY_BROKER_URL=redis://localhost:6379/1 +CELERY_RESULT_BACKEND=redis://localhost:6379/2 + +# ===== Lobster Agent(龙虾 Agent API)===== +# 对接真实龙虾平台,联调时由运维/产品提供,先留空。 +# LOBSTER_API_BASE 示例:https://api.lobster-agent.com/v1 +# LOBSTER_API_KEY 龙虾平台签发的 Bearer Token +# LOBSTER_CALLBACK_SECRET 龙虾回调 loom 时的 HMAC-SHA256 签名密钥 +# LOBSTER_TIMEOUT_SECONDS 单次 HTTP 调用超时(秒),默认 30 +# LOBSTER_POLL_INTERVAL_SECONDS 无回调时兜底轮询周期(秒),默认 5 +LOBSTER_API_BASE= +LOBSTER_API_KEY= +LOBSTER_CALLBACK_SECRET= +LOBSTER_TIMEOUT_SECONDS=30 +LOBSTER_POLL_INTERVAL_SECONDS=5 + +# ===== OpenAI(主 LLM Provider;其它 Provider 运行时在 /llm/providers 配置)===== +# OPENAI_API_BASE 默认 https://api.openai.com/v1;走代理时改这里 +# OPENAI_API_KEY OpenAI 控制台创建的 sk-… Key +# OPENAI_DEFAULT_MODEL 默认模型,建议 gpt-4o-mini +# OPENAI_TIMEOUT_SECONDS 单次 HTTP 调用超时(秒),默认 60 +OPENAI_API_BASE=https://api.openai.com/v1 +OPENAI_API_KEY= +OPENAI_DEFAULT_MODEL=gpt-4o-mini +OPENAI_TIMEOUT_SECONDS=60 + +# ===== 加密 ===== +# Fernet 32 字节 urlsafe_b64 Key,用于加密 platform_account 凭据。 +# 生成:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +FERNET_KEY= + +# ===== CORS ===== +# 逗号分隔;本地开发 vite 默认 5173 +CORS_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..dd52721 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,57 @@ +# Loom 生产环境 .env 模板 +# 使用方法:在目标服务器上 `cp .env.production.example .env` 后填入真实值。 +# +# 所有密钥字段严禁提交到仓库,生产凭据通过服务器侧环境变量 / 密钥管理服务注入。 + +# ===== App ===== +APP_ENV=prod +# 32 字节随机字符串,每个环境独立生成:openssl rand -hex 32 +APP_SECRET_KEY= +APP_BASE_URL=https://ai.aidfxn.com + +JWT_ALG=HS256 +JWT_ACCESS_TTL=3600 +JWT_REFRESH_TTL=604800 + +# ===== PostgreSQL(生产:强密码)===== +POSTGRES_USER=loom +# openssl rand -base64 24 +POSTGRES_PASSWORD= +POSTGRES_DB=loom +# 容器内服务名,勿改 +DATABASE_URL=postgresql+asyncpg://loom:__POSTGRES_PASSWORD__@postgres:5432/loom + +# ===== Redis / Celery ===== +REDIS_URL=redis://redis:6379/0 +CELERY_BROKER_URL=redis://redis:6379/1 +CELERY_RESULT_BACKEND=redis://redis:6379/2 + +# ===== Lobster Agent ===== +# 以下 5 项也可在 UI 的"系统设置 → 执行器配置 → 龙虾 Agent"里填;UI 值优先级高于 .env。 +# 生产建议:至少在 .env 里填一套兜底,避免 UI 清空后掉到 mock。 +LOBSTER_API_BASE= +LOBSTER_API_KEY= +LOBSTER_CALLBACK_SECRET= +LOBSTER_TIMEOUT_SECONDS=30 +LOBSTER_POLL_INTERVAL_SECONDS=5 + +# ===== OpenAI ===== +# 每个 Provider 也可以在 UI 的"执行器配置 → LLM Provider"里单独配置 api_key。 +OPENAI_API_BASE=https://api.openai.com/v1 +OPENAI_API_KEY= +OPENAI_DEFAULT_MODEL=gpt-4o-mini +OPENAI_TIMEOUT_SECONDS=60 + +# ===== 加密 ===== +# 用于 Fernet 加解密平台账号凭据 + LLM Provider key + 龙虾 config。 +# 生成:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# ⚠️ 生产环境生成后严禁更换:所有加密字段(平台账号密码、LLM api_key、龙虾 api_key 等)都需用此 key 解密 +FERNET_KEY= + +# ===== CORS ===== +# 生产只放行正式域名 +CORS_ALLOW_ORIGINS=https://ai.aidfxn.com + +# ===== 容器启动时是否自动跑 seed ===== +# 首次部署设为 true 播种超管 + 菜单;二次部署建议 false 避免误覆盖业务数据 +SEED_ON_BOOT=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2de8ca5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# === Secrets === +.env +.env.* +!.env.example +!.env.production.example + +# === Claude Code === +.claude/ + +# === Python === +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +dist/ +build/ + +# === Node === +node_modules/ +npm-debug.log* +yarn-debug.log* +pnpm-debug.log* +.pnpm-store/ +frontend/dist/ +frontend/.vite/ + +# === IDE / OS === +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +# === Logs / Runtime === +*.log +logs/ +tmp/ +data/ +uploads/ + +# === Docker volumes (本地挂载) === +.docker-data/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb99d3f --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# Loom + +企业级 Agent 任务自动化平台。统一管理龙虾 Agent 与本地脚本引擎两类执行器,提供 Skill 注册、四维 RBAC、任务调度、LLM 路由与审计观测。 + +## 目录 + +``` +loom/ +├── backend/ FastAPI + SQLAlchemy (async) + subprocess 执行器 + 内嵌 scheduler +├── frontend/ React 18 + Vite + Ant Design 5 + Zustand +├── docker/ nginx 配置 / 通用 Docker 片段 +├── docs/ PRD、架构设计、原型 +├── docker-compose.yml +└── .env.example 所有外部配置项(含详细注释) +``` + +## 快速开始(开发模式) + +### 1. 准备配置 + +```bash +cp .env.example .env +# 生成必须的密钥 +openssl rand -hex 32 # → APP_SECRET_KEY +python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # → FERNET_KEY + +# 联调时再填:LOBSTER_API_BASE / LOBSTER_API_KEY / LOBSTER_CALLBACK_SECRET +# OpenAI:OPENAI_API_KEY(留空会走 mock 回显) +``` + +### 2. 起 pg + redis + +```bash +docker compose up -d postgres redis +``` + +### 3. 后端 + +```bash +cd backend +uv venv --python 3.11 && source .venv/bin/activate +uv pip install -e '.[dev]' +alembic upgrade head +# 初始化种子数据(超管/业务员 + 菜单 + 示例产品/Skill/LLM Provider) +python -m app.scripts.seed +python -m app.scripts.seed_p4 +python -m app.scripts.seed_p6 +uvicorn app.main:app --reload +# → http://localhost:8000/api/v1/healthz +# → http://localhost:8000/docs +# → 内嵌 scheduler 已启动,每分钟扫 due schedules +``` + +种子账号: +- `admin` / `admin123`(超级管理员,全部菜单 + 权限) +- `operator` / `operator123`(业务员,仅仪表盘 + Skill + 任务中心) + +### 4. 前端 + +```bash +cd frontend +npm i # 或 pnpm / yarn +npm run dev +# → http://localhost:5173 +``` + +### 5. 一键全栈(构建镜像后启动) + +```bash +# 修改 .env 的 DATABASE_URL 等为容器内服务名(compose 已在 environment: 覆写,可保持 localhost) +docker compose --profile full up -d --build +# → http://localhost/ (前端 + nginx 反代 /api 到后端) +``` + +## 技术栈 + +- **前端**:React 18 · TypeScript · Vite 5 · Ant Design 5 · Zustand · React Router 6 · Axios · Tailwind(原子工具类) +- **后端**:FastAPI · SQLAlchemy 2.0 (async, asyncpg) · Pydantic v2 · Alembic · httpx · structlog · bcrypt · cryptography (Fernet) +- **基础设施**:PostgreSQL 16 · Redis 7 · Nginx · Docker Compose +- **部署域名**:`ai.aidfxn.com` + +## 模块地图 + +| 区块 | 页面 | 后端模块 | +|---|---|---| +| 仪表盘 | `/dashboard` | `dashboard_summary` | +| 权限 | 组织/角色/用户/菜单 | `rbac_service` + 4 维权限装饰器 | +| 产品 | 产品/渠道码/平台套件 + 平台账号(Fernet 加密)| `product_service` / `channel_code_service` | +| Skill | Skill 管理(双执行器 + JSON Schema 参数 + 测试台)| `skill_service` | +| 任务 | 四步向导 + 列表 + 实时日志轮询 | `task_service`(LOBSTER `httpx` / SCRIPT `subprocess`)| +| 定时 | cron 5 段 + 启停 + 手动触发 | `schedule_service`(进程内 scheduler loop)| +| 日志 | 操作日志(OpLog ASGI 中间件)+ 统计报表 | `op_log` / `dashboard_report` | +| 系统 | 执行器状态 + LLM Provider/Model/Routing + 调试台 + 脚本模板 | `llm_service` / `executor` / `script_template_service` | + +## 开发阶段 + +项目按 6 个 Phase 分期推进: + +| Phase | 目标 | +|---|---| +| P1 | 基建:工程骨架、健康检查、登录页 | +| P2 | RBAC + 动态布局:四维权限、菜单树、路由守卫 | +| P3 | 基础资产:组织/角色/用户/菜单 + 平台账号 + OpLog 审计 | +| P4 | Skill 与脚本:JSON Schema 表单、产品范围绑定、测试台 | +| P5 | 任务执行核心:四步向导、双执行器、实时日志、定时任务 | +| P6 | LLM Provider/Model/Routing、执行器配置、调试台、生产部署包 | + +测试:`cd backend && pytest` — **45 个集成测试全绿** + +详细路线参见 [docs/](docs/) 与 PRD。 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..4d0d386 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,51 @@ +# Loom Backend + +FastAPI + SQLAlchemy 2.0 (async) + Celery + PostgreSQL + Redis。 + +## 本地开发 + +```bash +# 1. 创建虚拟环境并安装依赖 +python -m venv .venv +source .venv/bin/activate +pip install -e '.[dev]' + +# 2. 配置(根目录 .env 由 .env.example 复制) +# 生成 APP_SECRET_KEY:openssl rand -hex 32 +# 生成 FERNET_KEY:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" + +# 3. 起 pg/redis +docker compose -f ../docker-compose.yml up -d postgres redis + +# 4. 跑迁移(P1 暂无迁移文件,可跳过) +alembic upgrade head + +# 5. 启动 +uvicorn app.main:app --reload + +# 6. 检查 +open http://localhost:8000/api/v1/healthz +open http://localhost:8000/docs +``` + +## 测试 + +```bash +pytest +``` + +## 目录 + +``` +app/ +├── main.py FastAPI 入口 +├── core/ 配置、日志、异常、JWT、依赖 +├── db/ SQLAlchemy 引擎 & Base +├── api/v1/ REST 路由 +├── models/ ORM(P2 起) +├── schemas/ Pydantic DTO(P2 起) +├── services/ 业务服务(P2 起) +├── tasks/ Celery 任务(P5 起) +├── integrations/ Lobster / OpenAI 客户端(P4/P6) +└── utils/ +``` diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..fa04fb3 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,42 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +# sqlalchemy.url 留空,env.py 会从 app.core.config 读取 DATABASE_URL +sqlalchemy.url = + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..b5cb996 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,65 @@ +"""Alembic 环境:从 app.core.config 取 DATABASE_URL,支持 async 引擎。""" +from __future__ import annotations + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from app.core.config import settings +from app.db.base import Base + +# Alembic Config +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# 把 DATABASE_URL 注入 alembic ini +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +# 导入所有模型,让 Base.metadata 能被 autogenerate 扫描 +from app import models # noqa: F401, E402 + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=settings.DATABASE_URL, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..453528f --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: str | Sequence[str] | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/.gitkeep b/backend/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/alembic/versions/0b286bfebd6e_p4_product_skill.py b/backend/alembic/versions/0b286bfebd6e_p4_product_skill.py new file mode 100644 index 0000000..2f0a0a6 --- /dev/null +++ b/backend/alembic/versions/0b286bfebd6e_p4_product_skill.py @@ -0,0 +1,160 @@ +"""p4_product_skill + +Revision ID: 0b286bfebd6e +Revises: 7c8589fdbff1 +Create Date: 2026-04-24 13:59:03.048452 +""" +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0b286bfebd6e' +down_revision: str | Sequence[str] | None = '7c8589fdbff1' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('channel_code', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=64), nullable=False), + sa.Column('name', sa.String(length=64), nullable=False), + sa.Column('type', sa.String(length=16), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code') + ) + op.create_table('platform', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=64), nullable=False), + sa.Column('name', sa.String(length=64), nullable=False), + sa.Column('url', sa.String(length=255), nullable=True), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code') + ) + op.create_table('product', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=64), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('platform_id', sa.Integer(), nullable=True), + sa.Column('dept_id', sa.Integer(), nullable=True), + sa.Column('owner_id', sa.Integer(), nullable=True), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['dept_id'], ['organization.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['platform_id'], ['platform.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code') + ) + op.create_index(op.f('ix_product_dept_id'), 'product', ['dept_id'], unique=False) + op.create_index(op.f('ix_product_owner_id'), 'product', ['owner_id'], unique=False) + op.create_index(op.f('ix_product_platform_id'), 'product', ['platform_id'], unique=False) + op.create_table('script_template', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=64), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('language', sa.String(length=16), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('timeout_seconds', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('owner_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code') + ) + op.create_table('product_channel_code', + sa.Column('product_id', sa.Integer(), nullable=False), + sa.Column('channel_code_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['channel_code_id'], ['channel_code.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['product_id'], ['product.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('product_id', 'channel_code_id') + ) + op.create_table('skill', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=64), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('category', sa.String(length=32), nullable=True), + sa.Column('executor_type', sa.String(length=16), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('input_schema', sa.JSON(), nullable=True), + sa.Column('param_mapping', sa.JSON(), nullable=True), + sa.Column('lobster_template_id', sa.String(length=128), nullable=True), + sa.Column('script_template_id', sa.Integer(), nullable=True), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('doc', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['script_template_id'], ['script_template.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code') + ) + op.create_table('skill_call_log', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('skill_id', sa.Integer(), nullable=False), + sa.Column('skill_code', sa.String(length=64), nullable=False), + sa.Column('operator_id', sa.Integer(), nullable=True), + sa.Column('operator_name', sa.String(length=64), nullable=True), + sa.Column('executor_type', sa.String(length=16), nullable=False), + sa.Column('product_id', sa.Integer(), nullable=True), + sa.Column('input_data', sa.JSON(), nullable=True), + sa.Column('output_data', sa.JSON(), nullable=True), + sa.Column('result', sa.String(length=16), nullable=False), + sa.Column('message', sa.String(length=512), nullable=True), + sa.Column('cost_ms', sa.Integer(), nullable=False), + sa.Column('source', sa.String(length=16), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['operator_id'], ['user.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['product_id'], ['product.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['skill_id'], ['skill.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_skill_call_log_created_at'), 'skill_call_log', ['created_at'], unique=False) + op.create_index(op.f('ix_skill_call_log_operator_id'), 'skill_call_log', ['operator_id'], unique=False) + op.create_index(op.f('ix_skill_call_log_skill_id'), 'skill_call_log', ['skill_id'], unique=False) + op.create_table('skill_product_scope', + sa.Column('skill_id', sa.Integer(), nullable=False), + sa.Column('product_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['product_id'], ['product.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['skill_id'], ['skill.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('skill_id', 'product_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('skill_product_scope') + op.drop_index(op.f('ix_skill_call_log_skill_id'), table_name='skill_call_log') + op.drop_index(op.f('ix_skill_call_log_operator_id'), table_name='skill_call_log') + op.drop_index(op.f('ix_skill_call_log_created_at'), table_name='skill_call_log') + op.drop_table('skill_call_log') + op.drop_table('skill') + op.drop_table('product_channel_code') + op.drop_table('script_template') + op.drop_index(op.f('ix_product_platform_id'), table_name='product') + op.drop_index(op.f('ix_product_owner_id'), table_name='product') + op.drop_index(op.f('ix_product_dept_id'), table_name='product') + op.drop_table('product') + op.drop_table('platform') + op.drop_table('channel_code') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/2862beceb4e7_p2_rbac.py b/backend/alembic/versions/2862beceb4e7_p2_rbac.py new file mode 100644 index 0000000..3f69160 --- /dev/null +++ b/backend/alembic/versions/2862beceb4e7_p2_rbac.py @@ -0,0 +1,139 @@ +"""p2_rbac + +Revision ID: 2862beceb4e7 +Revises: +Create Date: 2026-04-24 12:56:40.426760 +""" +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2862beceb4e7' +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('menu', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('parent_id', sa.Integer(), nullable=True), + sa.Column('name', sa.String(length=64), nullable=False), + sa.Column('type', sa.String(length=16), nullable=False), + sa.Column('path', sa.String(length=128), nullable=True), + sa.Column('component', sa.String(length=128), nullable=True), + sa.Column('icon', sa.String(length=64), nullable=True), + sa.Column('perm_code', sa.String(length=128), nullable=True), + sa.Column('sort', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['parent_id'], ['menu.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('perm_code') + ) + op.create_index(op.f('ix_menu_parent_id'), 'menu', ['parent_id'], unique=False) + op.create_table('organization', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('parent_id', sa.Integer(), nullable=True), + sa.Column('name', sa.String(length=64), nullable=False), + sa.Column('code', sa.String(length=64), nullable=False), + sa.Column('type', sa.String(length=16), nullable=False), + sa.Column('sort', sa.Integer(), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['parent_id'], ['organization.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code') + ) + op.create_index(op.f('ix_organization_parent_id'), 'organization', ['parent_id'], unique=False) + op.create_table('role', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('dept_id', sa.Integer(), nullable=True), + sa.Column('name', sa.String(length=64), nullable=False), + sa.Column('code', sa.String(length=64), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('is_builtin', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['dept_id'], ['organization.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('dept_id', 'code', name='uq_role_dept_code') + ) + op.create_index(op.f('ix_role_dept_id'), 'role', ['dept_id'], unique=False) + op.create_table('user', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('username', sa.String(length=64), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('nickname', sa.String(length=64), nullable=True), + sa.Column('email', sa.String(length=128), nullable=True), + sa.Column('avatar', sa.String(length=255), nullable=True), + sa.Column('dept_id', sa.Integer(), nullable=True), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('is_superuser', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['dept_id'], ['organization.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_index(op.f('ix_user_dept_id'), 'user', ['dept_id'], unique=False) + op.create_table('role_account_scope', + sa.Column('role_id', sa.Integer(), nullable=False), + sa.Column('account_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['role_id'], ['role.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('role_id', 'account_id') + ) + op.create_table('role_menu', + sa.Column('role_id', sa.Integer(), nullable=False), + sa.Column('menu_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['menu_id'], ['menu.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['role_id'], ['role.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('role_id', 'menu_id') + ) + op.create_table('role_product_scope', + sa.Column('role_id', sa.Integer(), nullable=False), + sa.Column('product_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['role_id'], ['role.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('role_id', 'product_id') + ) + op.create_table('role_skill_scope', + sa.Column('role_id', sa.Integer(), nullable=False), + sa.Column('skill_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['role_id'], ['role.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('role_id', 'skill_id') + ) + op.create_table('user_role', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('role_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['role_id'], ['role.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id', 'role_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('user_role') + op.drop_table('role_skill_scope') + op.drop_table('role_product_scope') + op.drop_table('role_menu') + op.drop_table('role_account_scope') + op.drop_index(op.f('ix_user_dept_id'), table_name='user') + op.drop_table('user') + op.drop_index(op.f('ix_role_dept_id'), table_name='role') + op.drop_table('role') + op.drop_index(op.f('ix_organization_parent_id'), table_name='organization') + op.drop_table('organization') + op.drop_index(op.f('ix_menu_parent_id'), table_name='menu') + op.drop_table('menu') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/3099be983359_p6_llm.py b/backend/alembic/versions/3099be983359_p6_llm.py new file mode 100644 index 0000000..625ef31 --- /dev/null +++ b/backend/alembic/versions/3099be983359_p6_llm.py @@ -0,0 +1,109 @@ +"""p6_llm + +Revision ID: 3099be983359 +Revises: a74b3a3db72f +Create Date: 2026-04-24 15:16:37.670788 +""" +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3099be983359' +down_revision: str | Sequence[str] | None = 'a74b3a3db72f' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('llm_provider', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=64), nullable=False), + sa.Column('name', sa.String(length=64), nullable=False), + sa.Column('vendor', sa.String(length=32), nullable=False), + sa.Column('api_base', sa.String(length=255), nullable=False), + sa.Column('api_key_cipher', sa.String(length=512), nullable=True), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code') + ) + op.create_table('llm_model', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('provider_id', sa.Integer(), nullable=False), + sa.Column('model', sa.String(length=128), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('max_context', sa.Integer(), nullable=False), + sa.Column('price_input_per_1k', sa.Numeric(precision=10, scale=6), nullable=False), + sa.Column('price_output_per_1k', sa.Numeric(precision=10, scale=6), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['provider_id'], ['llm_provider.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_llm_model_provider_id'), 'llm_model', ['provider_id'], unique=False) + op.create_table('llm_routing_rule', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('priority', sa.Integer(), nullable=False), + sa.Column('conditions', sa.JSON(), nullable=True), + sa.Column('model_id', sa.Integer(), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['model_id'], ['llm_model.id'], ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('llm_usage', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('provider_id', sa.Integer(), nullable=True), + sa.Column('model_id', sa.Integer(), nullable=True), + sa.Column('provider_code', sa.String(length=64), nullable=True), + sa.Column('model', sa.String(length=128), nullable=True), + sa.Column('operator_id', sa.Integer(), nullable=True), + sa.Column('operator_name', sa.String(length=64), nullable=True), + sa.Column('source', sa.String(length=16), nullable=False), + sa.Column('task_id', sa.Integer(), nullable=True), + sa.Column('skill_id', sa.Integer(), nullable=True), + sa.Column('prompt_tokens', sa.Integer(), nullable=False), + sa.Column('completion_tokens', sa.Integer(), nullable=False), + sa.Column('total_tokens', sa.Integer(), nullable=False), + sa.Column('cost_usd', sa.Numeric(precision=12, scale=6), nullable=False), + sa.Column('result', sa.String(length=16), nullable=False), + sa.Column('message', sa.String(length=255), nullable=True), + sa.Column('cost_ms', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['model_id'], ['llm_model.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['operator_id'], ['user.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['provider_id'], ['llm_provider.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_llm_usage_created_at'), 'llm_usage', ['created_at'], unique=False) + op.create_index(op.f('ix_llm_usage_model_id'), 'llm_usage', ['model_id'], unique=False) + op.create_index(op.f('ix_llm_usage_operator_id'), 'llm_usage', ['operator_id'], unique=False) + op.create_index(op.f('ix_llm_usage_provider_id'), 'llm_usage', ['provider_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_llm_usage_provider_id'), table_name='llm_usage') + op.drop_index(op.f('ix_llm_usage_operator_id'), table_name='llm_usage') + op.drop_index(op.f('ix_llm_usage_model_id'), table_name='llm_usage') + op.drop_index(op.f('ix_llm_usage_created_at'), table_name='llm_usage') + op.drop_table('llm_usage') + op.drop_table('llm_routing_rule') + op.drop_index(op.f('ix_llm_model_provider_id'), table_name='llm_model') + op.drop_table('llm_model') + op.drop_table('llm_provider') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/4911d408555c_p6b_lobster_config.py b/backend/alembic/versions/4911d408555c_p6b_lobster_config.py new file mode 100644 index 0000000..12b20be --- /dev/null +++ b/backend/alembic/versions/4911d408555c_p6b_lobster_config.py @@ -0,0 +1,42 @@ +"""p6b_lobster_config + +Revision ID: 4911d408555c +Revises: 3099be983359 +Create Date: 2026-04-24 16:04:28.253699 +""" +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4911d408555c' +down_revision: str | Sequence[str] | None = '3099be983359' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('lobster_config', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('api_base', sa.String(length=255), nullable=True), + sa.Column('api_key_cipher', sa.Text(), nullable=True), + sa.Column('callback_secret_cipher', sa.Text(), nullable=True), + sa.Column('timeout_seconds', sa.Integer(), nullable=True), + sa.Column('poll_interval_seconds', sa.Integer(), nullable=True), + sa.Column('note', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('lobster_config') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/7c8589fdbff1_p3_platform_account_oplog.py b/backend/alembic/versions/7c8589fdbff1_p3_platform_account_oplog.py new file mode 100644 index 0000000..d66593e --- /dev/null +++ b/backend/alembic/versions/7c8589fdbff1_p3_platform_account_oplog.py @@ -0,0 +1,73 @@ +"""p3_platform_account_oplog + +Revision ID: 7c8589fdbff1 +Revises: 2862beceb4e7 +Create Date: 2026-04-24 13:35:23.265444 +""" +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '7c8589fdbff1' +down_revision: str | Sequence[str] | None = '2862beceb4e7' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('op_log', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('operator_id', sa.Integer(), nullable=True), + sa.Column('operator_name', sa.String(length=64), nullable=True), + sa.Column('module', sa.String(length=64), nullable=False), + sa.Column('action', sa.String(length=32), nullable=False), + sa.Column('method', sa.String(length=8), nullable=False), + sa.Column('path', sa.String(length=255), nullable=False), + sa.Column('status_code', sa.Integer(), nullable=False), + sa.Column('ip', sa.String(length=64), nullable=True), + sa.Column('user_agent', sa.String(length=255), nullable=True), + sa.Column('payload', sa.JSON(), nullable=True), + sa.Column('cost_ms', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['operator_id'], ['user.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_op_log_created_at'), 'op_log', ['created_at'], unique=False) + op.create_index(op.f('ix_op_log_module'), 'op_log', ['module'], unique=False) + op.create_index(op.f('ix_op_log_operator_id'), 'op_log', ['operator_id'], unique=False) + op.create_table('platform_account', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('owner_id', sa.Integer(), nullable=False), + sa.Column('platform_name', sa.String(length=64), nullable=False), + sa.Column('product_code', sa.String(length=64), nullable=True), + sa.Column('login', sa.String(length=128), nullable=False), + sa.Column('password_cipher', sa.Text(), nullable=False), + sa.Column('google_key_cipher', sa.Text(), nullable=True), + sa.Column('remark', sa.String(length=255), nullable=True), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('last_test_result', sa.String(length=16), nullable=False), + sa.Column('last_test_message', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_platform_account_owner_id'), 'platform_account', ['owner_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_platform_account_owner_id'), table_name='platform_account') + op.drop_table('platform_account') + op.drop_index(op.f('ix_op_log_operator_id'), table_name='op_log') + op.drop_index(op.f('ix_op_log_module'), table_name='op_log') + op.drop_index(op.f('ix_op_log_created_at'), table_name='op_log') + op.drop_table('op_log') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/a74b3a3db72f_p5_task_schedule.py b/backend/alembic/versions/a74b3a3db72f_p5_task_schedule.py new file mode 100644 index 0000000..866b2ea --- /dev/null +++ b/backend/alembic/versions/a74b3a3db72f_p5_task_schedule.py @@ -0,0 +1,131 @@ +"""p5_task_schedule + +Revision ID: a74b3a3db72f +Revises: 0b286bfebd6e +Create Date: 2026-04-24 14:23:34.316948 +""" +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a74b3a3db72f' +down_revision: str | Sequence[str] | None = '0b286bfebd6e' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('schedule', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('skill_id', sa.Integer(), nullable=False), + sa.Column('product_id', sa.Integer(), nullable=True), + sa.Column('input_data', sa.JSON(), nullable=True), + sa.Column('cron', sa.String(length=64), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.Column('last_run_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('next_run_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_task_id', sa.Integer(), nullable=True), + sa.Column('owner_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['product_id'], ['product.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['skill_id'], ['skill.id'], ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_schedule_skill_id'), 'schedule', ['skill_id'], unique=False) + op.create_table('task', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('task_no', sa.String(length=32), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('skill_id', sa.Integer(), nullable=False), + sa.Column('skill_code', sa.String(length=64), nullable=False), + sa.Column('product_id', sa.Integer(), nullable=True), + sa.Column('executor_type', sa.String(length=16), nullable=False), + sa.Column('input_data', sa.JSON(), nullable=True), + sa.Column('resolved_input', sa.JSON(), nullable=True), + sa.Column('result_data', sa.JSON(), nullable=True), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('external_ref', sa.String(length=128), nullable=True), + sa.Column('source', sa.String(length=16), nullable=False), + sa.Column('schedule_id', sa.Integer(), nullable=True), + sa.Column('retry_of_id', sa.Integer(), nullable=True), + sa.Column('operator_id', sa.Integer(), nullable=True), + sa.Column('operator_name', sa.String(length=64), nullable=True), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('cost_ms', sa.Integer(), nullable=False), + sa.Column('timeout_seconds', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['operator_id'], ['user.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['product_id'], ['product.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['retry_of_id'], ['task.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['schedule_id'], ['schedule.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['skill_id'], ['skill.id'], ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_task_external_ref'), 'task', ['external_ref'], unique=False) + op.create_index(op.f('ix_task_operator_id'), 'task', ['operator_id'], unique=False) + op.create_index(op.f('ix_task_product_id'), 'task', ['product_id'], unique=False) + op.create_index(op.f('ix_task_schedule_id'), 'task', ['schedule_id'], unique=False) + op.create_index(op.f('ix_task_skill_id'), 'task', ['skill_id'], unique=False) + op.create_index(op.f('ix_task_status'), 'task', ['status'], unique=False) + op.create_index(op.f('ix_task_task_no'), 'task', ['task_no'], unique=True) + op.create_table('schedule_log', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('schedule_id', sa.Integer(), nullable=False), + sa.Column('task_id', sa.Integer(), nullable=True), + sa.Column('result', sa.String(length=16), nullable=False), + sa.Column('message', sa.String(length=255), nullable=True), + sa.Column('triggered_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('cost_ms', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['schedule_id'], ['schedule.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['task_id'], ['task.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_schedule_log_schedule_id'), 'schedule_log', ['schedule_id'], unique=False) + op.create_index(op.f('ix_schedule_log_triggered_at'), 'schedule_log', ['triggered_at'], unique=False) + op.create_table('task_log', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('task_id', sa.Integer(), nullable=False), + sa.Column('level', sa.String(length=8), nullable=False), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('external_event_id', sa.String(length=128), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['task_id'], ['task.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('task_id', 'external_event_id', name='uq_task_log_event') + ) + op.create_index(op.f('ix_task_log_created_at'), 'task_log', ['created_at'], unique=False) + op.create_index(op.f('ix_task_log_task_id'), 'task_log', ['task_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_task_log_task_id'), table_name='task_log') + op.drop_index(op.f('ix_task_log_created_at'), table_name='task_log') + op.drop_table('task_log') + op.drop_index(op.f('ix_schedule_log_triggered_at'), table_name='schedule_log') + op.drop_index(op.f('ix_schedule_log_schedule_id'), table_name='schedule_log') + op.drop_table('schedule_log') + op.drop_index(op.f('ix_task_task_no'), table_name='task') + op.drop_index(op.f('ix_task_status'), table_name='task') + op.drop_index(op.f('ix_task_skill_id'), table_name='task') + op.drop_index(op.f('ix_task_schedule_id'), table_name='task') + op.drop_index(op.f('ix_task_product_id'), table_name='task') + op.drop_index(op.f('ix_task_operator_id'), table_name='task') + op.drop_index(op.f('ix_task_external_ref'), table_name='task') + op.drop_table('task') + op.drop_index(op.f('ix_schedule_skill_id'), table_name='schedule') + op.drop_table('schedule') + # ### end Alembic commands ### diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/router.py b/backend/app/api/router.py new file mode 100644 index 0000000..dd14346 --- /dev/null +++ b/backend/app/api/router.py @@ -0,0 +1,50 @@ +"""聚合所有 v1 路由。""" +from __future__ import annotations + +from fastapi import APIRouter + +from app.api.v1 import ( + auth, + callbacks, + channel_codes, + dashboard, + executor, + health, + llm, + lobster_config, + menus, + op_logs, + organizations, + platform_accounts, + platforms, + products, + roles, + schedules, + scripts, + skills, + tasks, + users, +) + +api_router = APIRouter(prefix="/api/v1") +api_router.include_router(health.router) +api_router.include_router(auth.router) +api_router.include_router(organizations.router) +api_router.include_router(roles.router) +api_router.include_router(users.router) +api_router.include_router(menus.router) +api_router.include_router(platform_accounts.router) +api_router.include_router(platforms.router) +api_router.include_router(channel_codes.router) +api_router.include_router(products.router) +api_router.include_router(scripts.router) +api_router.include_router(skills.router) +api_router.include_router(skills.log_router) +api_router.include_router(tasks.router) +api_router.include_router(schedules.router) +api_router.include_router(callbacks.router) +api_router.include_router(dashboard.router) +api_router.include_router(op_logs.router) +api_router.include_router(executor.router) +api_router.include_router(lobster_config.router) +api_router.include_router(llm.router) diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..8f9a7bb --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,60 @@ +"""Auth:登录 / 刷新 / me / 登出。""" +from __future__ import annotations + +from fastapi import APIRouter + +from app.core.deps import CurrentUser, DbDep +from app.core.exceptions import ok +from app.schemas.auth import ( + LoginRequest, + MeResponse, + RefreshRequest, + RoleBrief, + TokenPair, + UserInfo, +) +from app.services import auth_service +from app.services.rbac_service import build_menu_tree, get_user_menus, get_user_perm_codes + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/login") +async def login(body: LoginRequest, db: DbDep) -> dict: + _user, pair = await auth_service.login(db, body.username, body.password) + return ok(pair.model_dump()) + + +@router.post("/refresh") +async def refresh(body: RefreshRequest, db: DbDep) -> dict: + pair: TokenPair = await auth_service.refresh(db, body.refresh_token) + return ok(pair.model_dump()) + + +@router.post("/logout") +async def logout(_user: CurrentUser) -> dict: + # P2:无状态 JWT,客户端清本地 token 即可。 + # 未来可在 Redis 建 denylist 实现服务端吊销。 + return ok() + + +@router.get("/me") +async def me(user: CurrentUser, db: DbDep) -> dict: + perm_codes = await get_user_perm_codes(db, user) + menus = await get_user_menus(db, user) + tree = build_menu_tree(menus) + + user_info = UserInfo( + id=user.id, + username=user.username, + nickname=user.nickname, + email=user.email, + avatar=user.avatar, + dept_id=user.dept_id, + dept_name=user.dept.name if user.dept else None, + is_superuser=user.is_superuser, + roles=[RoleBrief.model_validate(r) for r in user.roles], + perm_codes=perm_codes, + ) + resp = MeResponse(user=user_info, menu_tree=tree) + return ok(resp.model_dump()) diff --git a/backend/app/api/v1/callbacks.py b/backend/app/api/v1/callbacks.py new file mode 100644 index 0000000..41dbf35 --- /dev/null +++ b/backend/app/api/v1/callbacks.py @@ -0,0 +1,69 @@ +"""龙虾回调端点:接收外部任务状态更新。 + +签名校验的 secret 来自 DB(lobster_config)优先,.env 兜底。未配置时跳过校验以便本地自测。 +""" +from __future__ import annotations + +import hashlib +import hmac + +from fastapi import APIRouter, Header, Request +from sqlalchemy import select + +from app.core.deps import DbDep +from app.core.exceptions import AppError, ok +from app.models import Task +from app.services import lobster_config_service, task_service + +router = APIRouter(prefix="/callbacks", tags=["callback"]) + + +async def _verify_signature(db, body: bytes, signature: str | None) -> None: + eff = await lobster_config_service.load_effective(db) + secret = eff.callback_secret + if not secret: + # 未配置 secret 时跳过校验,便于本地自测 + return + if not signature: + raise AppError("签名缺失", code=401, http_status=401) + expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, signature): + raise AppError("签名无效", code=401, http_status=401) + + +@router.post("/lobster") +async def lobster_callback( + request: Request, + db: DbDep, + x_loom_signature: str | None = Header(default=None), +) -> dict: + body = await request.body() + await _verify_signature(db, body, x_loom_signature) + + payload = await request.json() + run_id = str(payload.get("run_id") or "") + status_ = payload.get("status") + if not run_id: + raise AppError("run_id 缺失", code=400, http_status=400) + + t = (await db.execute(select(Task).where(Task.external_ref == run_id))).scalar_one_or_none() + if t is None: + return ok({"handled": False, "reason": "unknown run_id"}) + + for log in payload.get("logs") or []: + await task_service._append_log( + db, t.id, + str(log.get("level", "INFO")), + str(log.get("message", "")), + str(log.get("event_id")) if log.get("event_id") else None, + ) + if status_ == "SUCCEEDED": + t.result_data = payload.get("output") + await task_service._transition(db, t, "SUCCEEDED") + elif status_ == "FAILED": + t.error_message = payload.get("message") + await task_service._transition(db, t, "FAILED") + elif status_ == "TIMEOUT": + t.error_message = payload.get("message") or "timeout" + await task_service._transition(db, t, "TIMEOUT") + return ok({"handled": True}) diff --git a/backend/app/api/v1/channel_codes.py b/backend/app/api/v1/channel_codes.py new file mode 100644 index 0000000..e238dd0 --- /dev/null +++ b/backend/app/api/v1/channel_codes.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from app.core.deps import DbDep, require_perm +from app.core.exceptions import ok +from app.schemas.channel_code import ChannelCreate, ChannelRead, ChannelUpdate +from app.services import channel_code_service + +router = APIRouter(prefix="/channel-codes", tags=["channel-code"]) + + +@router.get("", dependencies=[Depends(require_perm("product:channel:view"))]) +async def list_channels( + db: DbDep, + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=200), + keyword: str | None = None, + type: str | None = Query(default=None, alias="type"), +) -> dict: + rows, total = await channel_code_service.paginate(db, page, size, keyword, type) + return ok({ + "list": [ChannelRead.model_validate(r).model_dump() for r in rows], + "total": total, + "page": page, + "size": size, + }) + + +@router.get("/all", dependencies=[Depends(require_perm("product:channel:view"))]) +async def list_all_channels(db: DbDep) -> dict: + rows = await channel_code_service.list_all(db) + return ok([ChannelRead.model_validate(r).model_dump() for r in rows]) + + +@router.post("", dependencies=[Depends(require_perm("product:channel:create"))]) +async def create_channel(data: ChannelCreate, db: DbDep) -> dict: + c = await channel_code_service.create(db, data) + return ok(ChannelRead.model_validate(c).model_dump()) + + +@router.patch("/{cid}", dependencies=[Depends(require_perm("product:channel:update"))]) +async def update_channel(cid: int, data: ChannelUpdate, db: DbDep) -> dict: + c = await channel_code_service.update(db, cid, data) + return ok(ChannelRead.model_validate(c).model_dump()) + + +@router.delete("/{cid}", dependencies=[Depends(require_perm("product:channel:delete"))]) +async def delete_channel(cid: int, db: DbDep) -> dict: + await channel_code_service.delete(db, cid) + return ok() diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py new file mode 100644 index 0000000..6623705 --- /dev/null +++ b/backend/app/api/v1/dashboard.py @@ -0,0 +1,270 @@ +"""仪表盘 & 统计报表聚合接口。""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select + +from app.core.deps import DbDep, require_perm +from app.core.exceptions import ok +from app.models import Organization, Schedule, Skill, Task, User + +router = APIRouter(prefix="/dashboard", tags=["dashboard"]) + + +def _today_start_utc() -> datetime: + now = datetime.now(timezone.utc) + return now.replace(hour=0, minute=0, second=0, microsecond=0) + + +@router.get("/summary", dependencies=[Depends(require_perm("dashboard:view"))]) +async def summary(db: DbDep) -> dict: + today = _today_start_utc() + + total_users = (await db.execute(select(func.count(User.id)))).scalar() or 0 + total_skills = ( + await db.execute(select(func.count(Skill.id)).where(Skill.status == "ACTIVE")) + ).scalar() or 0 + total_tasks = (await db.execute(select(func.count(Task.id)))).scalar() or 0 + enabled_schedules = ( + await db.execute(select(func.count(Schedule.id)).where(Schedule.enabled.is_(True))) + ).scalar() or 0 + + today_total = (await db.execute( + select(func.count(Task.id)).where(Task.created_at >= today) + )).scalar() or 0 + today_succeeded = (await db.execute( + select(func.count(Task.id)).where(Task.created_at >= today, Task.status == "SUCCEEDED") + )).scalar() or 0 + today_failed = (await db.execute( + select(func.count(Task.id)).where( + Task.created_at >= today, Task.status.in_(["FAILED", "TIMEOUT"]) + ) + )).scalar() or 0 + running = (await db.execute( + select(func.count(Task.id)).where( + Task.status.in_(["PENDING", "QUEUED", "RUNNING"]) + ) + )).scalar() or 0 + + # 活跃用户:近 7 天内有创建任务的 distinct 人数 + active_start = datetime.now(timezone.utc) - timedelta(days=7) + active_users = (await db.execute( + select(func.count(func.distinct(Task.operator_id))) + .where(Task.created_at >= active_start, Task.operator_id.is_not(None)) + )).scalar() or 0 + + status_rows = (await db.execute( + select(Task.status, func.count(Task.id)).group_by(Task.status) + )).all() + by_status = {s: int(c) for s, c in status_rows} + + recent = (await db.execute(select(Task).order_by(Task.id.desc()).limit(8))).scalars().all() + recent_tasks = [ + { + "id": t.id, + "task_no": t.task_no, + "name": t.name, + "skill_code": t.skill_code, + "executor_type": t.executor_type, + "product_id": t.product_id, + "operator_name": t.operator_name, + "status": t.status, + "cost_ms": t.cost_ms, + "created_at": t.created_at.isoformat() if t.created_at else None, + } + for t in recent + ] + + return ok({ + "total_users": int(total_users), + "total_skills": int(total_skills), + "total_tasks": int(total_tasks), + "enabled_schedules": int(enabled_schedules), + + "today_total": int(today_total), + "today_succeeded": int(today_succeeded), + "today_failed": int(today_failed), + "running": int(running), + "active_users_7d": int(active_users), + + "by_status": by_status, + "recent_tasks": recent_tasks, + }) + + +@router.get("/charts", dependencies=[Depends(require_perm("dashboard:view"))]) +async def charts(db: DbDep, days: int = Query(7, ge=1, le=30)) -> dict: + """仪表盘图表:7d 趋势 + 执行器分布 + Top5 Skill + 按部门统计。""" + now = datetime.now(timezone.utc) + start = (now - timedelta(days=days - 1)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + + # 1) 每天总量 + 成功数 + total_rows = (await db.execute( + select( + func.date_trunc("day", Task.created_at).label("day"), + func.count(Task.id), + ) + .where(Task.created_at >= start) + .group_by("day") + )).all() + ok_rows = (await db.execute( + select( + func.date_trunc("day", Task.created_at).label("day"), + func.count(Task.id), + ) + .where(Task.created_at >= start, Task.status == "SUCCEEDED") + .group_by("day") + )).all() + total_by_day = {d.date().isoformat(): int(c) for d, c in total_rows if d} + ok_by_day = {d.date().isoformat(): int(c) for d, c in ok_rows if d} + trend: list[dict] = [] + for i in range(days): + day_dt = start + timedelta(days=i) + day_key = day_dt.date().isoformat() + trend.append({ + "day": day_key, + "label": day_dt.strftime("%m/%d"), + "total": total_by_day.get(day_key, 0), + "succeeded": ok_by_day.get(day_key, 0), + }) + + # 2) 执行器分布 + exec_rows = (await db.execute( + select(Task.executor_type, func.count(Task.id)).group_by(Task.executor_type) + )).all() + exec_total = sum(int(c) for _, c in exec_rows) or 1 + executor_split = [ + { + "key": et, + "label": "龙虾 Agent" if et == "LOBSTER" else "脚本引擎", + "count": int(c), + "percent": round(int(c) / exec_total * 100, 1), + } + for et, c in exec_rows + ] + + # 3) Top 5 Skill(最近 days 天) + top_skill_rows = (await db.execute( + select(Task.skill_code, func.count(Task.id).label("cnt")) + .where(Task.created_at >= start) + .group_by(Task.skill_code) + .order_by(func.count(Task.id).desc()) + .limit(5) + )).all() + top_max = max((int(c) for _, c in top_skill_rows), default=1) + top_skills = [ + { + "skill_code": sc, + "count": int(c), + "percent": round(int(c) / top_max * 100, 1), + } + for sc, c in top_skill_rows + ] + + # 4) 按部门统计(通过 operator → User.dept_id → Organization.name) + op_rows = (await db.execute( + select(Task.operator_id, func.count(Task.id)) + .where(Task.operator_id.is_not(None), Task.created_at >= start) + .group_by(Task.operator_id) + )).all() + dept_stats: list[dict] = [] + if op_rows: + op_ids = [oid for oid, _ in op_rows] + user_rows = (await db.execute( + select(User.id, User.dept_id).where(User.id.in_(op_ids)) + )).all() + user_dept = dict(user_rows) + dept_counts: dict[int | None, int] = {} + for oid, c in op_rows: + did = user_dept.get(oid) + dept_counts[did] = dept_counts.get(did, 0) + int(c) + dept_ids = [d for d in dept_counts if d is not None] + name_map: dict[int, str] = {} + if dept_ids: + rows = (await db.execute( + select(Organization.id, Organization.name).where(Organization.id.in_(dept_ids)) + )).all() + name_map = dict(rows) + dept_total = sum(dept_counts.values()) or 1 + dept_stats = sorted( + [ + { + "dept_id": did, + "dept_name": name_map.get(did) if did else "未分配部门", + "count": c, + "percent": round(c / dept_total * 100, 1), + } + for did, c in dept_counts.items() + ], + key=lambda x: x["count"], + reverse=True, + ) + + return ok({ + "days": days, + "trend": trend, + "executor_split": executor_split, + "top_skills": top_skills, + "dept_stats": dept_stats, + }) + + +@router.get("/report", dependencies=[Depends(require_perm("log:report:view"))]) +async def report( + db: DbDep, + days: int = Query(7, ge=1, le=90), +) -> dict: + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + + daily_rows = (await db.execute( + select( + func.date_trunc("day", Task.created_at).label("day"), + Task.status, + func.count(Task.id), + ) + .where(Task.created_at >= start) + .group_by("day", Task.status) + .order_by("day") + )).all() + daily: dict[str, dict[str, int]] = {} + for day, s, c in daily_rows: + d = day.date().isoformat() if day else "n/a" + daily.setdefault(d, {})[s] = int(c) + + top_skills_rows = (await db.execute( + select(Task.skill_code, func.count(Task.id).label("cnt")) + .where(Task.created_at >= start) + .group_by(Task.skill_code) + .order_by(func.count(Task.id).desc()) + .limit(5) + )).all() + top_skills = [{"skill_code": s, "count": int(c)} for s, c in top_skills_rows] + + avg_cost = (await db.execute( + select(func.avg(Task.cost_ms)) + .where(Task.created_at >= start, Task.status == "SUCCEEDED") + )).scalar() + success = (await db.execute( + select(func.count(Task.id)).where( + Task.created_at >= start, Task.status == "SUCCEEDED" + ) + )).scalar() or 0 + failure = (await db.execute( + select(func.count(Task.id)).where( + Task.created_at >= start, Task.status.in_(["FAILED", "TIMEOUT"]) + ) + )).scalar() or 0 + + return ok({ + "days": days, + "daily": daily, + "top_skills": top_skills, + "avg_cost_ms": int(avg_cost or 0), + "success_count": int(success), + "failure_count": int(failure), + }) diff --git a/backend/app/api/v1/executor.py b/backend/app/api/v1/executor.py new file mode 100644 index 0000000..8a83c8b --- /dev/null +++ b/backend/app/api/v1/executor.py @@ -0,0 +1,72 @@ +"""执行器健康状态聚合接口。""" +from __future__ import annotations + +import shutil +import sys + +from fastapi import APIRouter, Depends +from sqlalchemy import func, select + +from app.core.config import settings +from app.core.deps import DbDep, require_perm +from app.core.exceptions import ok +from app.models import LLMProvider, LLMUsage, Skill, Task +from app.services import lobster_config_service + +router = APIRouter(prefix="/executors", tags=["executor"]) + + +@router.get("/status", dependencies=[Depends(require_perm("system:executor:view"))]) +async def executor_status(db: DbDep) -> dict: + lobster_eff = await lobster_config_service.load_effective(db) + lobster_tasks = (await db.execute( + select(func.count()).select_from(Task).where(Task.executor_type == "LOBSTER") + )).scalar() or 0 + script_tasks = (await db.execute( + select(func.count()).select_from(Task).where(Task.executor_type == "SCRIPT") + )).scalar() or 0 + + providers_active = (await db.execute( + select(func.count()).select_from(LLMProvider).where(LLMProvider.status == "ACTIVE") + )).scalar() or 0 + llm_calls = (await db.execute(select(func.count(LLMUsage.id)))).scalar() or 0 + llm_cost = float((await db.execute(select(func.sum(LLMUsage.cost_usd)))).scalar() or 0) + + skill_total = (await db.execute(select(func.count(Skill.id)))).scalar() or 0 + + node = "localhost/subprocess" + python_bin = sys.executable + node_bin = shutil.which("node") + bash_bin = shutil.which("bash") + + return ok({ + "lobster": { + "configured": not lobster_eff.is_mock, + "mode": "real" if not lobster_eff.is_mock else "mock", + "api_base": lobster_eff.api_base or "(未配置)", + "api_base_source": lobster_eff.api_base_source, + "api_key_source": lobster_eff.api_key_source, + "timeout_seconds": lobster_eff.timeout_seconds, + "poll_interval_seconds": lobster_eff.poll_interval_seconds, + "callback_configured": bool(lobster_eff.callback_secret), + "callback_source": lobster_eff.callback_secret_source, + "task_count": int(lobster_tasks), + }, + "script": { + "mode": "subprocess", + "node": node, + "python": python_bin, + "node_bin": node_bin, + "bash_bin": bash_bin, + "task_count": int(script_tasks), + }, + "llm": { + "providers_active": int(providers_active), + "total_calls": int(llm_calls), + "total_cost_usd": round(llm_cost, 6), + }, + "meta": { + "skill_total": int(skill_total), + "app_env": settings.APP_ENV, + }, + }) diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py new file mode 100644 index 0000000..628ace5 --- /dev/null +++ b/backend/app/api/v1/health.py @@ -0,0 +1,33 @@ +"""健康检查:验证 DB + Redis 连通性。""" +from __future__ import annotations + +import redis.asyncio as aioredis +from fastapi import APIRouter +from sqlalchemy import text + +from app.core.config import settings +from app.core.exceptions import ok +from app.db.session import engine + +router = APIRouter(tags=["health"]) + + +@router.get("/healthz") +async def healthz() -> dict: + db_status = "ok" + redis_status = "ok" + + try: + async with engine.connect() as conn: + await conn.execute(text("SELECT 1")) + except Exception as e: # noqa: BLE001 + db_status = f"error: {e.__class__.__name__}" + + try: + client = aioredis.from_url(settings.REDIS_URL) + await client.ping() + await client.aclose() + except Exception as e: # noqa: BLE001 + redis_status = f"error: {e.__class__.__name__}" + + return ok({"db": db_status, "redis": redis_status, "env": settings.APP_ENV}) diff --git a/backend/app/api/v1/llm.py b/backend/app/api/v1/llm.py new file mode 100644 index 0000000..638b011 --- /dev/null +++ b/backend/app/api/v1/llm.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from app.core.deps import CurrentUser, DbDep, require_perm +from app.core.exceptions import ok +from app.schemas.llm import ( + ChatRequest, + ModelCreate, + ModelUpdate, + ProviderCreate, + ProviderUpdate, + RuleCreate, + RuleUpdate, + UsageRead, +) +from app.services import llm_service + +router = APIRouter(prefix="/llm", tags=["llm"]) + + +# -------- Providers -------- + +@router.get("/providers", dependencies=[Depends(require_perm("system:executor:view"))]) +async def list_providers(db: DbDep) -> dict: + return ok([p.model_dump() for p in await llm_service.list_providers(db)]) + + +@router.post("/providers", dependencies=[Depends(require_perm("system:executor:update"))]) +async def create_provider(data: ProviderCreate, db: DbDep) -> dict: + p = await llm_service.create_provider(db, data) + return ok(llm_service.provider_to_read(p).model_dump()) + + +@router.patch( + "/providers/{pid}", dependencies=[Depends(require_perm("system:executor:update"))] +) +async def update_provider(pid: int, data: ProviderUpdate, db: DbDep) -> dict: + p = await llm_service.update_provider(db, pid, data) + return ok(llm_service.provider_to_read(p).model_dump()) + + +@router.delete( + "/providers/{pid}", dependencies=[Depends(require_perm("system:executor:update"))] +) +async def delete_provider(pid: int, db: DbDep) -> dict: + await llm_service.delete_provider(db, pid) + return ok() + + +# -------- Models -------- + +@router.get("/models", dependencies=[Depends(require_perm("system:executor:view"))]) +async def list_models(db: DbDep) -> dict: + return ok([m.model_dump() for m in await llm_service.list_models(db)]) + + +@router.post("/models", dependencies=[Depends(require_perm("system:executor:update"))]) +async def create_model(data: ModelCreate, db: DbDep) -> dict: + m = await llm_service.create_model(db, data) + return ok((await llm_service.model_to_read(db, m)).model_dump()) + + +@router.patch( + "/models/{mid}", dependencies=[Depends(require_perm("system:executor:update"))] +) +async def update_model(mid: int, data: ModelUpdate, db: DbDep) -> dict: + m = await llm_service.update_model(db, mid, data) + return ok((await llm_service.model_to_read(db, m)).model_dump()) + + +@router.delete( + "/models/{mid}", dependencies=[Depends(require_perm("system:executor:update"))] +) +async def delete_model(mid: int, db: DbDep) -> dict: + await llm_service.delete_model(db, mid) + return ok() + + +# -------- Routing -------- + +@router.get("/rules", dependencies=[Depends(require_perm("system:executor:view"))]) +async def list_rules(db: DbDep) -> dict: + return ok([r.model_dump() for r in await llm_service.list_rules(db)]) + + +@router.post("/rules", dependencies=[Depends(require_perm("system:executor:update"))]) +async def create_rule(data: RuleCreate, db: DbDep) -> dict: + r = await llm_service.create_rule(db, data) + return ok({ + "id": r.id, "name": r.name, "priority": r.priority, + "conditions": r.conditions, "model_id": r.model_id, + "enabled": r.enabled, "description": r.description, + }) + + +@router.patch( + "/rules/{rid}", dependencies=[Depends(require_perm("system:executor:update"))] +) +async def update_rule(rid: int, data: RuleUpdate, db: DbDep) -> dict: + r = await llm_service.update_rule(db, rid, data) + return ok({ + "id": r.id, "name": r.name, "priority": r.priority, + "conditions": r.conditions, "model_id": r.model_id, + "enabled": r.enabled, "description": r.description, + }) + + +@router.delete( + "/rules/{rid}", dependencies=[Depends(require_perm("system:executor:update"))] +) +async def delete_rule(rid: int, db: DbDep) -> dict: + await llm_service.delete_rule(db, rid) + return ok() + + +# -------- Chat / Usage -------- + +@router.post("/chat", dependencies=[Depends(require_perm("system:executor:view"))]) +async def chat(body: ChatRequest, db: DbDep, user: CurrentUser) -> dict: + resp = await llm_service.call_chat( + db, + operator=user, + messages=body.messages, + model_label=body.model, + conditions=body.conditions, + temperature=body.temperature, + max_tokens=body.max_tokens, + source=body.source, + ) + return ok(resp.model_dump()) + + +@router.get("/usage", dependencies=[Depends(require_perm("system:executor:view"))]) +async def list_usage( + db: DbDep, + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=200), + provider_id: int | None = None, + source: str | None = None, +) -> dict: + rows, total = await llm_service.paginate_usage(db, page, size, provider_id, source) + return ok({ + "list": [UsageRead.model_validate(r).model_dump(mode="json") for r in rows], + "total": total, + "page": page, + "size": size, + }) + + +@router.get("/usage/summary", dependencies=[Depends(require_perm("system:executor:view"))]) +async def usage_summary(db: DbDep) -> dict: + return ok(await llm_service.usage_summary(db)) diff --git a/backend/app/api/v1/lobster_config.py b/backend/app/api/v1/lobster_config.py new file mode 100644 index 0000000..ca18175 --- /dev/null +++ b/backend/app/api/v1/lobster_config.py @@ -0,0 +1,21 @@ +"""龙虾 Agent 运行时配置 API(UI 可编辑)。""" +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.core.deps import DbDep, require_perm +from app.core.exceptions import ok +from app.schemas.lobster_config import LobsterConfigUpdate +from app.services import lobster_config_service + +router = APIRouter(prefix="/lobster-config", tags=["lobster-config"]) + + +@router.get("", dependencies=[Depends(require_perm("system:executor:view"))]) +async def get_config(db: DbDep) -> dict: + return ok((await lobster_config_service.read(db)).model_dump()) + + +@router.put("", dependencies=[Depends(require_perm("system:executor:update"))]) +async def update_config(data: LobsterConfigUpdate, db: DbDep) -> dict: + return ok((await lobster_config_service.update(db, data)).model_dump()) diff --git a/backend/app/api/v1/menus.py b/backend/app/api/v1/menus.py new file mode 100644 index 0000000..1e6ef47 --- /dev/null +++ b/backend/app/api/v1/menus.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.core.deps import DbDep, require_perm +from app.core.exceptions import ok +from app.schemas.menu import MenuCreate, MenuRead, MenuUpdate +from app.services import menu_service + +router = APIRouter(prefix="/menus", tags=["menu"]) + + +@router.get("", dependencies=[Depends(require_perm("auth:menu:view"))]) +async def list_menus(db: DbDep) -> dict: + items = await menu_service.list_all(db) + return ok([MenuRead.model_validate(m).model_dump() for m in items]) + + +@router.get("/tree", dependencies=[Depends(require_perm("auth:menu:view"))]) +async def menu_tree(db: DbDep) -> dict: + items = await menu_service.list_all(db) + tree = menu_service.build_tree(items) + return ok([t.model_dump() for t in tree]) + + +@router.post("", dependencies=[Depends(require_perm("auth:menu:create"))]) +async def create_menu(data: MenuCreate, db: DbDep) -> dict: + m = await menu_service.create(db, data) + return ok(MenuRead.model_validate(m).model_dump()) + + +@router.patch("/{mid}", dependencies=[Depends(require_perm("auth:menu:update"))]) +async def update_menu(mid: int, data: MenuUpdate, db: DbDep) -> dict: + m = await menu_service.update(db, mid, data) + return ok(MenuRead.model_validate(m).model_dump()) + + +@router.delete("/{mid}", dependencies=[Depends(require_perm("auth:menu:delete"))]) +async def delete_menu(mid: int, db: DbDep) -> dict: + await menu_service.delete(db, mid) + return ok() diff --git a/backend/app/api/v1/op_logs.py b/backend/app/api/v1/op_logs.py new file mode 100644 index 0000000..116a29d --- /dev/null +++ b/backend/app/api/v1/op_logs.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select + +from app.core.deps import DbDep, require_perm +from app.core.exceptions import ok +from app.models import OpLog + +router = APIRouter(prefix="/op-logs", tags=["op-log"]) + + +@router.get("", dependencies=[Depends(require_perm("log:op:view"))]) +async def list_op_logs( + db: DbDep, + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=200), + module: str | None = None, + operator_id: int | None = None, +) -> dict: + stmt = select(OpLog) + if module: + stmt = stmt.where(OpLog.module == module) + if operator_id: + stmt = stmt.where(OpLog.operator_id == operator_id) + + total = (await db.execute( + select(func.count()).select_from(stmt.order_by(None).subquery()) + )).scalar() or 0 + stmt = stmt.order_by(OpLog.id.desc()).offset((page - 1) * size).limit(size) + rows = list((await db.execute(stmt)).scalars().all()) + + def _row(r: OpLog) -> dict: + return { + "id": r.id, + "operator_id": r.operator_id, + "operator_name": r.operator_name, + "module": r.module, + "action": r.action, + "method": r.method, + "path": r.path, + "status_code": r.status_code, + "ip": r.ip, + "cost_ms": r.cost_ms, + "payload": r.payload, + "created_at": r.created_at.isoformat() if r.created_at else None, + } + + return ok({"list": [_row(r) for r in rows], "total": total, "page": page, "size": size}) diff --git a/backend/app/api/v1/organizations.py b/backend/app/api/v1/organizations.py new file mode 100644 index 0000000..732434b --- /dev/null +++ b/backend/app/api/v1/organizations.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.core.deps import DbDep, require_perm +from app.core.exceptions import ok +from app.schemas.organization import OrgCreate, OrgRead, OrgUpdate +from app.services import org_service + +router = APIRouter(prefix="/organizations", tags=["organization"]) + + +@router.get("", dependencies=[Depends(require_perm("auth:org:view"))]) +async def list_orgs(db: DbDep) -> dict: + items = await org_service.list_all(db) + return ok([OrgRead.model_validate(o).model_dump() for o in items]) + + +@router.get("/tree", dependencies=[Depends(require_perm("auth:org:view"))]) +async def org_tree(db: DbDep) -> dict: + items = await org_service.list_all(db) + tree = org_service.build_tree(items) + return ok([t.model_dump() for t in tree]) + + +@router.post("", dependencies=[Depends(require_perm("auth:org:create"))]) +async def create_org(data: OrgCreate, db: DbDep) -> dict: + o = await org_service.create(db, data) + return ok(OrgRead.model_validate(o).model_dump()) + + +@router.patch("/{oid}", dependencies=[Depends(require_perm("auth:org:update"))]) +async def update_org(oid: int, data: OrgUpdate, db: DbDep) -> dict: + o = await org_service.update(db, oid, data) + return ok(OrgRead.model_validate(o).model_dump()) + + +@router.delete("/{oid}", dependencies=[Depends(require_perm("auth:org:delete"))]) +async def delete_org(oid: int, db: DbDep) -> dict: + await org_service.delete(db, oid) + return ok() diff --git a/backend/app/api/v1/platform_accounts.py b/backend/app/api/v1/platform_accounts.py new file mode 100644 index 0000000..5a93e43 --- /dev/null +++ b/backend/app/api/v1/platform_accounts.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from app.core.deps import CurrentUser, DbDep, require_perm +from app.core.exceptions import ok +from app.schemas.platform_account import PlatformAccountCreate, PlatformAccountUpdate +from app.services import platform_account_service as pa_service + +router = APIRouter(prefix="/platform-accounts", tags=["platform-account"]) + + +@router.get("", dependencies=[Depends(require_perm("platform-account:view"))]) +async def list_accounts( + db: DbDep, + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=200), + keyword: str | None = None, + platform_name: str | None = None, + owner_id: int | None = None, +) -> dict: + rows, total = await pa_service.paginate(db, page, size, keyword, platform_name, owner_id) + return ok({ + "list": [(await pa_service.to_read(db, a)).model_dump() for a in rows], + "total": total, + "page": page, + "size": size, + }) + + +@router.get("/{aid}", dependencies=[Depends(require_perm("platform-account:view"))]) +async def get_account(aid: int, db: DbDep) -> dict: + acc = await pa_service.get(db, aid) + return ok((await pa_service.to_read(db, acc)).model_dump()) + + +@router.post("", dependencies=[Depends(require_perm("platform-account:create"))]) +async def create_account(data: PlatformAccountCreate, db: DbDep, user: CurrentUser) -> dict: + acc = await pa_service.create(db, owner_id=user.id, data=data) + return ok((await pa_service.to_read(db, acc)).model_dump()) + + +@router.patch("/{aid}", dependencies=[Depends(require_perm("platform-account:update"))]) +async def update_account(aid: int, data: PlatformAccountUpdate, db: DbDep) -> dict: + acc = await pa_service.update(db, aid, data) + return ok((await pa_service.to_read(db, acc)).model_dump()) + + +@router.delete("/{aid}", dependencies=[Depends(require_perm("platform-account:delete"))]) +async def delete_account(aid: int, db: DbDep) -> dict: + await pa_service.delete(db, aid) + return ok() + + +@router.post("/{aid}/test", dependencies=[Depends(require_perm("platform-account:test"))]) +async def test_account(aid: int, db: DbDep) -> dict: + acc = await pa_service.test_connection(db, aid) + return ok((await pa_service.to_read(db, acc)).model_dump()) diff --git a/backend/app/api/v1/platforms.py b/backend/app/api/v1/platforms.py new file mode 100644 index 0000000..a4e489d --- /dev/null +++ b/backend/app/api/v1/platforms.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.core.deps import DbDep, require_perm +from app.core.exceptions import ok +from app.schemas.platform import PlatformCreate, PlatformRead, PlatformUpdate +from app.services import platform_service + +router = APIRouter(prefix="/platforms", tags=["platform"]) + + +@router.get("", dependencies=[Depends(require_perm("product:suite:view"))]) +async def list_platforms(db: DbDep) -> dict: + return ok([p.model_dump() for p in await platform_service.list_all(db)]) + + +@router.post("", dependencies=[Depends(require_perm("product:suite:create"))]) +async def create_platform(data: PlatformCreate, db: DbDep) -> dict: + p = await platform_service.create(db, data) + return ok(PlatformRead( + id=p.id, code=p.code, name=p.name, url=p.url, description=p.description, + status=p.status, product_count=0, + ).model_dump()) + + +@router.patch("/{pid}", dependencies=[Depends(require_perm("product:suite:update"))]) +async def update_platform(pid: int, data: PlatformUpdate, db: DbDep) -> dict: + p = await platform_service.update(db, pid, data) + return ok(PlatformRead( + id=p.id, code=p.code, name=p.name, url=p.url, description=p.description, + status=p.status, product_count=0, + ).model_dump()) + + +@router.delete("/{pid}", dependencies=[Depends(require_perm("product:suite:delete"))]) +async def delete_platform(pid: int, db: DbDep) -> dict: + await platform_service.delete(db, pid) + return ok() diff --git a/backend/app/api/v1/products.py b/backend/app/api/v1/products.py new file mode 100644 index 0000000..ffbb079 --- /dev/null +++ b/backend/app/api/v1/products.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from app.core.deps import DbDep, require_perm +from app.core.exceptions import ok +from app.schemas.product import ProductCreate, ProductUpdate +from app.services import product_service + +router = APIRouter(prefix="/products", tags=["product"]) + + +@router.get("", dependencies=[Depends(require_perm("product:view"))]) +async def list_products( + db: DbDep, + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=200), + keyword: str | None = None, + platform_id: int | None = None, + dept_id: int | None = None, +) -> dict: + rows, total = await product_service.paginate(db, page, size, keyword, platform_id, dept_id) + return ok({ + "list": [(await product_service.to_read(db, p)).model_dump() for p in rows], + "total": total, + "page": page, + "size": size, + }) + + +@router.get("/{pid}", dependencies=[Depends(require_perm("product:view"))]) +async def get_product(pid: int, db: DbDep) -> dict: + p = await product_service.get(db, pid) + return ok((await product_service.to_read(db, p)).model_dump()) + + +@router.post("", dependencies=[Depends(require_perm("product:create"))]) +async def create_product(data: ProductCreate, db: DbDep) -> dict: + p = await product_service.create(db, data) + return ok((await product_service.to_read(db, p)).model_dump()) + + +@router.patch("/{pid}", dependencies=[Depends(require_perm("product:update"))]) +async def update_product(pid: int, data: ProductUpdate, db: DbDep) -> dict: + p = await product_service.update(db, pid, data) + return ok((await product_service.to_read(db, p)).model_dump()) + + +@router.delete("/{pid}", dependencies=[Depends(require_perm("product:delete"))]) +async def delete_product(pid: int, db: DbDep) -> dict: + await product_service.delete(db, pid) + return ok() diff --git a/backend/app/api/v1/roles.py b/backend/app/api/v1/roles.py new file mode 100644 index 0000000..0ad7207 --- /dev/null +++ b/backend/app/api/v1/roles.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.core.deps import DbDep, require_perm +from app.core.exceptions import ok +from app.schemas.role import RoleCreate, RoleRead, RoleUpdate +from app.services import role_service + +router = APIRouter(prefix="/roles", tags=["role"]) + + +@router.get("", dependencies=[Depends(require_perm("auth:role:view"))]) +async def list_roles(db: DbDep) -> dict: + items = await role_service.list_all(db) + return ok([RoleRead.model_validate(r).model_dump() for r in items]) + + +@router.get("/{rid}", dependencies=[Depends(require_perm("auth:role:view"))]) +async def get_role(rid: int, db: DbDep) -> dict: + role = await role_service.get_with_menus(db, rid) + detail = await role_service.to_detail(db, role) + return ok(detail.model_dump()) + + +@router.post("", dependencies=[Depends(require_perm("auth:role:create"))]) +async def create_role(data: RoleCreate, db: DbDep) -> dict: + role = await role_service.create(db, data) + detail = await role_service.to_detail(db, role) + return ok(detail.model_dump()) + + +@router.patch("/{rid}", dependencies=[Depends(require_perm("auth:role:update"))]) +async def update_role(rid: int, data: RoleUpdate, db: DbDep) -> dict: + role = await role_service.update(db, rid, data) + detail = await role_service.to_detail(db, role) + return ok(detail.model_dump()) + + +@router.delete("/{rid}", dependencies=[Depends(require_perm("auth:role:delete"))]) +async def delete_role(rid: int, db: DbDep) -> dict: + await role_service.delete(db, rid) + return ok() diff --git a/backend/app/api/v1/schedules.py b/backend/app/api/v1/schedules.py new file mode 100644 index 0000000..40e955c --- /dev/null +++ b/backend/app/api/v1/schedules.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from app.core.deps import CurrentUser, DbDep, require_perm +from app.core.exceptions import ok +from app.schemas.schedule import ScheduleCreate, ScheduleUpdate +from app.services import schedule_service + +router = APIRouter(prefix="/schedules", tags=["schedule"]) + + +@router.get("", dependencies=[Depends(require_perm("system:cron:view"))]) +async def list_schedules(db: DbDep) -> dict: + return ok(await schedule_service.list_all(db)) + + +@router.get("/{sid}", dependencies=[Depends(require_perm("system:cron:view"))]) +async def get_schedule(sid: int, db: DbDep) -> dict: + return ok(await schedule_service.get(db, sid)) + + +@router.get("/{sid}/logs", dependencies=[Depends(require_perm("system:cron:view"))]) +async def list_schedule_logs( + sid: int, db: DbDep, limit: int = Query(50, ge=1, le=500) +) -> dict: + return ok(await schedule_service.list_logs(db, sid, limit)) + + +@router.post("", dependencies=[Depends(require_perm("system:cron:create"))]) +async def create_schedule(data: ScheduleCreate, db: DbDep, user: CurrentUser) -> dict: + return ok(await schedule_service.create(db, data, user)) + + +@router.patch("/{sid}", dependencies=[Depends(require_perm("system:cron:update"))]) +async def update_schedule(sid: int, data: ScheduleUpdate, db: DbDep) -> dict: + return ok(await schedule_service.update(db, sid, data)) + + +@router.post("/{sid}/toggle", dependencies=[Depends(require_perm("system:cron:update"))]) +async def toggle_schedule(sid: int, enabled: bool, db: DbDep) -> dict: + return ok(await schedule_service.toggle(db, sid, enabled)) + + +@router.post("/{sid}/trigger", dependencies=[Depends(require_perm("system:cron:update"))]) +async def trigger_now(sid: int, db: DbDep, user: CurrentUser) -> dict: + return ok(await schedule_service.trigger_now(db, sid, user)) + + +@router.delete("/{sid}", dependencies=[Depends(require_perm("system:cron:delete"))]) +async def delete_schedule(sid: int, db: DbDep) -> dict: + await schedule_service.delete(db, sid) + return ok() diff --git a/backend/app/api/v1/scripts.py b/backend/app/api/v1/scripts.py new file mode 100644 index 0000000..2015a25 --- /dev/null +++ b/backend/app/api/v1/scripts.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from app.core.deps import CurrentUser, DbDep, require_perm +from app.core.exceptions import ok +from app.schemas.script_template import ScriptCreate, ScriptRead, ScriptUpdate +from app.services import script_template_service + +router = APIRouter(prefix="/scripts", tags=["script-template"]) + + +@router.get("", dependencies=[Depends(require_perm("system:script:view"))]) +async def list_scripts( + db: DbDep, + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=200), + keyword: str | None = None, + language: str | None = None, +) -> dict: + rows, total = await script_template_service.paginate(db, page, size, keyword, language) + return ok({ + "list": [ScriptRead.model_validate(r).model_dump() for r in rows], + "total": total, + "page": page, + "size": size, + }) + + +@router.get("/all", dependencies=[Depends(require_perm("skill:view"))]) +async def list_all(db: DbDep) -> dict: + """供 Skill 编辑时选脚本模板用。""" + rows = await script_template_service.list_all(db) + return ok([ScriptRead.model_validate(r).model_dump() for r in rows]) + + +@router.post("", dependencies=[Depends(require_perm("system:script:create"))]) +async def create_script(data: ScriptCreate, db: DbDep, user: CurrentUser) -> dict: + s = await script_template_service.create(db, data, owner_id=user.id) + return ok(ScriptRead.model_validate(s).model_dump()) + + +@router.patch("/{sid}", dependencies=[Depends(require_perm("system:script:update"))]) +async def update_script(sid: int, data: ScriptUpdate, db: DbDep) -> dict: + s = await script_template_service.update(db, sid, data) + return ok(ScriptRead.model_validate(s).model_dump()) + + +@router.delete("/{sid}", dependencies=[Depends(require_perm("system:script:delete"))]) +async def delete_script(sid: int, db: DbDep) -> dict: + await script_template_service.delete(db, sid) + return ok() diff --git a/backend/app/api/v1/skills.py b/backend/app/api/v1/skills.py new file mode 100644 index 0000000..3b62f9a --- /dev/null +++ b/backend/app/api/v1/skills.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from app.core.deps import CurrentUser, DbDep, require_perm +from app.core.exceptions import ok +from app.schemas.skill import ( + SkillCallLogRead, + SkillCreate, + SkillTestRequest, + SkillUpdate, +) +from app.services import skill_service + +router = APIRouter(prefix="/skills", tags=["skill"]) + + +@router.get("", dependencies=[Depends(require_perm("skill:view"))]) +async def list_skills( + db: DbDep, + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=200), + keyword: str | None = None, + executor_type: str | None = None, + category: str | None = None, +) -> dict: + rows, total = await skill_service.paginate(db, page, size, keyword, executor_type, category) + return ok({ + "list": [skill_service.to_read(s).model_dump() for s in rows], + "total": total, + "page": page, + "size": size, + }) + + +@router.get("/{sid}", dependencies=[Depends(require_perm("skill:view"))]) +async def get_skill(sid: int, db: DbDep) -> dict: + s = await skill_service.get(db, sid) + return ok(skill_service.to_read(s).model_dump()) + + +@router.post("", dependencies=[Depends(require_perm("skill:create"))]) +async def create_skill(data: SkillCreate, db: DbDep) -> dict: + s = await skill_service.create(db, data) + return ok(skill_service.to_read(s).model_dump()) + + +@router.patch("/{sid}", dependencies=[Depends(require_perm("skill:update"))]) +async def update_skill(sid: int, data: SkillUpdate, db: DbDep) -> dict: + s = await skill_service.update(db, sid, data) + return ok(skill_service.to_read(s).model_dump()) + + +@router.delete("/{sid}", dependencies=[Depends(require_perm("skill:delete"))]) +async def delete_skill(sid: int, db: DbDep) -> dict: + await skill_service.delete(db, sid) + return ok() + + +@router.post("/{sid}/test", dependencies=[Depends(require_perm("skill:test"))]) +async def test_skill(sid: int, body: SkillTestRequest, db: DbDep, user: CurrentUser) -> dict: + log = await skill_service.test_skill(db, sid, body.input_data, body.product_id, user) + return ok(_log_to_dict(log)) + + +def _log_to_dict(log) -> dict: + return { + "id": log.id, + "skill_id": log.skill_id, + "skill_code": log.skill_code, + "operator_id": log.operator_id, + "operator_name": log.operator_name, + "executor_type": log.executor_type, + "product_id": log.product_id, + "input_data": log.input_data, + "output_data": log.output_data, + "result": log.result, + "message": log.message, + "cost_ms": log.cost_ms, + "source": log.source, + "created_at": log.created_at.isoformat() if log.created_at else None, + } + + +# Skill 调用日志 +log_router = APIRouter(prefix="/skill-logs", tags=["skill-log"]) + + +@log_router.get("", dependencies=[Depends(require_perm("skill:log:view"))]) +async def list_skill_logs( + db: DbDep, + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=200), + skill_id: int | None = None, + result: str | None = None, + source: str | None = None, +) -> dict: + rows, total = await skill_service.paginate_logs(db, page, size, skill_id, result, source) + return ok({ + "list": [_log_to_dict(r) for r in rows], + "total": total, + "page": page, + "size": size, + }) diff --git a/backend/app/api/v1/tasks.py b/backend/app/api/v1/tasks.py new file mode 100644 index 0000000..198f91e --- /dev/null +++ b/backend/app/api/v1/tasks.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select + +from app.core.deps import CurrentUser, DbDep, require_perm +from app.core.exceptions import ok +from app.models import Product, Skill +from app.schemas.task import TaskCreate, TaskLogRead, TaskRead +from app.services import task_service + +router = APIRouter(prefix="/tasks", tags=["task"]) + + +async def _to_read(db, t) -> dict: + skill = await db.get(Skill, t.skill_id) + product = await db.get(Product, t.product_id) if t.product_id else None + return TaskRead( + id=t.id, task_no=t.task_no, name=t.name, + skill_id=t.skill_id, skill_code=t.skill_code, + skill_name=skill.name if skill else None, + product_id=t.product_id, + product_name=product.name if product else None, + executor_type=t.executor_type, status=t.status, + error_message=t.error_message, external_ref=t.external_ref, + source=t.source, + operator_id=t.operator_id, operator_name=t.operator_name, + input_data=t.input_data, resolved_input=t.resolved_input, + result_data=t.result_data, + started_at=t.started_at, finished_at=t.finished_at, + cost_ms=t.cost_ms, timeout_seconds=t.timeout_seconds, + created_at=t.created_at, + ).model_dump(mode="json") + + +@router.get("", dependencies=[Depends(require_perm("task:list"))]) +async def list_tasks( + db: DbDep, + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=200), + keyword: str | None = None, + status: str | None = None, + skill_id: int | None = None, + product_id: int | None = None, +) -> dict: + rows, total = await task_service.paginate(db, page, size, keyword, status, skill_id, product_id) + return ok({ + "list": [await _to_read(db, t) for t in rows], + "total": total, "page": page, "size": size, + }) + + +@router.get("/{tid}", dependencies=[Depends(require_perm("task:list"))]) +async def get_task(tid: int, db: DbDep) -> dict: + t = await task_service.get(db, tid) + return ok(await _to_read(db, t)) + + +@router.get("/{tid}/logs", dependencies=[Depends(require_perm("task:list"))]) +async def get_task_logs( + tid: int, db: DbDep, after_id: int = Query(0, ge=0) +) -> dict: + logs = await task_service.get_logs(db, tid, after_id) + return ok([TaskLogRead.model_validate(l).model_dump(mode="json") for l in logs]) + + +@router.post("", dependencies=[Depends(require_perm("task:create"))]) +async def create_task(data: TaskCreate, db: DbDep, user: CurrentUser) -> dict: + t = await task_service.create_task( + db, + name=data.name, skill_id=data.skill_id, product_id=data.product_id, + input_data=data.input_data, timeout_seconds=data.timeout_seconds, + operator=user, source="wizard", + ) + return ok(await _to_read(db, t)) + + +@router.post("/{tid}/run", dependencies=[Depends(require_perm("task:create"))]) +async def run_task(tid: int, db: DbDep) -> dict: + t = await task_service.run_task(db, tid) + return ok(await _to_read(db, t)) + + +@router.post("/{tid}/cancel", dependencies=[Depends(require_perm("task:cancel"))]) +async def cancel_task(tid: int, db: DbDep) -> dict: + t = await task_service.cancel_task(db, tid) + return ok(await _to_read(db, t)) + + +@router.post("/{tid}/retry", dependencies=[Depends(require_perm("task:create"))]) +async def retry_task(tid: int, db: DbDep, user: CurrentUser) -> dict: + t = await task_service.retry_task(db, tid, user) + return ok(await _to_read(db, t)) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..636a4ae --- /dev/null +++ b/backend/app/api/v1/users.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from app.core.deps import DbDep, require_perm +from app.core.exceptions import ok +from app.schemas.user import UserCreate, UserResetPwd, UserUpdate +from app.services import user_service + +router = APIRouter(prefix="/users", tags=["user"]) + + +@router.get("", dependencies=[Depends(require_perm("auth:user:view"))]) +async def list_users( + db: DbDep, + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=200), + keyword: str | None = None, + dept_id: int | None = None, +) -> dict: + rows, total = await user_service.paginate(db, page, size, keyword, dept_id) + return ok({ + "list": [user_service.to_read(u).model_dump() for u in rows], + "total": total, + "page": page, + "size": size, + }) + + +@router.get("/{uid}", dependencies=[Depends(require_perm("auth:user:view"))]) +async def get_user(uid: int, db: DbDep) -> dict: + u = await user_service.get_with_rels(db, uid) + return ok(user_service.to_read(u).model_dump()) + + +@router.post("", dependencies=[Depends(require_perm("auth:user:create"))]) +async def create_user(data: UserCreate, db: DbDep) -> dict: + u = await user_service.create(db, data) + return ok(user_service.to_read(u).model_dump()) + + +@router.patch("/{uid}", dependencies=[Depends(require_perm("auth:user:update"))]) +async def update_user(uid: int, data: UserUpdate, db: DbDep) -> dict: + u = await user_service.update(db, uid, data) + return ok(user_service.to_read(u).model_dump()) + + +@router.post("/{uid}/reset-password", dependencies=[Depends(require_perm("auth:user:update"))]) +async def reset_password(uid: int, data: UserResetPwd, db: DbDep) -> dict: + await user_service.reset_password(db, uid, data.password) + return ok() + + +@router.delete("/{uid}", dependencies=[Depends(require_perm("auth:user:delete"))]) +async def delete_user(uid: int, db: DbDep) -> dict: + await user_service.delete(db, uid) + return ok() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..3417b8e --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,73 @@ +"""全局配置:从项目根 .env 加载环境变量,通过 `settings` 单例访问。""" +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +# 项目根(backend/ 上一级) +ROOT_DIR = Path(__file__).resolve().parents[3] + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=str(ROOT_DIR / ".env"), + env_file_encoding="utf-8", + case_sensitive=True, + extra="ignore", + ) + + # App + APP_ENV: str = "dev" + APP_SECRET_KEY: str = "change-me" + APP_BASE_URL: str = "http://localhost:8000" + + # JWT + JWT_ALG: str = "HS256" + JWT_ACCESS_TTL: int = 3600 + JWT_REFRESH_TTL: int = 604800 + + # Database + DATABASE_URL: str = "postgresql+asyncpg://loom:loom@localhost:5432/loom" + + # Redis / Celery + REDIS_URL: str = "redis://localhost:6379/0" + CELERY_BROKER_URL: str = "redis://localhost:6379/1" + CELERY_RESULT_BACKEND: str = "redis://localhost:6379/2" + + # Lobster Agent(联调前留空) + LOBSTER_API_BASE: str = "" + LOBSTER_API_KEY: str = "" + LOBSTER_CALLBACK_SECRET: str = "" + LOBSTER_TIMEOUT_SECONDS: int = 30 + LOBSTER_POLL_INTERVAL_SECONDS: int = 5 + + # OpenAI(联调前留空) + OPENAI_API_BASE: str = "https://api.openai.com/v1" + OPENAI_API_KEY: str = "" + OPENAI_DEFAULT_MODEL: str = "gpt-4o-mini" + OPENAI_TIMEOUT_SECONDS: int = 60 + + # 加密 + FERNET_KEY: str = "" + + # CORS + CORS_ALLOW_ORIGINS: str = "http://localhost:5173,http://127.0.0.1:5173" + + @property + def cors_origins(self) -> list[str]: + return [o.strip() for o in self.CORS_ALLOW_ORIGINS.split(",") if o.strip()] + + @property + def is_dev(self) -> bool: + return self.APP_ENV.lower() in {"dev", "development", "local"} + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py new file mode 100644 index 0000000..1bba079 --- /dev/null +++ b/backend/app/core/constants.py @@ -0,0 +1,29 @@ +"""全局枚举 & 常量。""" +from __future__ import annotations + +from enum import Enum + + +class TaskStatus(str, Enum): + PENDING = "PENDING" + QUEUED = "QUEUED" + RUNNING = "RUNNING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + TIMEOUT = "TIMEOUT" + CANCELED = "CANCELED" + + +class ExecutorType(str, Enum): + LOBSTER = "LOBSTER" + SCRIPT = "SCRIPT" + + +class UserStatus(str, Enum): + ACTIVE = "ACTIVE" + DISABLED = "DISABLED" + + +class ChannelType(str, Enum): + INTERNAL = "INTERNAL" + NATURAL = "NATURAL" diff --git a/backend/app/core/crypto.py b/backend/app/core/crypto.py new file mode 100644 index 0000000..72de2ac --- /dev/null +++ b/backend/app/core/crypto.py @@ -0,0 +1,46 @@ +"""对称加密工具:用于平台账号凭据等敏感字段的落库加密。 + +Key 来自 `settings.FERNET_KEY`,`python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 生成。 +""" +from __future__ import annotations + +from functools import lru_cache + +from cryptography.fernet import Fernet, InvalidToken + +from app.core.config import settings + + +@lru_cache +def _fernet() -> Fernet: + key = settings.FERNET_KEY + if not key: + raise RuntimeError( + "FERNET_KEY 未配置,无法加密/解密敏感字段。" + "生成:python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"" + ) + return Fernet(key.encode() if isinstance(key, str) else key) + + +def encrypt(value: str | None) -> str | None: + if value is None or value == "": + return None + return _fernet().encrypt(value.encode("utf-8")).decode("ascii") + + +def decrypt(value: str | None) -> str | None: + if value is None or value == "": + return None + try: + return _fernet().decrypt(value.encode("ascii")).decode("utf-8") + except InvalidToken: + return None + + +def mask(value: str | None, keep: int = 2) -> str: + """显示掩码:前 keep 位 + ****。""" + if not value: + return "" + if len(value) <= keep: + return "*" * len(value) + return value[:keep] + "*" * max(4, len(value) - keep) diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..7c41630 --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,72 @@ +"""FastAPI 依赖注入:DB session、JWT 解析、权限校验。""" +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import Annotated + +from fastapi import Depends, Header, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import AppError +from app.core.security import JWTError, decode_token +from app.db.session import AsyncSessionLocal +from app.models import User +from app.services.rbac_service import get_user_perm_codes, load_user_with_perms + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise + + +DbDep = Annotated[AsyncSession, Depends(get_db)] + + +async def _resolve_token(authorization: str | None) -> int: + if not authorization or not authorization.lower().startswith("bearer "): + raise AppError("未登录", code=401, http_status=status.HTTP_401_UNAUTHORIZED) + token = authorization.split(" ", 1)[1].strip() + try: + payload = decode_token(token) + except JWTError as e: + raise AppError("token 无效", code=401, http_status=status.HTTP_401_UNAUTHORIZED) from e + if payload.get("type") not in (None, "access"): + raise AppError("token 类型错误", code=401, http_status=status.HTTP_401_UNAUTHORIZED) + try: + return int(payload["sub"]) + except (KeyError, ValueError) as e: + raise AppError("token 无效", code=401, http_status=status.HTTP_401_UNAUTHORIZED) from e + + +async def get_current_user( + db: DbDep, + authorization: str | None = Header(default=None), +) -> User: + user_id = await _resolve_token(authorization) + user = await load_user_with_perms(db, user_id) + if user is None or user.status != "ACTIVE": + raise AppError("账号已失效", code=401, http_status=status.HTTP_401_UNAUTHORIZED) + return user + + +CurrentUser = Annotated[User, Depends(get_current_user)] + + +def require_perm(code: str): + """权限校验依赖:挂在路由上,没命中则 403。超管通过。""" + + async def _check(db: DbDep, user: CurrentUser) -> User: + if user.is_superuser: + return user + codes = await get_user_perm_codes(db, user) + if code not in codes: + raise AppError( + f"无权限: {code}", code=403, http_status=status.HTTP_403_FORBIDDEN + ) + return user + + return _check diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000..59e89a0 --- /dev/null +++ b/backend/app/core/exceptions.py @@ -0,0 +1,69 @@ +"""统一响应 + 全局异常处理。所有接口返回 {code, message, data}。""" +from __future__ import annotations + +from typing import Any + +from fastapi import FastAPI, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +from app.core.logging import logger + + +class AppError(Exception): + """业务异常基类。服务层抛出,由全局 handler 转成标准响应体。""" + + def __init__( + self, + message: str, + *, + code: int = 1000, + http_status: int = status.HTTP_400_BAD_REQUEST, + data: Any = None, + ) -> None: + super().__init__(message) + self.message = message + self.code = code + self.http_status = http_status + self.data = data + + +def ok(data: Any = None, message: str = "ok") -> dict: + return {"code": 0, "message": message, "data": data} + + +def fail(code: int, message: str, data: Any = None) -> dict: + return {"code": code, "message": message, "data": data} + + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(AppError) + async def _app_error(request: Request, exc: AppError) -> JSONResponse: + logger.warning("app_error", path=request.url.path, code=exc.code, message=exc.message) + return JSONResponse( + status_code=exc.http_status, + content=fail(exc.code, exc.message, exc.data), + ) + + @app.exception_handler(StarletteHTTPException) + async def _http_exc(request: Request, exc: StarletteHTTPException) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content=fail(exc.status_code, str(exc.detail)), + ) + + @app.exception_handler(RequestValidationError) + async def _validation_exc(request: Request, exc: RequestValidationError) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content=fail(422, "参数校验失败", exc.errors()), + ) + + @app.exception_handler(Exception) + async def _unhandled(request: Request, exc: Exception) -> JSONResponse: + logger.exception("unhandled_exception", path=request.url.path) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content=fail(500, "服务器内部错误"), + ) diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..c73f87c --- /dev/null +++ b/backend/app/core/logging.py @@ -0,0 +1,44 @@ +"""structlog 配置:开发彩色、生产 JSON。""" +from __future__ import annotations + +import logging +import sys + +import structlog + +from app.core.config import settings + + +def configure_logging() -> None: + shared_processors: list = [ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso", utc=True), + structlog.processors.StackInfoRenderer(), + ] + + if settings.is_dev: + renderer = structlog.dev.ConsoleRenderer(colors=True) + else: + renderer = structlog.processors.JSONRenderer() + + structlog.configure( + processors=[*shared_processors, renderer], + wrapper_class=structlog.make_filtering_bound_logger(logging.INFO), + context_class=dict, + logger_factory=structlog.PrintLoggerFactory(file=sys.stdout), + cache_logger_on_first_use=True, + ) + + # 让 uvicorn / fastapi 的标准 logging 也走 structlog + logging.basicConfig( + format="%(message)s", + stream=sys.stdout, + level=logging.INFO, + ) + for name in ("uvicorn", "uvicorn.error", "uvicorn.access", "sqlalchemy.engine"): + logging.getLogger(name).handlers.clear() + logging.getLogger(name).propagate = True + + +logger = structlog.get_logger() diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py new file mode 100644 index 0000000..a5a1821 --- /dev/null +++ b/backend/app/core/middleware.py @@ -0,0 +1,192 @@ +"""请求审计中间件(纯 ASGI 实现)。 + +不使用 `BaseHTTPMiddleware`,因为它内部借助 anyio task group 执行 call_next, +会与 asyncpg 的底层 future 跨 loop(TestClient 场景)或出现 request body 重放坑。 + +对变更接口(POST/PATCH/PUT/DELETE)成功响应后异步落 OpLog。 +""" +from __future__ import annotations + +import asyncio +import json +import time +from typing import Any + +from sqlalchemy.exc import SQLAlchemyError +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from app.core.logging import logger +from app.core.security import JWTError, decode_token +from app.db.session import AsyncSessionLocal +from app.models import OpLog, User + +MUTATION_METHODS = {"POST", "PATCH", "PUT", "DELETE"} +SKIP_PATHS = { + "/api/v1/auth/login", + "/api/v1/auth/refresh", + "/api/v1/healthz", +} +SENSITIVE_KEYS = {"password", "google_key", "password_hash", "refresh_token", "access_token"} + + +def _sanitize(obj: Any) -> Any: + if isinstance(obj, dict): + return { + k: ("***" if k in SENSITIVE_KEYS and v else _sanitize(v)) + for k, v in obj.items() + } + if isinstance(obj, list): + return [_sanitize(i) for i in obj] + return obj + + +def _infer_module(path: str) -> str: + parts = path.strip("/").split("/") + if len(parts) >= 3 and parts[0] == "api" and parts[1] == "v1": + return parts[2] + return "unknown" + + +def _infer_action(method: str, path: str) -> str: + if path.endswith("/reset-password"): + return "reset_password" + if path.endswith("/test"): + return "test" + return {"POST": "create", "PATCH": "update", "PUT": "update", "DELETE": "delete"}.get( + method, method.lower() + ) + + +class OpLogMiddleware: + """纯 ASGI 中间件:只在请求/响应 message 流动的边界记录必要字段。""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + method: str = scope.get("method", "GET").upper() + path: str = scope.get("path", "") + need_audit = ( + method in MUTATION_METHODS + and path.startswith("/api/v1/") + and path not in SKIP_PATHS + ) + + if not need_audit: + await self.app(scope, receive, send) + return + + # —— 收集请求体(一次性读取,再用缓存回放给下游) + body_chunks: list[bytes] = [] + more = True + while more: + msg = await receive() + if msg["type"] == "http.request": + body_chunks.append(msg.get("body", b"")) + more = msg.get("more_body", False) + else: + more = False + raw = b"".join(body_chunks) + payload: Any = None + if raw: + try: + payload = _sanitize(json.loads(raw)) + except json.JSONDecodeError: + payload = {"_raw_bytes": len(raw)} + + replayed = False + + async def replay_receive() -> Message: + nonlocal replayed + if not replayed: + replayed = True + return {"type": "http.request", "body": raw, "more_body": False} + return {"type": "http.disconnect"} + + # —— 拦响应的 start,拿 status + status_code: int = 500 + start = time.perf_counter() + + async def send_wrapper(message: Message) -> None: + nonlocal status_code + if message["type"] == "http.response.start": + status_code = int(message.get("status", 500)) + await send(message) + + await self.app(scope, replay_receive, send_wrapper) + + cost_ms = int((time.perf_counter() - start) * 1000) + + if 200 <= status_code < 400: + headers = { + k.decode("latin-1").lower(): v.decode("latin-1") + for k, v in scope.get("headers", []) + } + client = scope.get("client") or (None, None) + try: + asyncio.create_task( + _write_oplog( + method=method, + path=path, + status_code=status_code, + payload=payload, + ip=client[0] if client else None, + user_agent=headers.get("user-agent"), + authorization=headers.get("authorization"), + cost_ms=cost_ms, + ) + ) + except RuntimeError: + # 测试环境下 loop 可能已关闭;忽略,保证主路径不受影响 + pass + + +async def _write_oplog( + *, + method: str, + path: str, + status_code: int, + payload: Any, + ip: str | None, + user_agent: str | None, + authorization: str | None, + cost_ms: int, +) -> None: + operator_id: int | None = None + operator_name: str | None = None + if authorization and authorization.lower().startswith("bearer "): + token = authorization.split(" ", 1)[1].strip() + try: + sub = decode_token(token).get("sub") + operator_id = int(sub) if sub is not None else None + except (JWTError, ValueError): + operator_id = None + + try: + async with AsyncSessionLocal() as db: + if operator_id: + u = await db.get(User, operator_id) + operator_name = u.username if u else None + entry = OpLog( + operator_id=operator_id, + operator_name=operator_name, + module=_infer_module(path), + action=_infer_action(method, path), + method=method, + path=path, + status_code=status_code, + ip=ip, + user_agent=(user_agent or "")[:255] or None, + payload=payload, + cost_ms=cost_ms, + ) + db.add(entry) + await db.commit() + except SQLAlchemyError: + logger.exception("oplog_write_failed", path=path) + except Exception: # noqa: BLE001 + logger.exception("oplog_write_unhandled", path=path) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..1f58562 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,76 @@ +"""密码哈希 + JWT 签发/校验。 + +不用 passlib(新版 bcrypt 4.x 会让 passlib 的 wrap-bug 探测崩溃),直接用 bcrypt。 +对超过 72 字节的密码先做 SHA-256 + base64 预处理,规避 bcrypt 上限。 +""" +from __future__ import annotations + +import base64 +import hashlib +from datetime import datetime, timedelta, timezone +from typing import Any + +import bcrypt +from jose import JWTError, jwt + +from app.core.config import settings + + +def _prepare(password: str) -> bytes: + raw = password.encode("utf-8") + if len(raw) > 72: + # SHA-256 固定 32 字节 → base64 后 44 字节,始终 < 72 + raw = base64.b64encode(hashlib.sha256(raw).digest()) + return raw + + +def hash_password(plain: str) -> str: + return bcrypt.hashpw(_prepare(plain), bcrypt.gensalt()).decode("ascii") + + +def verify_password(plain: str, hashed: str) -> bool: + try: + return bcrypt.checkpw(_prepare(plain), hashed.encode("ascii")) + except ValueError: + return False + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def create_access_token(subject: str | int, extra: dict[str, Any] | None = None) -> str: + return _create_token(subject, settings.JWT_ACCESS_TTL, "access", extra) + + +def create_refresh_token(subject: str | int, extra: dict[str, Any] | None = None) -> str: + return _create_token(subject, settings.JWT_REFRESH_TTL, "refresh", extra) + + +def _create_token( + subject: str | int, ttl_seconds: int, token_type: str, extra: dict[str, Any] | None +) -> str: + payload: dict[str, Any] = { + "sub": str(subject), + "type": token_type, + "iat": _now(), + "exp": _now() + timedelta(seconds=ttl_seconds), + } + if extra: + payload.update(extra) + return jwt.encode(payload, settings.APP_SECRET_KEY, algorithm=settings.JWT_ALG) + + +def decode_token(token: str) -> dict[str, Any]: + """解码 JWT,失败抛 `JWTError`。""" + return jwt.decode(token, settings.APP_SECRET_KEY, algorithms=[settings.JWT_ALG]) + + +__all__ = [ + "hash_password", + "verify_password", + "create_access_token", + "create_refresh_token", + "decode_token", + "JWTError", +] diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..dc57988 --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,25 @@ +"""SQLAlchemy 2.0 DeclarativeBase + 通用 Mixin。""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + """所有 ORM 模型的基类。""" + + pass + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..ed643a4 --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,29 @@ +"""异步数据库引擎 & session factory。 + +注意:TestClient 每个请求会起独立 event loop,若连接池跨请求复用 asyncpg +连接则会 "Task attached to a different loop"。测试环境(检测 `PYTEST_CURRENT_TEST`) +使用 NullPool,每次 session 都新建连接。 +""" +from __future__ import annotations + +import os + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.core.config import settings + +_kwargs: dict = {"echo": False, "future": True} +if os.environ.get("PYTEST_CURRENT_TEST") or os.environ.get("LOOM_TESTING"): + _kwargs["poolclass"] = NullPool +else: + _kwargs["pool_pre_ping"] = True + +engine = create_async_engine(settings.DATABASE_URL, **_kwargs) + +AsyncSessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, +) diff --git a/backend/app/integrations/__init__.py b/backend/app/integrations/__init__.py new file mode 100644 index 0000000..e5b5eaa --- /dev/null +++ b/backend/app/integrations/__init__.py @@ -0,0 +1,8 @@ +"""外部服务客户端。 + +- `lobster_client`:龙虾 Agent HTTP 客户端;`.env` 配置 `LOBSTER_API_BASE / LOBSTER_API_KEY` + 后真调 `POST /runs` + `GET /runs/{id}`;未配置时客户端内置 mock 便于本地开发。 +- `openai_client`:OpenAI Chat Completions;`.env` 的 `OPENAI_API_KEY` 未配置时走 mock 回显, + 每个 LLM Provider 也可在 UI 里单独设置 key。 +- `script_executor`:subprocess 脚本执行器(python/node/shell + 超时 + 逐行日志回调)。 +""" diff --git a/backend/app/integrations/lobster_client.py b/backend/app/integrations/lobster_client.py new file mode 100644 index 0000000..ff3ac94 --- /dev/null +++ b/backend/app/integrations/lobster_client.py @@ -0,0 +1,142 @@ +"""龙虾 Agent HTTP 客户端。 + +运行时配置来源优先级:DB (lobster_config 表) > .env (settings.LOBSTER_*)。 +调用方通过 `build_lobster_client(db)` 或 `build_lobster_client_sync(api_base, api_key, timeout)` +获取实例;不再使用模块级单例,便于配置热更新。 + +- 当 api_base / api_key 任一为空时走 mock(便于本地自测)。 +- 真调协议:POST {api_base}/runs 提交、GET {api_base}/runs/{run_id} 查状态。 + 实际字段由联调时与龙虾平台对齐。 +""" +from __future__ import annotations + +import asyncio +import random +import time +from dataclasses import dataclass +from typing import Any + +import httpx + +from app.core.logging import logger + + +@dataclass +class LobsterRunResult: + run_id: str + status: str # QUEUED / RUNNING / SUCCEEDED / FAILED / TIMEOUT + output: dict[str, Any] | None = None + logs: list[dict[str, Any]] | None = None # [{"event_id": str, "level": str, "message": str}] + message: str | None = None + + +# 模块级共享 mock 状态机(同一 run_id 多次 poll 可继续推进) +_MOCK_RUNS: dict[str, dict[str, Any]] = {} + + +class LobsterClient: + """按运行时配置构造;API_BASE / API_KEY 任一为空时退化为 mock。""" + + def __init__(self, api_base: str = "", api_key: str = "", timeout: int = 30) -> None: + self.base = (api_base or "").rstrip("/") + self.key = api_key or "" + self.timeout = timeout or 30 + + @property + def is_mock(self) -> bool: + return not self.base or not self.key + + async def submit( + self, template_id: str, params: dict[str, Any], task_no: str + ) -> LobsterRunResult: + if self.is_mock: + return await self._mock_submit(template_id, params, task_no) + + headers = { + "Authorization": f"Bearer {self.key}", + "X-Loom-Task": task_no, + "Content-Type": "application/json", + } + payload = {"template_id": template_id, "input": params, "task_no": task_no} + async with httpx.AsyncClient(timeout=self.timeout) as client: + r = await client.post(f"{self.base}/runs", json=payload, headers=headers) + r.raise_for_status() + data = r.json() + return LobsterRunResult( + run_id=str(data.get("run_id") or data.get("id") or ""), + status=str(data.get("status") or "QUEUED"), + message=data.get("message"), + ) + + async def poll(self, run_id: str) -> LobsterRunResult: + if self.is_mock: + return await self._mock_poll(run_id) + + headers = {"Authorization": f"Bearer {self.key}"} + async with httpx.AsyncClient(timeout=self.timeout) as client: + r = await client.get(f"{self.base}/runs/{run_id}", headers=headers) + r.raise_for_status() + data = r.json() + return LobsterRunResult( + run_id=run_id, + status=str(data.get("status") or "RUNNING"), + output=data.get("output"), + logs=data.get("logs") or [], + message=data.get("message"), + ) + + # ---- mock ---- + + async def _mock_submit( + self, template_id: str, params: dict[str, Any], task_no: str + ) -> LobsterRunResult: + run_id = f"mock-{task_no}-{int(time.time()*1000)}" + _MOCK_RUNS[run_id] = { + "created_at": time.time(), + "template_id": template_id, + "input": params, + "polls": 0, + } + logger.info("lobster_mock_submit", run_id=run_id, template_id=template_id) + await asyncio.sleep(0.05) + return LobsterRunResult(run_id=run_id, status="QUEUED") + + async def _mock_poll(self, run_id: str) -> LobsterRunResult: + state = _MOCK_RUNS.get(run_id) + if state is None: + return LobsterRunResult(run_id=run_id, status="FAILED", message="mock run_id 未知") + state["polls"] += 1 + if state["polls"] == 1: + return LobsterRunResult( + run_id=run_id, + status="RUNNING", + logs=[{"event_id": f"{run_id}-1", "level": "INFO", "message": "mock: 开始执行"}], + ) + if state["polls"] >= 3: + output = {"mock": True, "input": state["input"], "random": random.randint(1, 99)} + return LobsterRunResult( + run_id=run_id, + status="SUCCEEDED", + output=output, + logs=[{"event_id": f"{run_id}-2", "level": "INFO", "message": "mock: 完成"}], + message="mock 成功", + ) + return LobsterRunResult( + run_id=run_id, + status="RUNNING", + logs=[{"event_id": f"{run_id}-poll-{state['polls']}", "level": "INFO", + "message": f"mock: 第 {state['polls']} 次轮询"}], + ) + + +async def build_lobster_client(db) -> LobsterClient: + """根据当前 DB + .env 生效配置构造 client。调用方在执行任务前拿一次即可。""" + # 局部 import 避免模块循环依赖 + from app.services import lobster_config_service + + eff = await lobster_config_service.load_effective(db) + return LobsterClient( + api_base=eff.api_base, + api_key=eff.api_key, + timeout=eff.timeout_seconds, + ) diff --git a/backend/app/integrations/openai_client.py b/backend/app/integrations/openai_client.py new file mode 100644 index 0000000..031da08 --- /dev/null +++ b/backend/app/integrations/openai_client.py @@ -0,0 +1,86 @@ +"""OpenAI Chat Completions 客户端。 + +- 真实接入:httpx 直调 `/chat/completions`,使用各 provider 独立的 base_url 和 api_key +- mock:api_key 为空时回显一个简单响应并估算 token 数 +""" +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Any + +import httpx + +from app.core.config import settings + + +@dataclass +class ChatResult: + content: str + prompt_tokens: int + completion_tokens: int + total_tokens: int + raw: dict[str, Any] | None = None + + +def _estimate_tokens(text: str) -> int: + """粗估:平均 4 个字符 ≈ 1 token。""" + return max(1, len(text) // 4) + + +async def chat_completion( + *, + api_base: str, + api_key: str | None, + model: str, + messages: list[dict[str, Any]], + temperature: float | None = None, + max_tokens: int | None = None, + timeout_seconds: int | None = None, +) -> ChatResult: + """发起一次 Chat Completions。api_key 为空时走 mock。""" + timeout = timeout_seconds or settings.OPENAI_TIMEOUT_SECONDS or 60 + + if not api_key: + joined = "\n".join(m.get("content", "") for m in messages if isinstance(m, dict)) + reply = f"[mock:{model}] 接收到 {len(messages)} 条消息;配置 OPENAI_API_KEY 后将转为真实调用。" + prompt = sum(_estimate_tokens(m.get("content", "") or "") for m in messages) + comp = _estimate_tokens(reply) + return ChatResult( + content=reply, + prompt_tokens=prompt, + completion_tokens=comp, + total_tokens=prompt + comp, + raw={"mock": True, "preview": joined[:200]}, + ) + + payload: dict[str, Any] = {"model": model, "messages": messages} + if temperature is not None: + payload["temperature"] = temperature + if max_tokens is not None: + payload["max_tokens"] = max_tokens + + url = api_base.rstrip("/") + "/chat/completions" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + async with httpx.AsyncClient(timeout=timeout) as client: + r = await client.post(url, json=payload, headers=headers) + r.raise_for_status() + data = r.json() + + content = "" + try: + content = data["choices"][0]["message"]["content"] or "" + except Exception: # noqa: BLE001 + content = "" + usage = data.get("usage") or {} + return ChatResult( + content=content, + prompt_tokens=int(usage.get("prompt_tokens") or _estimate_tokens(str(messages))), + completion_tokens=int(usage.get("completion_tokens") or _estimate_tokens(content)), + total_tokens=int(usage.get("total_tokens") or 0) + or _estimate_tokens(str(messages)) + _estimate_tokens(content), + raw=data, + ) diff --git a/backend/app/integrations/script_executor.py b/backend/app/integrations/script_executor.py new file mode 100644 index 0000000..97aeb79 --- /dev/null +++ b/backend/app/integrations/script_executor.py @@ -0,0 +1,150 @@ +"""脚本执行器:subprocess 沙箱版。 + +Phase 5 简化实现:在 worker 进程内直接 subprocess 执行脚本字符串。 +- 支持 python / shell / node +- 超时自动 kill +- 每行 stdout/stderr 作为一条 TaskLog + +(Celery worker 版本为未来优化项;当前流程在 FastAPI 后台任务里执行即可。) +""" +from __future__ import annotations + +import asyncio +import json +import shutil +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, AsyncIterator + + +def _resolve_lang_cmd() -> dict[str, list[str]]: + """启动时探测各语言解释器。Python 直接用当前 venv 的解释器,不依赖 PATH。""" + node = shutil.which("node") or "node" + bash = shutil.which("bash") or "/bin/bash" + return { + "python": [sys.executable, "-u"], # -u 保证 stdout 无缓冲 + "node": [node], + "shell": [bash], + } + + +LANG_CMD: dict[str, list[str]] = _resolve_lang_cmd() + + +@dataclass +class ScriptChunk: + level: str # INFO / ERROR + message: str + + +@dataclass +class ScriptResult: + returncode: int + stdout: str + stderr: str + status: str # SUCCEEDED / FAILED / TIMEOUT + output: dict[str, Any] + + +async def run_script( + *, + language: str, + code: str, + params: dict[str, Any], + timeout_seconds: int = 60, + on_chunk: callable | None = None, +) -> ScriptResult: + if language not in LANG_CMD: + raise ValueError(f"不支持的脚本语言: {language}") + + with tempfile.TemporaryDirectory() as tmpdir: + p = Path(tmpdir) + ext = {"python": ".py", "node": ".js", "shell": ".sh"}[language] + script_path = p / f"entry{ext}" + script_path.write_text(code, encoding="utf-8") + # 通过环境变量 LOOM_PARAMS 传入参数(JSON),脚本可 json.loads 读 + env = {"LOOM_PARAMS": json.dumps(params, ensure_ascii=False, default=str)} + + cmd = LANG_CMD[language] + [str(script_path)] + import os as _os + + proc = await asyncio.create_subprocess_exec( + *cmd, + cwd=str(p), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={ + **env, + "PATH": _os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"), + }, + ) + + stdout_chunks: list[str] = [] + stderr_chunks: list[str] = [] + + async def _pipe(stream, is_err: bool) -> None: + if stream is None: + return + async for raw in stream: + try: + line = raw.decode("utf-8", errors="replace").rstrip("\n") + except Exception: # noqa: BLE001 + line = str(raw) + (stderr_chunks if is_err else stdout_chunks).append(line) + if on_chunk: + await on_chunk(ScriptChunk(level=("ERROR" if is_err else "INFO"), message=line)) + + try: + await asyncio.wait_for( + asyncio.gather(_pipe(proc.stdout, False), _pipe(proc.stderr, True)), + timeout=timeout_seconds, + ) + returncode = await proc.wait() + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return ScriptResult( + returncode=-1, + stdout="\n".join(stdout_chunks), + stderr="\n".join(stderr_chunks) + "\n[timeout killed]", + status="TIMEOUT", + output={"timeout": True, "timeout_seconds": timeout_seconds}, + ) + + status = "SUCCEEDED" if returncode == 0 else "FAILED" + return ScriptResult( + returncode=returncode, + stdout="\n".join(stdout_chunks), + stderr="\n".join(stderr_chunks), + status=status, + output={ + "returncode": returncode, + "stdout_tail": "\n".join(stdout_chunks[-20:]), + }, + ) + + +async def stream_lines( + *, language: str, code: str, params: dict[str, Any], timeout_seconds: int = 60 +) -> AsyncIterator[tuple[str, str]]: + """供外部需要逐行消费时使用(当前 run_script 已支持 callback,这个保留扩展位)。""" + chunks: asyncio.Queue[tuple[str, str] | None] = asyncio.Queue() + + async def _on_chunk(c: ScriptChunk) -> None: + await chunks.put((c.level, c.message)) + + task = asyncio.create_task( + run_script( + language=language, code=code, params=params, + timeout_seconds=timeout_seconds, on_chunk=_on_chunk, + ) + ) + task.add_done_callback(lambda _: chunks.put_nowait(None)) + while True: + item = await chunks.get() + if item is None: + break + yield item + await task diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..9ecf727 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,61 @@ +"""FastAPI 入口:挂载路由、异常 handler、CORS、日志。""" +from __future__ import annotations + +import os +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.router import api_router +from app.core.config import settings +from app.core.exceptions import register_exception_handlers +from app.core.logging import configure_logging, logger +from app.core.middleware import OpLogMiddleware +from app.services import schedule_service + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + configure_logging() + logger.info("loom_backend_start", env=settings.APP_ENV, base_url=settings.APP_BASE_URL) + + # 测试环境不启动后台循环,避免 NullPool 循环申请连接 & TestClient 生命周期冲突 + scheduler_task = None + if not (os.environ.get("LOOM_TESTING") or os.environ.get("PYTEST_CURRENT_TEST")): + scheduler_task = schedule_service.start_scheduler_task() + logger.info("scheduler_started") + + try: + yield + finally: + if scheduler_task is not None: + scheduler_task.cancel() + logger.info("loom_backend_stop") + + +def create_app() -> FastAPI: + app = FastAPI( + title="Loom API", + description="Loom Agent 任务自动化平台 - 后端 API", + version="0.1.0", + lifespan=lifespan, + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + # OpLog 中间件注册在 CORS 之后:请求先经 CORS 鉴别来源,再进审计; + # starlette 的 middleware 栈是"最后 add 的最先执行",顺序符合预期。 + app.add_middleware(OpLogMiddleware) + + register_exception_handlers(app) + app.include_router(api_router) + return app + + +app = create_app() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..d484bb1 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,53 @@ +"""ORM 模型集中 import。确保 Base.metadata 认到所有表。""" +from app.models.channel_code import ChannelCode, product_channel_code +from app.models.llm import LLMModel, LLMProvider, LLMRoutingRule, LLMUsage +from app.models.lobster_config import LobsterConfig +from app.models.menu import Menu +from app.models.op_log import OpLog +from app.models.organization import Organization +from app.models.platform import Platform +from app.models.platform_account import PlatformAccount +from app.models.product import Product +from app.models.role import ( + Role, + role_account_scope, + role_menu, + role_product_scope, + role_skill_scope, +) +from app.models.schedule import Schedule, ScheduleLog +from app.models.script_template import ScriptTemplate +from app.models.skill import Skill, SkillCallLog, skill_product_scope +from app.models.task import Task, TaskLog +from app.models.user import User, user_role + +__all__ = [ + "ChannelCode", + "LLMModel", + "LLMProvider", + "LLMRoutingRule", + "LLMUsage", + "LobsterConfig", + "Menu", + "OpLog", + "Organization", + "Platform", + "PlatformAccount", + "Product", + "Role", + "Schedule", + "ScheduleLog", + "ScriptTemplate", + "Skill", + "SkillCallLog", + "Task", + "TaskLog", + "User", + "product_channel_code", + "role_menu", + "role_product_scope", + "role_skill_scope", + "role_account_scope", + "skill_product_scope", + "user_role", +] diff --git a/backend/app/models/channel_code.py b/backend/app/models/channel_code.py new file mode 100644 index 0000000..2a4ccf3 --- /dev/null +++ b/backend/app/models/channel_code.py @@ -0,0 +1,31 @@ +"""渠道码:内导(internal)/ 自然(natural)。与产品多对多。""" +from __future__ import annotations + +from sqlalchemy import Column, ForeignKey, Integer, String, Table +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin + +product_channel_code = Table( + "product_channel_code", + Base.metadata, + Column("product_id", Integer, ForeignKey("product.id", ondelete="CASCADE"), primary_key=True), + Column( + "channel_code_id", + Integer, + ForeignKey("channel_code.id", ondelete="CASCADE"), + primary_key=True, + ), +) + + +class ChannelCode(Base, TimestampMixin): + __tablename__ = "channel_code" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + name: Mapped[str] = mapped_column(String(64), nullable=False) + # INTERNAL / NATURAL + type: Mapped[str] = mapped_column(String(16), nullable=False, default="INTERNAL") + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False) diff --git a/backend/app/models/llm.py b/backend/app/models/llm.py new file mode 100644 index 0000000..02fd623 --- /dev/null +++ b/backend/app/models/llm.py @@ -0,0 +1,120 @@ +"""LLM Provider / Model / RoutingRule / Usage。 + +支持多 Provider(openai / azure / 国产 LLM 等)统一管理;路由规则按优先级命中 +provider + model;每次调用在 llm_usage 落 token / 成本。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import ( + JSON, + Boolean, + DateTime, + ForeignKey, + Integer, + Numeric, + String, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base, TimestampMixin + + +class LLMProvider(Base, TimestampMixin): + __tablename__ = "llm_provider" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + name: Mapped[str] = mapped_column(String(64), nullable=False) + # openai / azure / anthropic / custom + vendor: Mapped[str] = mapped_column(String(32), nullable=False, default="openai") + api_base: Mapped[str] = mapped_column(String(255), nullable=False) + # 加密存储;由 service 层做加解密 + api_key_cipher: Mapped[str | None] = mapped_column(String(512), nullable=True) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False) + + +class LLMModel(Base, TimestampMixin): + __tablename__ = "llm_model" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + provider_id: Mapped[int] = mapped_column( + ForeignKey("llm_provider.id", ondelete="CASCADE"), nullable=False, index=True + ) + # 对外展示名,如 "gpt-4o-mini" + model: Mapped[str] = mapped_column(String(128), nullable=False) + name: Mapped[str] = mapped_column(String(128), nullable=False) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + # 上下文窗口(tokens) + max_context: Mapped[int] = mapped_column(Integer, default=8192, nullable=False) + # 单价(每千 token,USD),用于成本粗估 + price_input_per_1k: Mapped[float] = mapped_column( + Numeric(10, 6), default=0, nullable=False + ) + price_output_per_1k: Mapped[float] = mapped_column( + Numeric(10, 6), default=0, nullable=False + ) + status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False) + + provider: Mapped[LLMProvider] = relationship("LLMProvider", lazy="selectin") + + +class LLMRoutingRule(Base, TimestampMixin): + __tablename__ = "llm_routing_rule" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + # 优先级:数字越小越先匹配 + priority: Mapped[int] = mapped_column(Integer, default=100, nullable=False) + # 匹配条件(JSON dict):如 {"category": "social", "executor_type": "LOBSTER"};为空表示默认 + conditions: Mapped[dict | None] = mapped_column(JSON, nullable=True) + model_id: Mapped[int] = mapped_column( + ForeignKey("llm_model.id", ondelete="RESTRICT"), nullable=False + ) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + + model_obj: Mapped[LLMModel] = relationship("LLMModel", lazy="selectin") + + +class LLMUsage(Base): + """每次 LLM 调用的用量记账。""" + + __tablename__ = "llm_usage" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + provider_id: Mapped[int] = mapped_column( + ForeignKey("llm_provider.id", ondelete="SET NULL"), nullable=True, index=True + ) + model_id: Mapped[int] = mapped_column( + ForeignKey("llm_model.id", ondelete="SET NULL"), nullable=True, index=True + ) + provider_code: Mapped[str | None] = mapped_column(String(64), nullable=True) + model: Mapped[str | None] = mapped_column(String(128), nullable=True) + + operator_id: Mapped[int | None] = mapped_column( + ForeignKey("user.id", ondelete="SET NULL"), nullable=True, index=True + ) + operator_name: Mapped[str | None] = mapped_column(String(64), nullable=True) + + # 任务 / Skill / playground + source: Mapped[str] = mapped_column(String(16), default="playground", nullable=False) + task_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + skill_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + + prompt_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + completion_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + total_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + cost_usd: Mapped[float] = mapped_column(Numeric(12, 6), default=0, nullable=False) + + # ok / failed + result: Mapped[str] = mapped_column(String(16), default="ok", nullable=False) + message: Mapped[str | None] = mapped_column(String(255), nullable=True) + cost_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, index=True + ) diff --git a/backend/app/models/lobster_config.py b/backend/app/models/lobster_config.py new file mode 100644 index 0000000..90465db --- /dev/null +++ b/backend/app/models/lobster_config.py @@ -0,0 +1,29 @@ +"""龙虾 Agent 运行时配置(单行表 id=1,运行期可改)。 + +- 该表最多 1 行;服务端在首次读时自动 upsert。 +- 字段为空/NULL 时 fallback 到 `.env`(`settings.LOBSTER_*`),做到 "UI 配 > .env 兜底"。 +- api_key / callback_secret 走 Fernet 加密存储。 +""" +from __future__ import annotations + +from sqlalchemy import Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin + + +class LobsterConfig(Base, TimestampMixin): + __tablename__ = "lobster_config" + + # 单例:固定 id = 1 + id: Mapped[int] = mapped_column(primary_key=True, default=1) + + api_base: Mapped[str | None] = mapped_column(String(255), nullable=True) + # Fernet 密文(可为空,表示使用 .env 或未配置走 mock) + api_key_cipher: Mapped[str | None] = mapped_column(Text, nullable=True) + callback_secret_cipher: Mapped[str | None] = mapped_column(Text, nullable=True) + + timeout_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) + poll_interval_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) + + note: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/backend/app/models/menu.py b/backend/app/models/menu.py new file mode 100644 index 0000000..7d2d9b7 --- /dev/null +++ b/backend/app/models/menu.py @@ -0,0 +1,30 @@ +"""菜单 / 按钮权限树。""" +from __future__ import annotations + +from sqlalchemy import ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base, TimestampMixin + + +class Menu(Base, TimestampMixin): + __tablename__ = "menu" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + parent_id: Mapped[int | None] = mapped_column( + ForeignKey("menu.id", ondelete="CASCADE"), nullable=True, index=True + ) + name: Mapped[str] = mapped_column(String(64), nullable=False) + # dir: 目录分组;menu: 页面;button: 按钮权限 + type: Mapped[str] = mapped_column(String(16), nullable=False, default="menu") + path: Mapped[str | None] = mapped_column(String(128), nullable=True) + component: Mapped[str | None] = mapped_column(String(128), nullable=True) + icon: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 业务权限码,如 task:list / task:create + perm_code: Mapped[str | None] = mapped_column(String(128), unique=True, nullable=True) + sort: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False) + + parent: Mapped["Menu | None"] = relationship( + "Menu", remote_side="Menu.id", backref="children" + ) diff --git a/backend/app/models/op_log.py b/backend/app/models/op_log.py new file mode 100644 index 0000000..99c7472 --- /dev/null +++ b/backend/app/models/op_log.py @@ -0,0 +1,34 @@ +"""操作审计日志。由 middleware 在变更接口成功后异步写入。""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class OpLog(Base): + __tablename__ = "op_log" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + operator_id: Mapped[int | None] = mapped_column( + ForeignKey("user.id", ondelete="SET NULL"), nullable=True, index=True + ) + operator_name: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 业务模块:auth.user / auth.role / product ... + module: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + # 动作:create / update / delete / reset_password / test ... + action: Mapped[str] = mapped_column(String(32), nullable=False) + method: Mapped[str] = mapped_column(String(8), nullable=False) + path: Mapped[str] = mapped_column(String(255), nullable=False) + status_code: Mapped[int] = mapped_column(Integer, nullable=False) + ip: Mapped[str | None] = mapped_column(String(64), nullable=True) + user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True) + # 请求体 / 响应体(敏感字段需由写入方做脱敏) + payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + cost_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, index=True + ) diff --git a/backend/app/models/organization.py b/backend/app/models/organization.py new file mode 100644 index 0000000..309d00e --- /dev/null +++ b/backend/app/models/organization.py @@ -0,0 +1,26 @@ +"""组织架构:公司 / 部门 / 小组 三级树。""" +from __future__ import annotations + +from sqlalchemy import ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base, TimestampMixin + + +class Organization(Base, TimestampMixin): + __tablename__ = "organization" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + parent_id: Mapped[int | None] = mapped_column( + ForeignKey("organization.id", ondelete="SET NULL"), nullable=True, index=True + ) + name: Mapped[str] = mapped_column(String(64), nullable=False) + code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + # company | department | group + type: Mapped[str] = mapped_column(String(16), nullable=False, default="department") + sort: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + + parent: Mapped["Organization | None"] = relationship( + "Organization", remote_side="Organization.id", backref="children" + ) diff --git a/backend/app/models/platform.py b/backend/app/models/platform.py new file mode 100644 index 0000000..0788312 --- /dev/null +++ b/backend/app/models/platform.py @@ -0,0 +1,18 @@ +"""平台基础信息(平台套件)。""" +from __future__ import annotations + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin + + +class Platform(Base, TimestampMixin): + __tablename__ = "platform" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + name: Mapped[str] = mapped_column(String(64), nullable=False) + url: Mapped[str | None] = mapped_column(String(255), nullable=True) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False) diff --git a/backend/app/models/platform_account.py b/backend/app/models/platform_account.py new file mode 100644 index 0000000..6c8f763 --- /dev/null +++ b/backend/app/models/platform_account.py @@ -0,0 +1,34 @@ +"""平台产品账号:用户维护每个平台的登录凭据。 + +敏感字段(password、google_key 等)服务端 Fernet 加密后再落库,API 仅返回掩码。 +P3 简化:platform_name 用字符串而非 FK。P4 建完 Platform 主表再做硬关联。 +""" +from __future__ import annotations + +from sqlalchemy import ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin + + +class PlatformAccount(Base, TimestampMixin): + __tablename__ = "platform_account" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + owner_id: Mapped[int] = mapped_column( + ForeignKey("user.id", ondelete="CASCADE"), nullable=False, index=True + ) + + platform_name: Mapped[str] = mapped_column(String(64), nullable=False) + product_code: Mapped[str | None] = mapped_column(String(64), nullable=True) + + login: Mapped[str] = mapped_column(String(128), nullable=False) + # Fernet 密文(字符串形态) + password_cipher: Mapped[str] = mapped_column(Text, nullable=False) + google_key_cipher: Mapped[str | None] = mapped_column(Text, nullable=True) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + + status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False) + # 最近一次连通测试结果:ok / failed / unknown + last_test_result: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False) + last_test_message: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/backend/app/models/product.py b/backend/app/models/product.py new file mode 100644 index 0000000..e72f778 --- /dev/null +++ b/backend/app/models/product.py @@ -0,0 +1,31 @@ +"""产品(平台下的业务实体)。""" +from __future__ import annotations + +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base, TimestampMixin +from app.models.channel_code import ChannelCode, product_channel_code + + +class Product(Base, TimestampMixin): + __tablename__ = "product" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + platform_id: Mapped[int | None] = mapped_column( + ForeignKey("platform.id", ondelete="SET NULL"), nullable=True, index=True + ) + dept_id: Mapped[int | None] = mapped_column( + ForeignKey("organization.id", ondelete="SET NULL"), nullable=True, index=True + ) + owner_id: Mapped[int | None] = mapped_column( + ForeignKey("user.id", ondelete="SET NULL"), nullable=True, index=True + ) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False) + + channel_codes: Mapped[list[ChannelCode]] = relationship( + "ChannelCode", secondary=product_channel_code, lazy="selectin" + ) diff --git a/backend/app/models/role.py b/backend/app/models/role.py new file mode 100644 index 0000000..6f70ea1 --- /dev/null +++ b/backend/app/models/role.py @@ -0,0 +1,67 @@ +"""角色 + 4 维权限关联表。 + +关联表设计: +- role_menu 菜单/按钮权限 +- role_product_scope 产品数据范围(P3 建表后 product_id 再加 FK) +- role_skill_scope Skill 权限范围 +- role_account_scope 平台账号权限范围 + +P2 仅强依赖 role_menu;其余三张为 role 的可选能力边界,等 P3/P4 相应主表建好后补 FK。 +""" +from __future__ import annotations + +from sqlalchemy import ForeignKey, Integer, String, Table, Column, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base, TimestampMixin + +# Role <-> Menu(含按钮级权限) +role_menu = Table( + "role_menu", + Base.metadata, + Column("role_id", Integer, ForeignKey("role.id", ondelete="CASCADE"), primary_key=True), + Column("menu_id", Integer, ForeignKey("menu.id", ondelete="CASCADE"), primary_key=True), +) + +# Role 的产品 / Skill / 平台账号 范围(P3+ 再绑 FK) +role_product_scope = Table( + "role_product_scope", + Base.metadata, + Column("role_id", Integer, ForeignKey("role.id", ondelete="CASCADE"), primary_key=True), + Column("product_id", Integer, primary_key=True), +) + +role_skill_scope = Table( + "role_skill_scope", + Base.metadata, + Column("role_id", Integer, ForeignKey("role.id", ondelete="CASCADE"), primary_key=True), + Column("skill_id", Integer, primary_key=True), +) + +role_account_scope = Table( + "role_account_scope", + Base.metadata, + Column("role_id", Integer, ForeignKey("role.id", ondelete="CASCADE"), primary_key=True), + Column("account_id", Integer, primary_key=True), +) + + +class Role(Base, TimestampMixin): + __tablename__ = "role" + __table_args__ = (UniqueConstraint("dept_id", "code", name="uq_role_dept_code"),) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + # 角色归属部门;null 表示全局角色(如超管) + dept_id: Mapped[int | None] = mapped_column( + ForeignKey("organization.id", ondelete="SET NULL"), nullable=True, index=True + ) + name: Mapped[str] = mapped_column(String(64), nullable=False) + code: Mapped[str] = mapped_column(String(64), nullable=False) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False) + # 系统内置角色(如 super_admin)不允许用户删除 + is_builtin: Mapped[bool] = mapped_column(default=False, nullable=False) + + menus: Mapped[list["Menu"]] = relationship( # type: ignore[name-defined] # noqa: F821 + "Menu", secondary=role_menu, lazy="selectin" + ) diff --git a/backend/app/models/schedule.py b/backend/app/models/schedule.py new file mode 100644 index 0000000..c526270 --- /dev/null +++ b/backend/app/models/schedule.py @@ -0,0 +1,59 @@ +"""定时任务 + 触发记录。""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin + + +class Schedule(Base, TimestampMixin): + __tablename__ = "schedule" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + + skill_id: Mapped[int] = mapped_column( + ForeignKey("skill.id", ondelete="RESTRICT"), nullable=False, index=True + ) + product_id: Mapped[int | None] = mapped_column( + ForeignKey("product.id", ondelete="SET NULL"), nullable=True + ) + input_data: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + # 标准 5 段 cron:minute hour day month weekday + cron: Mapped[str] = mapped_column(String(64), nullable=False) + + # 开启 / 关闭 + enabled: Mapped[bool] = mapped_column(default=True, nullable=False) + + # 上一次 / 下一次触发时间 + last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + next_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + # 非 FK(避免与 Task.schedule_id 构成循环依赖),仅做引用 + last_task_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + + owner_id: Mapped[int | None] = mapped_column( + ForeignKey("user.id", ondelete="SET NULL"), nullable=True + ) + + +class ScheduleLog(Base): + __tablename__ = "schedule_log" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + schedule_id: Mapped[int] = mapped_column( + ForeignKey("schedule.id", ondelete="CASCADE"), nullable=False, index=True + ) + task_id: Mapped[int | None] = mapped_column( + ForeignKey("task.id", ondelete="SET NULL"), nullable=True + ) + # ok / skipped / failed + result: Mapped[str] = mapped_column(String(16), default="ok", nullable=False) + message: Mapped[str | None] = mapped_column(String(255), nullable=True) + triggered_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, index=True + ) + cost_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False) diff --git a/backend/app/models/script_template.py b/backend/app/models/script_template.py new file mode 100644 index 0000000..fdac83e --- /dev/null +++ b/backend/app/models/script_template.py @@ -0,0 +1,25 @@ +"""脚本模板(Python/Node/Shell)。版本由 `version` 字段自增。""" +from __future__ import annotations + +from sqlalchemy import ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin + + +class ScriptTemplate(Base, TimestampMixin): + __tablename__ = "script_template" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + # python / node / shell + language: Mapped[str] = mapped_column(String(16), nullable=False, default="python") + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + content: Mapped[str] = mapped_column(Text, nullable=False, default="") + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + timeout_seconds: Mapped[int] = mapped_column(Integer, default=60, nullable=False) + status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False) + owner_id: Mapped[int | None] = mapped_column( + ForeignKey("user.id", ondelete="SET NULL"), nullable=True + ) diff --git a/backend/app/models/skill.py b/backend/app/models/skill.py new file mode 100644 index 0000000..ee2e60a --- /dev/null +++ b/backend/app/models/skill.py @@ -0,0 +1,89 @@ +"""Skill:双执行器(龙虾 / 脚本)注册 + 参数 schema + 产品范围绑定。""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import ( + JSON, + Column, + DateTime, + ForeignKey, + Integer, + String, + Table, + Text, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base, TimestampMixin + +skill_product_scope = Table( + "skill_product_scope", + Base.metadata, + Column("skill_id", Integer, ForeignKey("skill.id", ondelete="CASCADE"), primary_key=True), + Column("product_id", Integer, ForeignKey("product.id", ondelete="CASCADE"), primary_key=True), +) + + +class Skill(Base, TimestampMixin): + __tablename__ = "skill" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + category: Mapped[str | None] = mapped_column(String(32), nullable=True) + # LOBSTER / SCRIPT + executor_type: Mapped[str] = mapped_column(String(16), nullable=False, default="LOBSTER") + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + + # 输入参数 JSON Schema(展示给用户填写) + input_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True) + # 参数映射表达式:{ "lobster_param_key": "{{input.field}}" } 之类 + param_mapping: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + # 龙虾模板 ID / 脚本模板 ID (二选一,由 executor_type 决定) + lobster_template_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + script_template_id: Mapped[int | None] = mapped_column( + ForeignKey("script_template.id", ondelete="SET NULL"), nullable=True + ) + + status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False) + + # 执行示例 / 说明文档(可选) + doc: Mapped[str | None] = mapped_column(Text, nullable=True) + + product_scope: Mapped[list["Product"]] = relationship( # type: ignore[name-defined] # noqa: F821 + "Product", secondary=skill_product_scope, lazy="selectin" + ) + + +class SkillCallLog(Base): + """Skill 调用审计(由测试台 / 任务执行器写入)。""" + + __tablename__ = "skill_call_log" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + skill_id: Mapped[int] = mapped_column( + ForeignKey("skill.id", ondelete="CASCADE"), nullable=False, index=True + ) + skill_code: Mapped[str] = mapped_column(String(64), nullable=False) + operator_id: Mapped[int | None] = mapped_column( + ForeignKey("user.id", ondelete="SET NULL"), nullable=True, index=True + ) + operator_name: Mapped[str | None] = mapped_column(String(64), nullable=True) + executor_type: Mapped[str] = mapped_column(String(16), nullable=False) + product_id: Mapped[int | None] = mapped_column( + ForeignKey("product.id", ondelete="SET NULL"), nullable=True + ) + input_data: Mapped[dict | None] = mapped_column(JSON, nullable=True) + output_data: Mapped[dict | None] = mapped_column(JSON, nullable=True) + # ok / failed + result: Mapped[str] = mapped_column(String(16), nullable=False, default="ok") + message: Mapped[str | None] = mapped_column(String(512), nullable=True) + cost_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + # 是测试台调用还是真实任务调用 + source: Mapped[str] = mapped_column(String(16), default="test", nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, index=True + ) diff --git a/backend/app/models/task.py b/backend/app/models/task.py new file mode 100644 index 0000000..61307bb --- /dev/null +++ b/backend/app/models/task.py @@ -0,0 +1,92 @@ +"""任务 + 任务日志。""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import ( + JSON, + DateTime, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin + + +class Task(Base, TimestampMixin): + __tablename__ = "task" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + # 任务编号(人类可读),由服务层生成,如 T20260424-0001 + task_no: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, index=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + + skill_id: Mapped[int] = mapped_column( + ForeignKey("skill.id", ondelete="RESTRICT"), nullable=False, index=True + ) + skill_code: Mapped[str] = mapped_column(String(64), nullable=False) + product_id: Mapped[int | None] = mapped_column( + ForeignKey("product.id", ondelete="SET NULL"), nullable=True, index=True + ) + + # LOBSTER / SCRIPT + executor_type: Mapped[str] = mapped_column(String(16), nullable=False) + # 用户输入的参数(JSON Schema 校验过) + input_data: Mapped[dict | None] = mapped_column(JSON, nullable=True) + # 经过 param_mapping 翻译后传给执行器的实际参数 + resolved_input: Mapped[dict | None] = mapped_column(JSON, nullable=True) + # 执行器返回的结果 + result_data: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + # PENDING / QUEUED / RUNNING / SUCCEEDED / FAILED / TIMEOUT / CANCELED + status: Mapped[str] = mapped_column( + String(16), nullable=False, default="PENDING", index=True + ) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + + # 外部执行系统返回的参考 ID(龙虾 run_id / celery task id) + external_ref: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + + # 任务触发来源:wizard / retry / schedule + source: Mapped[str] = mapped_column(String(16), default="wizard", nullable=False) + schedule_id: Mapped[int | None] = mapped_column( + ForeignKey("schedule.id", ondelete="SET NULL"), nullable=True, index=True + ) + retry_of_id: Mapped[int | None] = mapped_column( + ForeignKey("task.id", ondelete="SET NULL"), nullable=True + ) + + operator_id: Mapped[int | None] = mapped_column( + ForeignKey("user.id", ondelete="SET NULL"), nullable=True, index=True + ) + operator_name: Mapped[str | None] = mapped_column(String(64), nullable=True) + + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + cost_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + timeout_seconds: Mapped[int] = mapped_column(Integer, default=60, nullable=False) + + +class TaskLog(Base): + __tablename__ = "task_log" + __table_args__ = ( + UniqueConstraint("task_id", "external_event_id", name="uq_task_log_event"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + task_id: Mapped[int] = mapped_column( + ForeignKey("task.id", ondelete="CASCADE"), nullable=False, index=True + ) + # INFO / WARN / ERROR / STATUS + level: Mapped[str] = mapped_column(String(8), default="INFO", nullable=False) + message: Mapped[str] = mapped_column(Text, nullable=False) + # 龙虾回调带的事件 id(同一事件不重复写) + external_event_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, index=True + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..05e8040 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,38 @@ +"""用户 + 用户-角色关联。""" +from __future__ import annotations + +from sqlalchemy import Column, ForeignKey, Integer, String, Table +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base, TimestampMixin + +user_role = Table( + "user_role", + Base.metadata, + Column("user_id", Integer, ForeignKey("user.id", ondelete="CASCADE"), primary_key=True), + Column("role_id", Integer, ForeignKey("role.id", ondelete="CASCADE"), primary_key=True), +) + + +class User(Base, TimestampMixin): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + nickname: Mapped[str | None] = mapped_column(String(64), nullable=True) + email: Mapped[str | None] = mapped_column(String(128), nullable=True) + avatar: Mapped[str | None] = mapped_column(String(255), nullable=True) + dept_id: Mapped[int | None] = mapped_column( + ForeignKey("organization.id", ondelete="SET NULL"), nullable=True, index=True + ) + status: Mapped[str] = mapped_column(String(16), default="ACTIVE", nullable=False) + # 是否超级管理员(绕过菜单权限校验,仅用于系统初始账号) + is_superuser: Mapped[bool] = mapped_column(default=False, nullable=False) + + roles: Mapped[list["Role"]] = relationship( # type: ignore[name-defined] # noqa: F821 + "Role", secondary=user_role, lazy="selectin" + ) + dept: Mapped["Organization | None"] = relationship( # type: ignore[name-defined] # noqa: F821 + "Organization", lazy="selectin" + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..2228f42 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic DTO。P2 起按模块补齐。""" diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..7e99006 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,64 @@ +"""登录 / 用户画像 / 菜单树 DTO。""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class LoginRequest(BaseModel): + username: str = Field(min_length=1, max_length=64) + password: str = Field(min_length=1, max_length=128) + captcha: str | None = None + + +class TokenPair(BaseModel): + access_token: str + refresh_token: str + token_type: str = "Bearer" + expires_in: int + + +class RefreshRequest(BaseModel): + refresh_token: str + + +class MenuTreeNode(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + parent_id: int | None = None + name: str + type: str + path: str | None = None + component: str | None = None + icon: str | None = None + perm_code: str | None = None + sort: int = 0 + children: list["MenuTreeNode"] = Field(default_factory=list) + + +class RoleBrief(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + code: str + + +class UserInfo(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + nickname: str | None = None + email: str | None = None + avatar: str | None = None + dept_id: int | None = None + dept_name: str | None = None + is_superuser: bool = False + roles: list[RoleBrief] = Field(default_factory=list) + perm_codes: list[str] = Field(default_factory=list) + + +class MeResponse(BaseModel): + user: UserInfo + menu_tree: list[MenuTreeNode] diff --git a/backend/app/schemas/channel_code.py b/backend/app/schemas/channel_code.py new file mode 100644 index 0000000..c18e959 --- /dev/null +++ b/backend/app/schemas/channel_code.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class ChannelBase(BaseModel): + code: str = Field(min_length=1, max_length=64) + name: str = Field(min_length=1, max_length=64) + type: str = Field(default="INTERNAL", pattern=r"^(INTERNAL|NATURAL)$") + description: str | None = None + status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$") + + +class ChannelCreate(ChannelBase): + pass + + +class ChannelUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=64) + type: str | None = Field(default=None, pattern=r"^(INTERNAL|NATURAL)$") + description: str | None = None + status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$") + + +class ChannelRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + code: str + name: str + type: str + description: str | None = None + status: str diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py new file mode 100644 index 0000000..3dd7f35 --- /dev/null +++ b/backend/app/schemas/common.py @@ -0,0 +1,20 @@ +"""通用 DTO。""" +from __future__ import annotations + +from typing import Generic, TypeVar + +from pydantic import BaseModel, Field + +T = TypeVar("T") + + +class PageQuery(BaseModel): + page: int = Field(default=1, ge=1) + size: int = Field(default=20, ge=1, le=200) + + +class PageResp(BaseModel, Generic[T]): + list: list[T] + total: int + page: int + size: int diff --git a/backend/app/schemas/llm.py b/backend/app/schemas/llm.py new file mode 100644 index 0000000..816f8c9 --- /dev/null +++ b/backend/app/schemas/llm.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +# -------- Provider -------- + +class ProviderCreate(BaseModel): + code: str = Field(min_length=1, max_length=64) + name: str = Field(min_length=1, max_length=64) + vendor: str = Field(default="openai", pattern=r"^(openai|azure|anthropic|custom)$") + api_base: str = Field(min_length=1, max_length=255) + api_key: str | None = None + description: str | None = None + status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$") + + +class ProviderUpdate(BaseModel): + name: str | None = None + vendor: str | None = Field(default=None, pattern=r"^(openai|azure|anthropic|custom)$") + api_base: str | None = None + # 留空 / undefined 不改;空串清空 + api_key: str | None = None + description: str | None = None + status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$") + + +class ProviderRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + code: str + name: str + vendor: str + api_base: str + api_key_mask: str = "" + has_api_key: bool = False + description: str | None = None + status: str + + +# -------- Model -------- + +class ModelCreate(BaseModel): + provider_id: int + model: str = Field(min_length=1, max_length=128) + name: str = Field(min_length=1, max_length=128) + description: str | None = None + max_context: int = Field(default=8192, ge=1024, le=2_000_000) + price_input_per_1k: float = Field(default=0.0, ge=0) + price_output_per_1k: float = Field(default=0.0, ge=0) + status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$") + + +class ModelUpdate(BaseModel): + name: str | None = None + description: str | None = None + max_context: int | None = Field(default=None, ge=1024, le=2_000_000) + price_input_per_1k: float | None = Field(default=None, ge=0) + price_output_per_1k: float | None = Field(default=None, ge=0) + status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$") + + +class ModelRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + provider_id: int + provider_code: str | None = None + provider_name: str | None = None + model: str + name: str + description: str | None = None + max_context: int + price_input_per_1k: float + price_output_per_1k: float + status: str + + +# -------- Routing -------- + +class RuleBase(BaseModel): + name: str = Field(min_length=1, max_length=128) + priority: int = Field(default=100, ge=0, le=10000) + conditions: dict | None = None + model_id: int + enabled: bool = True + description: str | None = None + + +class RuleCreate(RuleBase): + pass + + +class RuleUpdate(BaseModel): + name: str | None = None + priority: int | None = Field(default=None, ge=0, le=10000) + conditions: dict | None = None + model_id: int | None = None + enabled: bool | None = None + description: str | None = None + + +class RuleRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + priority: int + conditions: dict | None = None + model_id: int + model_label: str | None = None # "provider/model" + enabled: bool + description: str | None = None + + +# -------- Chat / Usage -------- + +class ChatRequest(BaseModel): + """面向任务执行器 / playground 的统一请求。 + + - 若 `model` 指定(形如 "provider_code/model"),跳过路由 + - 否则 `conditions` 走路由命中 + """ + + messages: list[dict] + model: str | None = None + conditions: dict | None = None + temperature: float | None = Field(default=None, ge=0, le=2) + max_tokens: int | None = Field(default=None, ge=1) + stream: bool = False # 目前仅 false + source: str = Field(default="playground", max_length=16) + + +class ChatResponse(BaseModel): + provider: str + model: str + content: str + prompt_tokens: int + completion_tokens: int + total_tokens: int + cost_usd: float + cost_ms: int + + +class UsageRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + provider_id: int | None = None + provider_code: str | None = None + model_id: int | None = None + model: str | None = None + operator_id: int | None = None + operator_name: str | None = None + source: str + prompt_tokens: int + completion_tokens: int + total_tokens: int + cost_usd: float + result: str + message: str | None = None + cost_ms: int + created_at: datetime | None = None diff --git a/backend/app/schemas/lobster_config.py b/backend/app/schemas/lobster_config.py new file mode 100644 index 0000000..a857561 --- /dev/null +++ b/backend/app/schemas/lobster_config.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class LobsterConfigUpdate(BaseModel): + """所有字段可选;传值则覆盖 DB(空串 = 清除回落 .env);不传 = 保持不变。""" + + api_base: str | None = None + api_key: str | None = None + callback_secret: str | None = None + timeout_seconds: int | None = Field(default=None, ge=1, le=600) + poll_interval_seconds: int | None = Field(default=None, ge=1, le=60) + note: str | None = None + + +class LobsterConfigRead(BaseModel): + """回读。api_key / callback_secret 只返回掩码与是否配置标记;原文永不出口。""" + + api_base: str + api_base_source: str # "db" | "env" | "unset" + + api_key_mask: str + api_key_source: str + has_api_key: bool + + callback_secret_mask: str + callback_secret_source: str + has_callback_secret: bool + + timeout_seconds: int + timeout_source: str + + poll_interval_seconds: int + poll_interval_source: str + + mode: str # "real" / "mock" + note: str | None = None diff --git a/backend/app/schemas/menu.py b/backend/app/schemas/menu.py new file mode 100644 index 0000000..a7b63a5 --- /dev/null +++ b/backend/app/schemas/menu.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class MenuBase(BaseModel): + name: str = Field(min_length=1, max_length=64) + type: str = Field(pattern=r"^(dir|menu|button)$") + parent_id: int | None = None + path: str | None = None + component: str | None = None + icon: str | None = None + perm_code: str | None = None + sort: int = 0 + status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$") + + +class MenuCreate(MenuBase): + pass + + +class MenuUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=64) + type: str | None = Field(default=None, pattern=r"^(dir|menu|button)$") + parent_id: int | None = None + path: str | None = None + component: str | None = None + icon: str | None = None + perm_code: str | None = None + sort: int | None = None + status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$") + + +class MenuRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + parent_id: int | None = None + name: str + type: str + path: str | None = None + component: str | None = None + icon: str | None = None + perm_code: str | None = None + sort: int = 0 + status: str + + +class MenuTreeNode(MenuRead): + children: list["MenuTreeNode"] = Field(default_factory=list) diff --git a/backend/app/schemas/organization.py b/backend/app/schemas/organization.py new file mode 100644 index 0000000..50e3049 --- /dev/null +++ b/backend/app/schemas/organization.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class OrgBase(BaseModel): + name: str = Field(min_length=1, max_length=64) + code: str = Field(min_length=1, max_length=64) + type: str = Field(default="department", pattern=r"^(company|department|group)$") + parent_id: int | None = None + sort: int = 0 + description: str | None = None + + +class OrgCreate(OrgBase): + pass + + +class OrgUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=64) + type: str | None = Field(default=None, pattern=r"^(company|department|group)$") + parent_id: int | None = None + sort: int | None = None + description: str | None = None + + +class OrgRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + parent_id: int | None = None + name: str + code: str + type: str + sort: int = 0 + description: str | None = None + + +class OrgTreeNode(OrgRead): + children: list["OrgTreeNode"] = Field(default_factory=list) diff --git a/backend/app/schemas/platform.py b/backend/app/schemas/platform.py new file mode 100644 index 0000000..46fc6ca --- /dev/null +++ b/backend/app/schemas/platform.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class PlatformBase(BaseModel): + code: str = Field(min_length=1, max_length=64) + name: str = Field(min_length=1, max_length=64) + url: str | None = None + description: str | None = None + status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$") + + +class PlatformCreate(PlatformBase): + pass + + +class PlatformUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=64) + url: str | None = None + description: str | None = None + status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$") + + +class PlatformRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + code: str + name: str + url: str | None = None + description: str | None = None + status: str + product_count: int = 0 diff --git a/backend/app/schemas/platform_account.py b/backend/app/schemas/platform_account.py new file mode 100644 index 0000000..0b468b3 --- /dev/null +++ b/backend/app/schemas/platform_account.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class PlatformAccountBase(BaseModel): + platform_name: str = Field(min_length=1, max_length=64) + product_code: str | None = None + login: str = Field(min_length=1, max_length=128) + remark: str | None = None + status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$") + + +class PlatformAccountCreate(PlatformAccountBase): + password: str = Field(min_length=1, max_length=255) + google_key: str | None = None + + +class PlatformAccountUpdate(BaseModel): + platform_name: str | None = Field(default=None, min_length=1, max_length=64) + product_code: str | None = None + login: str | None = Field(default=None, min_length=1, max_length=128) + password: str | None = None # 留空表示不改 + google_key: str | None = None + remark: str | None = None + status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$") + + +class PlatformAccountRead(BaseModel): + """仅回传掩码后的凭据,原文永不出库。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + owner_id: int + owner_name: str | None = None + platform_name: str + product_code: str | None = None + login: str + password_mask: str = "" + google_key_mask: str = "" + has_google_key: bool = False + remark: str | None = None + status: str + last_test_result: str = "unknown" + last_test_message: str | None = None diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py new file mode 100644 index 0000000..28a1638 --- /dev/null +++ b/backend/app/schemas/product.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class ProductBase(BaseModel): + code: str = Field(min_length=1, max_length=64) + name: str = Field(min_length=1, max_length=128) + platform_id: int | None = None + dept_id: int | None = None + owner_id: int | None = None + description: str | None = None + status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$") + + +class ProductCreate(ProductBase): + channel_code_ids: list[int] = Field(default_factory=list) + + +class ProductUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=128) + platform_id: int | None = None + dept_id: int | None = None + owner_id: int | None = None + description: str | None = None + status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$") + channel_code_ids: list[int] | None = None + + +class ProductRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + code: str + name: str + platform_id: int | None = None + platform_name: str | None = None + dept_id: int | None = None + dept_name: str | None = None + owner_id: int | None = None + owner_name: str | None = None + description: str | None = None + status: str + channel_code_ids: list[int] = Field(default_factory=list) + channel_code_names: list[str] = Field(default_factory=list) diff --git a/backend/app/schemas/role.py b/backend/app/schemas/role.py new file mode 100644 index 0000000..f5c8ca0 --- /dev/null +++ b/backend/app/schemas/role.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class RoleBase(BaseModel): + name: str = Field(min_length=1, max_length=64) + code: str = Field(min_length=1, max_length=64) + dept_id: int | None = None + description: str | None = None + status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$") + + +class RoleCreate(RoleBase): + menu_ids: list[int] = Field(default_factory=list) + product_scope_ids: list[int] = Field(default_factory=list) + skill_scope_ids: list[int] = Field(default_factory=list) + account_scope_ids: list[int] = Field(default_factory=list) + + +class RoleUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=64) + dept_id: int | None = None + description: str | None = None + status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$") + menu_ids: list[int] | None = None + product_scope_ids: list[int] | None = None + skill_scope_ids: list[int] | None = None + account_scope_ids: list[int] | None = None + + +class RoleRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + code: str + dept_id: int | None = None + description: str | None = None + status: str + is_builtin: bool = False + + +class RoleDetail(RoleRead): + menu_ids: list[int] = Field(default_factory=list) + product_scope_ids: list[int] = Field(default_factory=list) + skill_scope_ids: list[int] = Field(default_factory=list) + account_scope_ids: list[int] = Field(default_factory=list) + dept_name: str | None = None diff --git a/backend/app/schemas/schedule.py b/backend/app/schemas/schedule.py new file mode 100644 index 0000000..32efd00 --- /dev/null +++ b/backend/app/schemas/schedule.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class ScheduleCreate(BaseModel): + name: str = Field(min_length=1, max_length=128) + skill_id: int + product_id: int | None = None + input_data: dict = Field(default_factory=dict) + cron: str = Field(min_length=9, max_length=64) + enabled: bool = True + + +class ScheduleUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=128) + product_id: int | None = None + input_data: dict | None = None + cron: str | None = Field(default=None, min_length=9, max_length=64) + enabled: bool | None = None + + +class ScheduleRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + skill_id: int + skill_code: str | None = None + skill_name: str | None = None + product_id: int | None = None + product_name: str | None = None + input_data: dict | None = None + cron: str + enabled: bool + last_run_at: datetime | None = None + next_run_at: datetime | None = None + last_task_id: int | None = None + owner_id: int | None = None + + +class ScheduleLogRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + schedule_id: int + task_id: int | None = None + result: str + message: str | None = None + triggered_at: datetime | None = None + cost_ms: int = 0 diff --git a/backend/app/schemas/script_template.py b/backend/app/schemas/script_template.py new file mode 100644 index 0000000..3ef1bf6 --- /dev/null +++ b/backend/app/schemas/script_template.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class ScriptBase(BaseModel): + code: str = Field(min_length=1, max_length=64) + name: str = Field(min_length=1, max_length=128) + language: str = Field(default="python", pattern=r"^(python|node|shell)$") + description: str | None = None + content: str = "" + timeout_seconds: int = Field(default=60, ge=1, le=3600) + status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$") + + +class ScriptCreate(ScriptBase): + pass + + +class ScriptUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=128) + language: str | None = Field(default=None, pattern=r"^(python|node|shell)$") + description: str | None = None + content: str | None = None + timeout_seconds: int | None = Field(default=None, ge=1, le=3600) + status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$") + + +class ScriptRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + code: str + name: str + language: str + description: str | None = None + content: str + version: int + timeout_seconds: int + status: str + owner_id: int | None = None diff --git a/backend/app/schemas/skill.py b/backend/app/schemas/skill.py new file mode 100644 index 0000000..6368803 --- /dev/null +++ b/backend/app/schemas/skill.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class SkillBase(BaseModel): + code: str = Field(min_length=1, max_length=64) + name: str = Field(min_length=1, max_length=128) + category: str | None = None + executor_type: str = Field(pattern=r"^(LOBSTER|SCRIPT)$") + description: str | None = None + input_schema: dict | None = None + param_mapping: dict | None = None + lobster_template_id: str | None = None + script_template_id: int | None = None + status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$") + doc: str | None = None + + +class SkillCreate(SkillBase): + product_scope_ids: list[int] = Field(default_factory=list) + + +class SkillUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=128) + category: str | None = None + executor_type: str | None = Field(default=None, pattern=r"^(LOBSTER|SCRIPT)$") + description: str | None = None + input_schema: dict | None = None + param_mapping: dict | None = None + lobster_template_id: str | None = None + script_template_id: int | None = None + status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$") + doc: str | None = None + product_scope_ids: list[int] | None = None + + +class SkillRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + code: str + name: str + category: str | None = None + executor_type: str + description: str | None = None + input_schema: dict | None = None + param_mapping: dict | None = None + lobster_template_id: str | None = None + script_template_id: int | None = None + status: str + doc: str | None = None + product_scope_ids: list[int] = Field(default_factory=list) + product_scope_names: list[str] = Field(default_factory=list) + + +class SkillTestRequest(BaseModel): + input_data: dict = Field(default_factory=dict) + product_id: int | None = None + + +class SkillCallLogRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + skill_id: int + skill_code: str + operator_id: int | None = None + operator_name: str | None = None + executor_type: str + product_id: int | None = None + input_data: dict | None = None + output_data: dict | None = None + result: str + message: str | None = None + cost_ms: int + source: str + created_at: str | None = None diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py new file mode 100644 index 0000000..d1fba5d --- /dev/null +++ b/backend/app/schemas/task.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class TaskCreate(BaseModel): + name: str = Field(min_length=1, max_length=128) + skill_id: int + product_id: int | None = None + input_data: dict = Field(default_factory=dict) + timeout_seconds: int = Field(default=60, ge=1, le=3600) + + +class TaskRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + task_no: str + name: str + skill_id: int + skill_code: str + skill_name: str | None = None + product_id: int | None = None + product_name: str | None = None + executor_type: str + status: str + error_message: str | None = None + external_ref: str | None = None + source: str + operator_id: int | None = None + operator_name: str | None = None + input_data: dict | None = None + resolved_input: dict | None = None + result_data: dict | None = None + started_at: datetime | None = None + finished_at: datetime | None = None + cost_ms: int = 0 + timeout_seconds: int = 60 + created_at: datetime | None = None + + +class TaskLogRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + task_id: int + level: str + message: str + created_at: datetime | None = None diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..31f022d --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +class UserBase(BaseModel): + username: str = Field(min_length=1, max_length=64) + nickname: str | None = None + email: EmailStr | None = None + dept_id: int | None = None + status: str = Field(default="ACTIVE", pattern=r"^(ACTIVE|DISABLED)$") + + +class UserCreate(UserBase): + password: str = Field(min_length=6, max_length=128) + role_ids: list[int] = Field(default_factory=list) + + +class UserUpdate(BaseModel): + nickname: str | None = None + email: EmailStr | None = None + dept_id: int | None = None + status: str | None = Field(default=None, pattern=r"^(ACTIVE|DISABLED)$") + role_ids: list[int] | None = None + + +class UserResetPwd(BaseModel): + password: str = Field(min_length=6, max_length=128) + + +class UserBrief(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + nickname: str | None = None + status: str + is_superuser: bool = False + + +class UserRead(UserBrief): + email: str | None = None + dept_id: int | None = None + dept_name: str | None = None + role_ids: list[int] = Field(default_factory=list) + role_names: list[str] = Field(default_factory=list) diff --git a/backend/app/scripts/__init__.py b/backend/app/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/scripts/seed.py b/backend/app/scripts/seed.py new file mode 100644 index 0000000..4541e1c --- /dev/null +++ b/backend/app/scripts/seed.py @@ -0,0 +1,250 @@ +"""P2 初始化种子: + +- 1 个根公司(Loom)+ 1 个部门(总部) +- 超级管理员角色(is_builtin=True) +- 业务员角色(仅任务/Skill 基础权限,用于演示动态菜单差异) +- admin 超管账号(密码 admin123) +- operator 演示账号(密码 operator123,绑定业务员角色) +- 完整菜单树(覆盖 19 个业务页面 + 按钮权限) + +执行: + python -m app.scripts.seed +可重复运行,根据 username/code 幂等处理。 +""" +from __future__ import annotations + +import asyncio +from typing import Any + +from sqlalchemy import delete, insert, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import hash_password +from app.db.session import AsyncSessionLocal +from app.models import Menu, Organization, Role, User, role_menu, user_role + +# ---------- 菜单结构 ---------- +# 每项:(name, type, path, icon, perm_code, children) +MENU_TREE: list[dict[str, Any]] = [ + { + "name": "仪表盘", "type": "menu", "path": "/dashboard", + "icon": "DashboardOutlined", "perm_code": "dashboard:view", + }, + { + "name": "权限管理", "type": "dir", "path": "/auth", "icon": "SafetyOutlined", + "children": [ + {"name": "组织架构", "type": "menu", "path": "/auth/org", + "perm_code": "auth:org:view"}, + {"name": "角色管理", "type": "menu", "path": "/auth/role", + "perm_code": "auth:role:view"}, + {"name": "用户管理", "type": "menu", "path": "/auth/user", + "perm_code": "auth:user:view"}, + {"name": "菜单管理", "type": "menu", "path": "/auth/menu", + "perm_code": "auth:menu:view"}, + ], + }, + { + "name": "产品管理", "type": "dir", "path": "/product", "icon": "AppstoreOutlined", + "children": [ + {"name": "产品", "type": "menu", "path": "/product/list", + "perm_code": "product:view"}, + {"name": "渠道码", "type": "menu", "path": "/product/channel", + "perm_code": "product:channel:view"}, + {"name": "平台套件", "type": "menu", "path": "/product/suite", + "perm_code": "product:suite:view"}, + ], + }, + { + "name": "平台账号", "type": "menu", "path": "/platform-account", + "icon": "KeyOutlined", "perm_code": "platform-account:view", + }, + { + "name": "Skill 中心", "type": "dir", "path": "/skill", "icon": "ApiOutlined", + "children": [ + {"name": "Skill 管理", "type": "menu", "path": "/skill/list", + "perm_code": "skill:view"}, + {"name": "调用日志", "type": "menu", "path": "/skill/log", + "perm_code": "skill:log:view"}, + ], + }, + { + "name": "任务中心", "type": "dir", "path": "/task", "icon": "ThunderboltOutlined", + "children": [ + {"name": "创建任务", "type": "menu", "path": "/task/create", + "perm_code": "task:create"}, + {"name": "任务列表", "type": "menu", "path": "/task/list", + "perm_code": "task:list"}, + ], + }, + { + "name": "日志观测", "type": "dir", "path": "/log", "icon": "LineChartOutlined", + "children": [ + {"name": "操作日志", "type": "menu", "path": "/log/op", + "perm_code": "log:op:view"}, + {"name": "统计报表", "type": "menu", "path": "/log/report", + "perm_code": "log:report:view"}, + ], + }, + { + "name": "系统设置", "type": "dir", "path": "/system", "icon": "SettingOutlined", + "children": [ + {"name": "执行器配置", "type": "menu", "path": "/system/executor", + "perm_code": "system:executor:view"}, + {"name": "脚本模板", "type": "menu", "path": "/system/script", + "perm_code": "system:script:view"}, + {"name": "定时任务", "type": "menu", "path": "/system/cron", + "perm_code": "system:cron:view"}, + ], + }, +] + +# 业务员角色仅授予这些菜单/权限 +OPERATOR_PERMS = { + "dashboard:view", + "task:create", + "task:list", + "skill:view", +} + + +async def upsert_org(db: AsyncSession, code: str, name: str, type_: str, + parent_id: int | None = None) -> Organization: + stmt = select(Organization).where(Organization.code == code) + org = (await db.execute(stmt)).scalar_one_or_none() + if org is None: + org = Organization(code=code, name=name, type=type_, parent_id=parent_id) + db.add(org) + await db.flush() + return org + + +async def upsert_menu(db: AsyncSession, node: dict[str, Any], + parent_id: int | None, sort_start: int = 0) -> list[Menu]: + path = node.get("path") + name = node["name"] + stmt = select(Menu).where( + (Menu.path == path) if path else (Menu.name == name) + ) + m = (await db.execute(stmt)).scalar_one_or_none() + if m is None: + m = Menu( + name=name, + type=node["type"], + path=path, + component=node.get("component"), + icon=node.get("icon"), + perm_code=node.get("perm_code"), + parent_id=parent_id, + sort=sort_start, + ) + db.add(m) + await db.flush() + else: + m.parent_id = parent_id + m.sort = sort_start + m.type = node["type"] + m.icon = node.get("icon") + m.perm_code = node.get("perm_code") + created = [m] + for i, child in enumerate(node.get("children", [])): + created.extend(await upsert_menu(db, child, m.id, sort_start=i)) + return created + + +async def upsert_role(db: AsyncSession, code: str, name: str, *, + dept_id: int | None, is_builtin: bool) -> Role: + stmt = select(Role).where(Role.code == code) + stmt = stmt.where(Role.dept_id.is_(None) if dept_id is None else Role.dept_id == dept_id) + role = (await db.execute(stmt)).scalar_one_or_none() + if role is None: + role = Role(code=code, name=name, dept_id=dept_id, is_builtin=is_builtin) + db.add(role) + await db.flush() + return role + + +async def _replace_assoc( + db: AsyncSession, table, left_col: str, left_val: int, right_col: str, right_vals: list[int] +) -> None: + """清空再插入关联表,避免 async 下 relationship 集合属性的 lazy-load 限制。""" + await db.execute(delete(table).where(table.c[left_col] == left_val)) + if right_vals: + await db.execute( + insert(table), + [{left_col: left_val, right_col: rv} for rv in right_vals], + ) + + +async def upsert_user(db: AsyncSession, username: str, password: str, *, + nickname: str, dept_id: int | None, is_superuser: bool) -> User: + stmt = select(User).where(User.username == username) + u = (await db.execute(stmt)).scalar_one_or_none() + if u is None: + u = User( + username=username, + password_hash=hash_password(password), + nickname=nickname, + dept_id=dept_id, + is_superuser=is_superuser, + ) + db.add(u) + await db.flush() + return u + + +async def seed() -> None: + async with AsyncSessionLocal() as db: + # 组织 + company = await upsert_org(db, "LOOM", "Loom 公司", "company") + hq = await upsert_org(db, "HQ", "总部", "department", parent_id=company.id) + + # 菜单 + all_menus: list[Menu] = [] + for i, node in enumerate(MENU_TREE): + all_menus.extend(await upsert_menu(db, node, parent_id=None, sort_start=i)) + await db.flush() + + # 角色 + super_role = await upsert_role( + db, "super_admin", "超级管理员", dept_id=None, is_builtin=True + ) + operator_role = await upsert_role( + db, "operator", "业务员", dept_id=hq.id, is_builtin=False + ) + + # 把所有菜单授权给超管;业务员仅授权 OPERATOR_PERMS + 祖先目录 + operator_menu_ids: set[int] = set() + menu_by_id = {m.id: m for m in all_menus} + for m in all_menus: + if m.perm_code in OPERATOR_PERMS: + cur: Menu | None = m + while cur and cur.id not in operator_menu_ids: + operator_menu_ids.add(cur.id) + cur = menu_by_id.get(cur.parent_id) if cur.parent_id else None + + # 用直接操作关联表的方式写权限,避免 async session 对集合属性的 lazy-load 麻烦 + await _replace_assoc(db, role_menu, "role_id", super_role.id, "menu_id", + [m.id for m in all_menus]) + await _replace_assoc(db, role_menu, "role_id", operator_role.id, "menu_id", + list(operator_menu_ids)) + + # 用户 + admin = await upsert_user( + db, "admin", "admin123", + nickname="超级管理员", dept_id=hq.id, is_superuser=True, + ) + operator = await upsert_user( + db, "operator", "operator123", + nickname="业务员", dept_id=hq.id, is_superuser=False, + ) + await _replace_assoc(db, user_role, "user_id", admin.id, "role_id", [super_role.id]) + await _replace_assoc(db, user_role, "user_id", operator.id, "role_id", [operator_role.id]) + + await db.commit() + print("[seed] done.") + print(" 超管 admin / admin123 (全部菜单)") + print(" 业务员 operator / operator123 (仪表盘 + 任务 + Skill)") + + +if __name__ == "__main__": + asyncio.run(seed()) diff --git a/backend/app/scripts/seed_p4.py b/backend/app/scripts/seed_p4.py new file mode 100644 index 0000000..206b6a0 --- /dev/null +++ b/backend/app/scripts/seed_p4.py @@ -0,0 +1,169 @@ +"""P4 种子:默认平台 + 脚本模板 + 一个示例 Skill(LOBSTER 模式)。 + +执行:python -m app.scripts.seed_p4 +可重复运行。 +""" +from __future__ import annotations + +import asyncio + +from sqlalchemy import delete, insert, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import AsyncSessionLocal +from app.models import ( + ChannelCode, + Platform, + Product, + ScriptTemplate, + Skill, + product_channel_code, + skill_product_scope, +) + + +DEFAULT_PLATFORMS = [ + {"code": "facebook", "name": "Facebook", "url": "https://facebook.com"}, + {"code": "tiktok", "name": "TikTok", "url": "https://tiktok.com"}, + {"code": "google", "name": "Google Ads", "url": "https://ads.google.com"}, +] + +DEFAULT_CHANNELS = [ + {"code": "FB_INT_01", "name": "FB 内导 01", "type": "INTERNAL"}, + {"code": "FB_NAT_01", "name": "FB 自然 01", "type": "NATURAL"}, + {"code": "TT_INT_01", "name": "TT 内导 01", "type": "INTERNAL"}, +] + +DEFAULT_SCRIPTS = [ + { + "code": "py_hello", + "name": "Python Hello", + "language": "python", + "description": "示例脚本,输出欢迎信息", + "content": 'print("hello from loom script")\n', + "timeout_seconds": 30, + }, + { + "code": "sh_curl_demo", + "name": "Shell curl 演示", + "language": "shell", + "description": "示例 curl 调用", + "content": 'echo "fetching..."\ncurl -s https://httpbin.org/ip\n', + "timeout_seconds": 60, + }, +] + +DEFAULT_SKILL = { + "code": "fb_lobster_post", + "name": "Facebook 发帖(龙虾)", + "category": "social", + "executor_type": "LOBSTER", + "description": "调用龙虾模板完成 Facebook 发帖", + "input_schema": { + "type": "object", + "required": ["title", "content"], + "properties": { + "title": {"type": "string", "title": "标题", "maxLength": 80}, + "content": {"type": "string", "title": "正文", "format": "textarea"}, + "image_url": {"type": "string", "title": "图片链接"}, + "schedule_at": {"type": "string", "title": "定时时间", "format": "date-time"}, + }, + }, + "param_mapping": { + "post_title": "{{input.title}}", + "post_body": "{{input.content}}", + "image_url": "{{input.image_url}}", + "scheduled": "{{input.schedule_at}}", + }, + "lobster_template_id": "lobster-demo-001", + "doc": "演示 Skill:LOBSTER 执行器 + 产品范围 + JSON Schema 参数。.env 配置 LOBSTER_API_KEY 后自动走真实龙虾 API,否则走客户端内置 mock。", +} + + +async def upsert(db: AsyncSession, model, key_field: str, value, **defaults): + stmt = select(model).where(getattr(model, key_field) == value) + obj = (await db.execute(stmt)).scalar_one_or_none() + if obj is None: + obj = model(**{key_field: value}, **defaults) + db.add(obj) + await db.flush() + else: + for k, v in defaults.items(): + setattr(obj, k, v) + return obj + + +async def seed() -> None: + async with AsyncSessionLocal() as db: + platforms = {} + for p in DEFAULT_PLATFORMS: + obj = await upsert(db, Platform, "code", p["code"], + name=p["name"], url=p["url"]) + platforms[p["code"]] = obj + + channels = {} + for c in DEFAULT_CHANNELS: + obj = await upsert(db, ChannelCode, "code", c["code"], + name=c["name"], type=c["type"]) + channels[c["code"]] = obj + + # 一个示例产品:FB / 产品代号 fb_demo + fb_product = await upsert( + db, Product, "code", "fb_demo", + name="FB 示例产品", + platform_id=platforms["facebook"].id, + description="P4 演示用产品", + ) + # 绑定 2 个渠道码 + await db.execute( + delete(product_channel_code).where( + product_channel_code.c.product_id == fb_product.id + ) + ) + await db.execute( + insert(product_channel_code), + [ + {"product_id": fb_product.id, "channel_code_id": channels["FB_INT_01"].id}, + {"product_id": fb_product.id, "channel_code_id": channels["FB_NAT_01"].id}, + ], + ) + + for s in DEFAULT_SCRIPTS: + await upsert(db, ScriptTemplate, "code", s["code"], **{ + "name": s["name"], + "language": s["language"], + "description": s["description"], + "content": s["content"], + "timeout_seconds": s["timeout_seconds"], + }) + + # 示例 Skill + stmt = select(Skill).where(Skill.code == DEFAULT_SKILL["code"]) + skill = (await db.execute(stmt)).scalar_one_or_none() + if skill is None: + skill = Skill(**DEFAULT_SKILL) + db.add(skill) + await db.flush() + else: + for k, v in DEFAULT_SKILL.items(): + setattr(skill, k, v) + + await db.execute( + delete(skill_product_scope).where(skill_product_scope.c.skill_id == skill.id) + ) + await db.execute( + insert(skill_product_scope), + [{"skill_id": skill.id, "product_id": fb_product.id}], + ) + + await db.commit() + print("[seed_p4] done.") + print(f" platforms: {[p['code'] for p in DEFAULT_PLATFORMS]}") + print(f" channels: {[c['code'] for c in DEFAULT_CHANNELS]}") + print(f" scripts: {[s['code'] for s in DEFAULT_SCRIPTS]}") + print(f" product: fb_demo (2 channels)") + print(f" skill: {DEFAULT_SKILL['code']} (LOBSTER, scope=[fb_demo])") + + +if __name__ == "__main__": + asyncio.run(seed()) diff --git a/backend/app/scripts/seed_p6.py b/backend/app/scripts/seed_p6.py new file mode 100644 index 0000000..08b8b96 --- /dev/null +++ b/backend/app/scripts/seed_p6.py @@ -0,0 +1,121 @@ +"""P6 种子:OpenAI provider + 2 个模型 + 默认路由规则。 + +API Key 从 `.env` 的 `OPENAI_API_KEY` 读取;未配置也能建 provider(走 mock)。 +执行:python -m app.scripts.seed_p6 +""" +from __future__ import annotations + +import asyncio + +from sqlalchemy import delete, insert, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.crypto import encrypt +from app.db.session import AsyncSessionLocal +from app.models import LLMModel, LLMProvider, LLMRoutingRule + + +DEFAULT_PROVIDER = { + "code": "openai", + "name": "OpenAI", + "vendor": "openai", + "api_base": settings.OPENAI_API_BASE or "https://api.openai.com/v1", + "description": "OpenAI 官方;联调阶段如 .env 未配 OPENAI_API_KEY,调用将走 mock 回显", +} + +DEFAULT_MODELS = [ + { + "model": "gpt-4o-mini", + "name": "GPT-4o mini", + "description": "通用场景推荐;响应快、成本低", + "max_context": 128_000, + "price_input_per_1k": 0.00015, + "price_output_per_1k": 0.00060, + }, + { + "model": "gpt-4o", + "name": "GPT-4o", + "description": "高复杂度推理", + "max_context": 128_000, + "price_input_per_1k": 0.0025, + "price_output_per_1k": 0.01, + }, +] + + +async def upsert(db: AsyncSession, model, key_field: str, value, **defaults): + stmt = select(model).where(getattr(model, key_field) == value) + obj = (await db.execute(stmt)).scalar_one_or_none() + if obj is None: + obj = model(**{key_field: value}, **defaults) + db.add(obj) + await db.flush() + else: + for k, v in defaults.items(): + setattr(obj, k, v) + return obj + + +async def seed() -> None: + async with AsyncSessionLocal() as db: + prov = await upsert( + db, LLMProvider, "code", DEFAULT_PROVIDER["code"], + name=DEFAULT_PROVIDER["name"], + vendor=DEFAULT_PROVIDER["vendor"], + api_base=DEFAULT_PROVIDER["api_base"], + description=DEFAULT_PROVIDER["description"], + ) + if settings.OPENAI_API_KEY: + prov.api_key_cipher = encrypt(settings.OPENAI_API_KEY) + await db.flush() + + models: dict[str, LLMModel] = {} + for m in DEFAULT_MODELS: + stmt = select(LLMModel).where( + LLMModel.provider_id == prov.id, LLMModel.model == m["model"] + ) + obj = (await db.execute(stmt)).scalar_one_or_none() + if obj is None: + obj = LLMModel(provider_id=prov.id, **m) + db.add(obj) + await db.flush() + else: + for k, v in m.items(): + setattr(obj, k, v) + models[m["model"]] = obj + + # 默认路由规则:无条件兜底 → gpt-4o-mini + await db.execute( + delete(LLMRoutingRule).where(LLMRoutingRule.name == "default") + ) + db.add(LLMRoutingRule( + name="default", + priority=1000, + conditions=None, + model_id=models["gpt-4o-mini"].id, + enabled=True, + description="全局兜底路由", + )) + # 高质量:category=ad 或 reasoning 命中 gpt-4o + await db.execute( + delete(LLMRoutingRule).where(LLMRoutingRule.name == "premium") + ) + db.add(LLMRoutingRule( + name="premium", + priority=10, + conditions={"category": "reasoning"}, + model_id=models["gpt-4o"].id, + enabled=True, + description="高复杂度推理使用 gpt-4o", + )) + + await db.commit() + print("[seed_p6] done.") + print(f" provider: {prov.code} (api_key_configured={bool(settings.OPENAI_API_KEY)})") + print(f" models: {list(models.keys())}") + print(f" rules: default(gpt-4o-mini) / premium(category=reasoning → gpt-4o)") + + +if __name__ == "__main__": + asyncio.run(seed()) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..87741d6 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""业务服务层。P2 起按模块补齐。""" diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..266990a --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,60 @@ +"""登录 / Token 刷新业务逻辑。""" +from __future__ import annotations + +from fastapi import status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import AppError +from app.core.security import ( + JWTError, + create_access_token, + create_refresh_token, + decode_token, + verify_password, +) +from app.models import User +from app.schemas.auth import TokenPair +from app.core.config import settings + + +async def login(db: AsyncSession, username: str, password: str) -> tuple[User, TokenPair]: + stmt = select(User).where(User.username == username) + user = (await db.execute(stmt)).scalar_one_or_none() + if user is None or not verify_password(password, user.password_hash): + raise AppError("账号或密码错误", code=401, http_status=status.HTTP_401_UNAUTHORIZED) + if user.status != "ACTIVE": + raise AppError("账号已禁用", code=403, http_status=status.HTTP_403_FORBIDDEN) + + access = create_access_token(user.id) + refresh = create_refresh_token(user.id) + return user, TokenPair( + access_token=access, + refresh_token=refresh, + expires_in=settings.JWT_ACCESS_TTL, + ) + + +async def refresh(db: AsyncSession, refresh_token: str) -> TokenPair: + try: + payload = decode_token(refresh_token) + except JWTError as e: + raise AppError( + "refresh_token 无效", code=401, http_status=status.HTTP_401_UNAUTHORIZED + ) from e + if payload.get("type") != "refresh": + raise AppError("token 类型错误", code=401, http_status=status.HTTP_401_UNAUTHORIZED) + try: + user_id = int(payload["sub"]) + except (KeyError, ValueError) as e: + raise AppError("token 无效", code=401, http_status=status.HTTP_401_UNAUTHORIZED) from e + + user = await db.get(User, user_id) + if user is None or user.status != "ACTIVE": + raise AppError("账号已失效", code=401, http_status=status.HTTP_401_UNAUTHORIZED) + + return TokenPair( + access_token=create_access_token(user.id), + refresh_token=create_refresh_token(user.id), + expires_in=settings.JWT_ACCESS_TTL, + ) diff --git a/backend/app/services/channel_code_service.py b/backend/app/services/channel_code_service.py new file mode 100644 index 0000000..2cf6a67 --- /dev/null +++ b/backend/app/services/channel_code_service.py @@ -0,0 +1,65 @@ +"""渠道码 CRUD。""" +from __future__ import annotations + +from fastapi import status +from sqlalchemy import func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import AppError +from app.models import ChannelCode +from app.schemas.channel_code import ChannelCreate, ChannelUpdate + + +async def paginate( + db: AsyncSession, page: int, size: int, keyword: str | None = None, type_: str | None = None +) -> tuple[list[ChannelCode], int]: + stmt = select(ChannelCode) + if keyword: + like = f"%{keyword}%" + stmt = stmt.where(or_(ChannelCode.code.ilike(like), ChannelCode.name.ilike(like))) + if type_: + stmt = stmt.where(ChannelCode.type == type_) + total = (await db.execute( + select(func.count()).select_from(stmt.order_by(None).subquery()) + )).scalar() or 0 + stmt = stmt.order_by(ChannelCode.id.desc()).offset((page - 1) * size).limit(size) + rows = list((await db.execute(stmt)).scalars().all()) + return rows, total + + +async def list_all(db: AsyncSession) -> list[ChannelCode]: + res = await db.execute(select(ChannelCode).order_by(ChannelCode.id)) + return list(res.scalars().all()) + + +async def get(db: AsyncSession, cid: int) -> ChannelCode: + c = await db.get(ChannelCode, cid) + if c is None: + raise AppError("渠道码不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + return c + + +async def create(db: AsyncSession, data: ChannelCreate) -> ChannelCode: + res = await db.execute(select(ChannelCode).where(ChannelCode.code == data.code)) + if res.scalar_one_or_none(): + raise AppError("code 已存在", code=409, http_status=status.HTTP_409_CONFLICT) + c = ChannelCode(**data.model_dump()) + db.add(c) + await db.commit() + await db.refresh(c) + return c + + +async def update(db: AsyncSession, cid: int, data: ChannelUpdate) -> ChannelCode: + c = await get(db, cid) + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(c, k, v) + await db.commit() + await db.refresh(c) + return c + + +async def delete(db: AsyncSession, cid: int) -> None: + c = await get(db, cid) + await db.delete(c) + await db.commit() diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py new file mode 100644 index 0000000..a4db82d --- /dev/null +++ b/backend/app/services/llm_service.py @@ -0,0 +1,409 @@ +"""LLM 服务:Provider / Model / RoutingRule CRUD、路由匹配、对话调用、用量记账。""" +from __future__ import annotations + +import time +from typing import Any + +from fastapi import status +from sqlalchemy import func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.crypto import decrypt, encrypt, mask +from app.core.exceptions import AppError +from app.integrations.openai_client import ChatResult, chat_completion +from app.models import LLMModel, LLMProvider, LLMRoutingRule, LLMUsage, User +from app.schemas.llm import ( + ChatResponse, + ModelCreate, + ModelRead, + ModelUpdate, + ProviderCreate, + ProviderRead, + ProviderUpdate, + RuleCreate, + RuleRead, + RuleUpdate, +) + + +# -------- Provider -------- + +async def list_providers(db: AsyncSession) -> list[ProviderRead]: + res = await db.execute(select(LLMProvider).order_by(LLMProvider.id)) + return [provider_to_read(p) for p in res.scalars().all()] + + +def provider_to_read(p: LLMProvider) -> ProviderRead: + plain = decrypt(p.api_key_cipher) if p.api_key_cipher else None + return ProviderRead( + id=p.id, + code=p.code, + name=p.name, + vendor=p.vendor, + api_base=p.api_base, + api_key_mask=mask(plain) if plain else "", + has_api_key=bool(plain), + description=p.description, + status=p.status, + ) + + +async def get_provider(db: AsyncSession, pid: int) -> LLMProvider: + p = await db.get(LLMProvider, pid) + if p is None: + raise AppError("Provider 不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + return p + + +async def create_provider(db: AsyncSession, data: ProviderCreate) -> LLMProvider: + exists = await db.execute(select(LLMProvider).where(LLMProvider.code == data.code)) + if exists.scalar_one_or_none(): + raise AppError("code 已存在", code=409, http_status=status.HTTP_409_CONFLICT) + p = LLMProvider( + code=data.code, + name=data.name, + vendor=data.vendor, + api_base=data.api_base, + api_key_cipher=encrypt(data.api_key) if data.api_key else None, + description=data.description, + status=data.status, + ) + db.add(p) + await db.commit() + await db.refresh(p) + return p + + +async def update_provider( + db: AsyncSession, pid: int, data: ProviderUpdate +) -> LLMProvider: + p = await get_provider(db, pid) + changes = data.model_dump(exclude_unset=True) + if "api_key" in changes: + val = changes.pop("api_key") + if val is not None: + p.api_key_cipher = encrypt(val) if val else None + for k, v in changes.items(): + setattr(p, k, v) + await db.commit() + await db.refresh(p) + return p + + +async def delete_provider(db: AsyncSession, pid: int) -> None: + p = await get_provider(db, pid) + # 若有模型使用,阻止删除 + cnt = (await db.execute( + select(func.count()).select_from(LLMModel).where(LLMModel.provider_id == pid) + )).scalar() or 0 + if cnt > 0: + raise AppError( + f"该 Provider 下还有 {cnt} 个模型,不允许删除", + code=409, http_status=status.HTTP_409_CONFLICT, + ) + await db.delete(p) + await db.commit() + + +# -------- Model -------- + +async def list_models(db: AsyncSession) -> list[ModelRead]: + res = await db.execute(select(LLMModel).order_by(LLMModel.id)) + out: list[ModelRead] = [] + for m in res.scalars().all(): + out.append(await model_to_read(db, m)) + return out + + +async def model_to_read(db: AsyncSession, m: LLMModel) -> ModelRead: + prov = await db.get(LLMProvider, m.provider_id) + return ModelRead( + id=m.id, + provider_id=m.provider_id, + provider_code=prov.code if prov else None, + provider_name=prov.name if prov else None, + model=m.model, + name=m.name, + description=m.description, + max_context=m.max_context, + price_input_per_1k=float(m.price_input_per_1k), + price_output_per_1k=float(m.price_output_per_1k), + status=m.status, + ) + + +async def create_model(db: AsyncSession, data: ModelCreate) -> LLMModel: + await get_provider(db, data.provider_id) + m = LLMModel(**data.model_dump()) + db.add(m) + await db.commit() + await db.refresh(m) + return m + + +async def update_model(db: AsyncSession, mid: int, data: ModelUpdate) -> LLMModel: + m = await db.get(LLMModel, mid) + if m is None: + raise AppError("Model 不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(m, k, v) + await db.commit() + await db.refresh(m) + return m + + +async def delete_model(db: AsyncSession, mid: int) -> None: + m = await db.get(LLMModel, mid) + if m is None: + raise AppError("Model 不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + cnt = (await db.execute( + select(func.count()).select_from(LLMRoutingRule).where(LLMRoutingRule.model_id == mid) + )).scalar() or 0 + if cnt > 0: + raise AppError( + f"该模型被 {cnt} 条路由规则引用", code=409, + http_status=status.HTTP_409_CONFLICT, + ) + await db.delete(m) + await db.commit() + + +# -------- Routing -------- + +async def list_rules(db: AsyncSession) -> list[RuleRead]: + res = await db.execute(select(LLMRoutingRule).order_by(LLMRoutingRule.priority, LLMRoutingRule.id)) + out: list[RuleRead] = [] + for r in res.scalars().all(): + model = r.model_obj + prov = model.provider if model else None + out.append( + RuleRead( + id=r.id, name=r.name, priority=r.priority, conditions=r.conditions, + model_id=r.model_id, + model_label=f"{prov.code}/{model.model}" if prov and model else None, + enabled=r.enabled, description=r.description, + ) + ) + return out + + +async def create_rule(db: AsyncSession, data: RuleCreate) -> LLMRoutingRule: + if (await db.get(LLMModel, data.model_id)) is None: + raise AppError("model 不存在", code=400, http_status=status.HTTP_400_BAD_REQUEST) + r = LLMRoutingRule(**data.model_dump()) + db.add(r) + await db.commit() + await db.refresh(r) + return r + + +async def update_rule(db: AsyncSession, rid: int, data: RuleUpdate) -> LLMRoutingRule: + r = await db.get(LLMRoutingRule, rid) + if r is None: + raise AppError("规则不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(r, k, v) + await db.commit() + await db.refresh(r) + return r + + +async def delete_rule(db: AsyncSession, rid: int) -> None: + r = await db.get(LLMRoutingRule, rid) + if r is None: + raise AppError("规则不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + await db.delete(r) + await db.commit() + + +# -------- Routing 匹配 -------- + +def _match(rule_conditions: dict | None, conditions: dict) -> bool: + if not rule_conditions: + return True # 默认规则永远命中 + for k, v in rule_conditions.items(): + if conditions.get(k) != v: + return False + return True + + +async def route( + db: AsyncSession, conditions: dict | None = None +) -> tuple[LLMProvider, LLMModel]: + """按优先级匹配路由;没命中时抛 400。""" + stmt = ( + select(LLMRoutingRule) + .where(LLMRoutingRule.enabled.is_(True)) + .order_by(LLMRoutingRule.priority, LLMRoutingRule.id) + ) + rules = list((await db.execute(stmt)).scalars().all()) + if not rules: + raise AppError("没有可用的 LLM 路由规则", code=400, http_status=status.HTTP_400_BAD_REQUEST) + + cond = conditions or {} + picked = next((r for r in rules if _match(r.conditions, cond)), None) + if picked is None: + raise AppError( + f"没有匹配条件 {cond} 的路由规则", code=400, + http_status=status.HTTP_400_BAD_REQUEST, + ) + model = picked.model_obj + prov = model.provider + if model.status != "ACTIVE" or prov.status != "ACTIVE": + raise AppError( + "路由命中的 Provider/Model 已禁用", code=400, + http_status=status.HTTP_400_BAD_REQUEST, + ) + return prov, model + + +async def resolve_by_label(db: AsyncSession, label: str) -> tuple[LLMProvider, LLMModel]: + """解析 `provider_code/model_name`。""" + if "/" not in label: + raise AppError("model 必须形如 provider_code/model_name", + code=400, http_status=status.HTTP_400_BAD_REQUEST) + code, name = label.split("/", 1) + prov = (await db.execute( + select(LLMProvider).where(LLMProvider.code == code) + )).scalar_one_or_none() + if prov is None: + raise AppError(f"provider {code} 不存在", + code=400, http_status=status.HTTP_400_BAD_REQUEST) + m = (await db.execute( + select(LLMModel).where(LLMModel.provider_id == prov.id, LLMModel.model == name) + )).scalar_one_or_none() + if m is None: + raise AppError(f"model {name} 不存在于 {code}", + code=400, http_status=status.HTTP_400_BAD_REQUEST) + return prov, m + + +# -------- Chat 调用 -------- + +def _compute_cost(model: LLMModel, prompt_t: int, completion_t: int) -> float: + return round( + float(model.price_input_per_1k) * prompt_t / 1000 + + float(model.price_output_per_1k) * completion_t / 1000, + 6, + ) + + +async def call_chat( + db: AsyncSession, + *, + operator: User, + messages: list[dict[str, Any]], + model_label: str | None = None, + conditions: dict | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + source: str = "playground", + task_id: int | None = None, + skill_id: int | None = None, +) -> ChatResponse: + if model_label: + prov, model = await resolve_by_label(db, model_label) + else: + prov, model = await route(db, conditions) + + start = time.perf_counter() + api_key = decrypt(prov.api_key_cipher) if prov.api_key_cipher else None + + try: + result: ChatResult = await chat_completion( + api_base=prov.api_base, + api_key=api_key, + model=model.model, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + ) + cost_ms = int((time.perf_counter() - start) * 1000) + cost_usd = _compute_cost(model, result.prompt_tokens, result.completion_tokens) + db.add(LLMUsage( + provider_id=prov.id, provider_code=prov.code, + model_id=model.id, model=model.model, + operator_id=operator.id, operator_name=operator.username, + source=source, task_id=task_id, skill_id=skill_id, + prompt_tokens=result.prompt_tokens, + completion_tokens=result.completion_tokens, + total_tokens=result.total_tokens, + cost_usd=cost_usd, result="ok", cost_ms=cost_ms, + )) + await db.commit() + return ChatResponse( + provider=prov.code, model=model.model, content=result.content, + prompt_tokens=result.prompt_tokens, + completion_tokens=result.completion_tokens, + total_tokens=result.total_tokens, + cost_usd=cost_usd, cost_ms=cost_ms, + ) + except Exception as e: # noqa: BLE001 + cost_ms = int((time.perf_counter() - start) * 1000) + db.add(LLMUsage( + provider_id=prov.id, provider_code=prov.code, + model_id=model.id, model=model.model, + operator_id=operator.id, operator_name=operator.username, + source=source, task_id=task_id, skill_id=skill_id, + result="failed", message=f"{e.__class__.__name__}: {e}", + cost_ms=cost_ms, + )) + await db.commit() + raise AppError( + f"LLM 调用失败:{e}", code=502, + http_status=status.HTTP_502_BAD_GATEWAY, + ) from e + + +# -------- Usage 查询 -------- + +async def paginate_usage( + db: AsyncSession, + page: int, + size: int, + provider_id: int | None = None, + source: str | None = None, +) -> tuple[list[LLMUsage], int]: + stmt = select(LLMUsage) + if provider_id: + stmt = stmt.where(LLMUsage.provider_id == provider_id) + if source: + stmt = stmt.where(LLMUsage.source == source) + total = (await db.execute( + select(func.count()).select_from(stmt.order_by(None).subquery()) + )).scalar() or 0 + stmt = stmt.order_by(LLMUsage.id.desc()).offset((page - 1) * size).limit(size) + rows = list((await db.execute(stmt)).scalars().all()) + return rows, total + + +async def usage_summary(db: AsyncSession) -> dict: + by_model = (await db.execute( + select( + LLMUsage.provider_code, + LLMUsage.model, + func.count(LLMUsage.id), + func.sum(LLMUsage.prompt_tokens), + func.sum(LLMUsage.completion_tokens), + func.sum(LLMUsage.cost_usd), + ) + .group_by(LLMUsage.provider_code, LLMUsage.model) + .order_by(func.sum(LLMUsage.total_tokens).desc()) + )).all() + total_cost = (await db.execute(select(func.sum(LLMUsage.cost_usd)))).scalar() or 0 + total_calls = (await db.execute(select(func.count(LLMUsage.id)))).scalar() or 0 + return { + "total_calls": int(total_calls), + "total_cost_usd": round(float(total_cost), 6), + "by_model": [ + { + "provider_code": pc, + "model": m, + "calls": int(c), + "prompt_tokens": int(pt or 0), + "completion_tokens": int(ct or 0), + "cost_usd": round(float(cu or 0), 6), + } + for pc, m, c, pt, ct, cu in by_model + ], + } diff --git a/backend/app/services/lobster_config_service.py b/backend/app/services/lobster_config_service.py new file mode 100644 index 0000000..5c86ae9 --- /dev/null +++ b/backend/app/services/lobster_config_service.py @@ -0,0 +1,152 @@ +"""龙虾运行时配置:读取(DB 优先 → .env 兜底)+ 更新。""" +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.crypto import decrypt, encrypt, mask +from app.models import LobsterConfig +from app.schemas.lobster_config import LobsterConfigRead, LobsterConfigUpdate + + +@dataclass +class EffectiveLobsterConfig: + """运行时真正生效的值(已合并 DB + .env)。""" + + api_base: str + api_key: str + callback_secret: str + timeout_seconds: int + poll_interval_seconds: int + + # 各字段的来源:db / env / unset,仅用于展示 + api_base_source: str = "env" + api_key_source: str = "env" + callback_secret_source: str = "env" + timeout_source: str = "env" + poll_interval_source: str = "env" + + @property + def is_mock(self) -> bool: + return not self.api_base or not self.api_key + + +async def _get_or_create_row(db: AsyncSession) -> LobsterConfig: + row = (await db.execute(select(LobsterConfig).where(LobsterConfig.id == 1))).scalar_one_or_none() + if row is None: + row = LobsterConfig(id=1) + db.add(row) + await db.flush() + return row + + +async def load_effective(db: AsyncSession) -> EffectiveLobsterConfig: + row = (await db.execute(select(LobsterConfig).where(LobsterConfig.id == 1))).scalar_one_or_none() + + # DB 字段(可能 None) + db_api_base = (row.api_base if row else None) or None + db_api_key = decrypt(row.api_key_cipher) if row and row.api_key_cipher else None + db_callback_secret = ( + decrypt(row.callback_secret_cipher) if row and row.callback_secret_cipher else None + ) + db_timeout = row.timeout_seconds if row else None + db_poll = row.poll_interval_seconds if row else None + + # env 兜底 + env_api_base = settings.LOBSTER_API_BASE or "" + env_api_key = settings.LOBSTER_API_KEY or "" + env_callback_secret = settings.LOBSTER_CALLBACK_SECRET or "" + env_timeout = settings.LOBSTER_TIMEOUT_SECONDS or 30 + env_poll = settings.LOBSTER_POLL_INTERVAL_SECONDS or 5 + + api_base = db_api_base or env_api_base + api_base_source = "db" if db_api_base else ("env" if env_api_base else "unset") + + api_key = db_api_key or env_api_key + api_key_source = "db" if db_api_key else ("env" if env_api_key else "unset") + + callback = db_callback_secret or env_callback_secret + callback_source = ( + "db" if db_callback_secret else ("env" if env_callback_secret else "unset") + ) + + timeout = db_timeout or env_timeout + timeout_source = "db" if db_timeout else "env" + + poll = db_poll or env_poll + poll_source = "db" if db_poll else "env" + + return EffectiveLobsterConfig( + api_base=api_base, + api_key=api_key, + callback_secret=callback, + timeout_seconds=timeout, + poll_interval_seconds=poll, + api_base_source=api_base_source, + api_key_source=api_key_source, + callback_secret_source=callback_source, + timeout_source=timeout_source, + poll_interval_source=poll_source, + ) + + +async def read(db: AsyncSession) -> LobsterConfigRead: + eff = await load_effective(db) + note = None + row = (await db.execute(select(LobsterConfig).where(LobsterConfig.id == 1))).scalar_one_or_none() + if row: + note = row.note + return LobsterConfigRead( + api_base=eff.api_base or "", + api_base_source=eff.api_base_source, + api_key_mask=mask(eff.api_key) if eff.api_key else "", + api_key_source=eff.api_key_source, + has_api_key=bool(eff.api_key), + callback_secret_mask=mask(eff.callback_secret) if eff.callback_secret else "", + callback_secret_source=eff.callback_secret_source, + has_callback_secret=bool(eff.callback_secret), + timeout_seconds=eff.timeout_seconds, + timeout_source=eff.timeout_source, + poll_interval_seconds=eff.poll_interval_seconds, + poll_interval_source=eff.poll_interval_source, + mode="real" if not eff.is_mock else "mock", + note=note, + ) + + +async def update(db: AsyncSession, data: LobsterConfigUpdate) -> LobsterConfigRead: + row = await _get_or_create_row(db) + changes = data.model_dump(exclude_unset=True) + + # api_key / callback_secret 空串表示清空 → fallback env + if "api_key" in changes: + v = changes.pop("api_key") + if v is None: + pass # 不改 + elif v == "": + row.api_key_cipher = None + else: + row.api_key_cipher = encrypt(v) + + if "callback_secret" in changes: + v = changes.pop("callback_secret") + if v is None: + pass + elif v == "": + row.callback_secret_cipher = None + else: + row.callback_secret_cipher = encrypt(v) + + # api_base 空串 = 清空回落 env + if "api_base" in changes: + v = changes.pop("api_base") + row.api_base = v if v else None + + for k, v in changes.items(): + setattr(row, k, v) + + await db.commit() + return await read(db) diff --git a/backend/app/services/menu_service.py b/backend/app/services/menu_service.py new file mode 100644 index 0000000..2b9a8b6 --- /dev/null +++ b/backend/app/services/menu_service.py @@ -0,0 +1,92 @@ +"""菜单 / 按钮权限 树 CRUD。""" +from __future__ import annotations + +from fastapi import status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import AppError +from app.models import Menu +from app.schemas.menu import MenuCreate, MenuTreeNode, MenuUpdate + + +async def list_all(db: AsyncSession) -> list[Menu]: + res = await db.execute(select(Menu).order_by(Menu.sort, Menu.id)) + return list(res.scalars().all()) + + +async def get(db: AsyncSession, mid: int) -> Menu: + m = await db.get(Menu, mid) + if m is None: + raise AppError("菜单不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + return m + + +async def create(db: AsyncSession, data: MenuCreate) -> Menu: + if data.perm_code: + exists = await db.execute(select(Menu).where(Menu.perm_code == data.perm_code)) + if exists.scalar_one_or_none(): + raise AppError("perm_code 已存在", code=409, http_status=status.HTTP_409_CONFLICT) + if data.parent_id and (await db.get(Menu, data.parent_id)) is None: + raise AppError("父菜单不存在", code=400, http_status=status.HTTP_400_BAD_REQUEST) + m = Menu(**data.model_dump()) + db.add(m) + await db.commit() + await db.refresh(m) + return m + + +async def update(db: AsyncSession, mid: int, data: MenuUpdate) -> Menu: + m = await get(db, mid) + changes = data.model_dump(exclude_unset=True) + if "perm_code" in changes and changes["perm_code"] and changes["perm_code"] != m.perm_code: + exists = await db.execute( + select(Menu).where(Menu.perm_code == changes["perm_code"], Menu.id != mid) + ) + if exists.scalar_one_or_none(): + raise AppError("perm_code 已存在", code=409, http_status=status.HTTP_409_CONFLICT) + if "parent_id" in changes and changes["parent_id"]: + if changes["parent_id"] == mid: + raise AppError("不能把自己设为父级", code=400, http_status=status.HTTP_400_BAD_REQUEST) + parent = await db.get(Menu, changes["parent_id"]) + if parent is None: + raise AppError("父菜单不存在", code=400, http_status=status.HTTP_400_BAD_REQUEST) + for k, v in changes.items(): + setattr(m, k, v) + await db.commit() + await db.refresh(m) + return m + + +async def delete(db: AsyncSession, mid: int) -> None: + m = await get(db, mid) + res = await db.execute(select(Menu.id).where(Menu.parent_id == mid)) + if res.first(): + raise AppError("存在子菜单,无法删除", code=409, http_status=status.HTTP_409_CONFLICT) + await db.delete(m) + await db.commit() + + +def build_tree(items: list[Menu]) -> list[MenuTreeNode]: + nodes: dict[int, MenuTreeNode] = {} + for m in items: + nodes[m.id] = MenuTreeNode( + id=m.id, parent_id=m.parent_id, name=m.name, type=m.type, path=m.path, + component=m.component, icon=m.icon, perm_code=m.perm_code, + sort=m.sort, status=m.status, + ) + roots: list[MenuTreeNode] = [] + for m in items: + n = nodes[m.id] + if m.parent_id and m.parent_id in nodes: + nodes[m.parent_id].children.append(n) + else: + roots.append(n) + + def _sort(lst: list[MenuTreeNode]) -> None: + lst.sort(key=lambda x: (x.sort, x.id)) + for it in lst: + _sort(it.children) + + _sort(roots) + return roots diff --git a/backend/app/services/org_service.py b/backend/app/services/org_service.py new file mode 100644 index 0000000..2aab956 --- /dev/null +++ b/backend/app/services/org_service.py @@ -0,0 +1,76 @@ +"""组织架构:CRUD + 树构建。""" +from __future__ import annotations + +from fastapi import status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import AppError +from app.models import Organization +from app.schemas.organization import OrgCreate, OrgTreeNode, OrgUpdate + + +async def list_all(db: AsyncSession) -> list[Organization]: + res = await db.execute(select(Organization).order_by(Organization.sort, Organization.id)) + return list(res.scalars().all()) + + +async def get(db: AsyncSession, oid: int) -> Organization: + org = await db.get(Organization, oid) + if org is None: + raise AppError("组织不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + return org + + +async def create(db: AsyncSession, data: OrgCreate) -> Organization: + exists = await db.execute(select(Organization).where(Organization.code == data.code)) + if exists.scalar_one_or_none(): + raise AppError("code 已存在", code=409, http_status=status.HTTP_409_CONFLICT) + org = Organization(**data.model_dump()) + db.add(org) + await db.commit() + await db.refresh(org) + return org + + +async def update(db: AsyncSession, oid: int, data: OrgUpdate) -> Organization: + org = await get(db, oid) + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(org, k, v) + await db.commit() + await db.refresh(org) + return org + + +async def delete(db: AsyncSession, oid: int) -> None: + org = await get(db, oid) + # 不允许删除有子节点的组织 + res = await db.execute(select(Organization.id).where(Organization.parent_id == oid)) + if res.first(): + raise AppError("存在子组织,无法删除", code=409, http_status=status.HTTP_409_CONFLICT) + await db.delete(org) + await db.commit() + + +def build_tree(items: list[Organization]) -> list[OrgTreeNode]: + nodes: dict[int, OrgTreeNode] = {} + for o in items: + nodes[o.id] = OrgTreeNode( + id=o.id, parent_id=o.parent_id, name=o.name, code=o.code, + type=o.type, sort=o.sort, description=o.description, + ) + roots: list[OrgTreeNode] = [] + for o in items: + n = nodes[o.id] + if o.parent_id and o.parent_id in nodes: + nodes[o.parent_id].children.append(n) + else: + roots.append(n) + + def _sort(lst: list[OrgTreeNode]) -> None: + lst.sort(key=lambda x: (x.sort, x.id)) + for it in lst: + _sort(it.children) + + _sort(roots) + return roots diff --git a/backend/app/services/platform_account_service.py b/backend/app/services/platform_account_service.py new file mode 100644 index 0000000..7547aab --- /dev/null +++ b/backend/app/services/platform_account_service.py @@ -0,0 +1,138 @@ +"""平台账号 CRUD + 连通测试。 + +连通测试当前是轻量桩(校验 login 非空 + 凭据可解密):真实"登录每个平台"的校验 +需要各平台(facebook / tiktok / google 等)各自的 SDK 或抓包,不在统一服务层做。 +由具体 Skill 在执行时通过 PlatformAccount 去登录平台即真正验证可用性。 +""" +from __future__ import annotations + +from fastapi import status +from sqlalchemy import func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.crypto import decrypt, encrypt, mask +from app.core.exceptions import AppError +from app.models import PlatformAccount, User +from app.schemas.platform_account import ( + PlatformAccountCreate, + PlatformAccountRead, + PlatformAccountUpdate, +) + + +async def paginate( + db: AsyncSession, + page: int, + size: int, + keyword: str | None = None, + platform_name: str | None = None, + owner_id: int | None = None, +) -> tuple[list[PlatformAccount], int]: + stmt = select(PlatformAccount) + if keyword: + like = f"%{keyword}%" + stmt = stmt.where(or_(PlatformAccount.login.ilike(like), + PlatformAccount.platform_name.ilike(like))) + if platform_name: + stmt = stmt.where(PlatformAccount.platform_name == platform_name) + if owner_id: + stmt = stmt.where(PlatformAccount.owner_id == owner_id) + + total_stmt = select(func.count()).select_from(stmt.order_by(None).subquery()) + total = (await db.execute(total_stmt)).scalar() or 0 + stmt = stmt.order_by(PlatformAccount.id.desc()).offset((page - 1) * size).limit(size) + rows = list((await db.execute(stmt)).scalars().all()) + return rows, total + + +async def to_read(db: AsyncSession, acc: PlatformAccount) -> PlatformAccountRead: + owner = await db.get(User, acc.owner_id) + pwd_plain = decrypt(acc.password_cipher) or "" + gkey_plain = decrypt(acc.google_key_cipher) if acc.google_key_cipher else None + return PlatformAccountRead( + id=acc.id, + owner_id=acc.owner_id, + owner_name=owner.username if owner else None, + platform_name=acc.platform_name, + product_code=acc.product_code, + login=acc.login, + password_mask=mask(pwd_plain), + google_key_mask=mask(gkey_plain) if gkey_plain else "", + has_google_key=bool(acc.google_key_cipher), + remark=acc.remark, + status=acc.status, + last_test_result=acc.last_test_result, + last_test_message=acc.last_test_message, + ) + + +async def create(db: AsyncSession, owner_id: int, data: PlatformAccountCreate) -> PlatformAccount: + acc = PlatformAccount( + owner_id=owner_id, + platform_name=data.platform_name, + product_code=data.product_code, + login=data.login, + password_cipher=encrypt(data.password) or "", + google_key_cipher=encrypt(data.google_key), + remark=data.remark, + status=data.status, + ) + db.add(acc) + await db.commit() + await db.refresh(acc) + return acc + + +async def get(db: AsyncSession, aid: int) -> PlatformAccount: + acc = await db.get(PlatformAccount, aid) + if acc is None: + raise AppError("账号不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + return acc + + +async def update(db: AsyncSession, aid: int, data: PlatformAccountUpdate) -> PlatformAccount: + acc = await get(db, aid) + changes = data.model_dump(exclude_unset=True) + if "password" in changes: + if changes["password"]: + acc.password_cipher = encrypt(changes["password"]) or "" + changes.pop("password") + if "google_key" in changes: + val = changes.pop("google_key") + # 空字符串 → 清空;None → 不改 + if val is not None: + acc.google_key_cipher = encrypt(val) if val else None + for k, v in changes.items(): + setattr(acc, k, v) + await db.commit() + await db.refresh(acc) + return acc + + +async def delete(db: AsyncSession, aid: int) -> None: + acc = await get(db, aid) + await db.delete(acc) + await db.commit() + + +async def test_connection(db: AsyncSession, aid: int) -> PlatformAccount: + """轻量桩:校验 login 非空 + 凭据能正常解密。 + + 真正验证账号对各个外部平台是否可登录,应由具体 Skill 在执行时通过本账号 + 去登录并观测结果;此处只做"数据层一致性"检查,避免任务执行时才发现凭据损坏。 + """ + from app.core.crypto import decrypt + + acc = await get(db, aid) + if not acc.login.strip(): + acc.last_test_result = "failed" + acc.last_test_message = "登录账号为空" + elif decrypt(acc.password_cipher) is None: + acc.last_test_result = "failed" + acc.last_test_message = "密码密文无法解密(FERNET_KEY 可能已变更)" + else: + acc.last_test_result = "ok" + acc.last_test_message = "凭据完整且可解密(真实平台登录由 Skill 执行时验证)" + await db.commit() + await db.refresh(acc) + return acc diff --git a/backend/app/services/platform_service.py b/backend/app/services/platform_service.py new file mode 100644 index 0000000..9b63b84 --- /dev/null +++ b/backend/app/services/platform_service.py @@ -0,0 +1,64 @@ +"""平台(Platform)CRUD。""" +from __future__ import annotations + +from fastapi import status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import AppError +from app.models import Platform, Product +from app.schemas.platform import PlatformCreate, PlatformRead, PlatformUpdate + + +async def list_all(db: AsyncSession) -> list[PlatformRead]: + res = await db.execute(select(Platform).order_by(Platform.id)) + platforms = list(res.scalars().all()) + count_res = await db.execute( + select(Product.platform_id, func.count(Product.id)) + .where(Product.platform_id.is_not(None)) + .group_by(Product.platform_id) + ) + counts = dict(count_res.all()) + return [ + PlatformRead( + id=p.id, code=p.code, name=p.name, url=p.url, description=p.description, + status=p.status, product_count=counts.get(p.id, 0), + ) + for p in platforms + ] + + +async def get(db: AsyncSession, pid: int) -> Platform: + p = await db.get(Platform, pid) + if p is None: + raise AppError("平台不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + return p + + +async def create(db: AsyncSession, data: PlatformCreate) -> Platform: + res = await db.execute(select(Platform).where(Platform.code == data.code)) + if res.scalar_one_or_none(): + raise AppError("code 已存在", code=409, http_status=status.HTTP_409_CONFLICT) + p = Platform(**data.model_dump()) + db.add(p) + await db.commit() + await db.refresh(p) + return p + + +async def update(db: AsyncSession, pid: int, data: PlatformUpdate) -> Platform: + p = await get(db, pid) + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(p, k, v) + await db.commit() + await db.refresh(p) + return p + + +async def delete(db: AsyncSession, pid: int) -> None: + p = await get(db, pid) + res = await db.execute(select(Product.id).where(Product.platform_id == pid)) + if res.first(): + raise AppError("存在关联产品,无法删除", code=409, http_status=status.HTTP_409_CONFLICT) + await db.delete(p) + await db.commit() diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py new file mode 100644 index 0000000..faeccc6 --- /dev/null +++ b/backend/app/services/product_service.py @@ -0,0 +1,125 @@ +"""产品 CRUD + 渠道码绑定。""" +from __future__ import annotations + +from fastapi import status +from sqlalchemy import delete as sa_delete, func, insert, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.exceptions import AppError +from app.models import ( + ChannelCode, + Organization, + Platform, + Product, + User, + product_channel_code, +) +from app.schemas.product import ProductCreate, ProductRead, ProductUpdate + + +async def paginate( + db: AsyncSession, + page: int, + size: int, + keyword: str | None = None, + platform_id: int | None = None, + dept_id: int | None = None, +) -> tuple[list[Product], int]: + stmt = select(Product).options(selectinload(Product.channel_codes)) + if keyword: + like = f"%{keyword}%" + stmt = stmt.where(or_(Product.code.ilike(like), Product.name.ilike(like))) + if platform_id: + stmt = stmt.where(Product.platform_id == platform_id) + if dept_id: + stmt = stmt.where(Product.dept_id == dept_id) + total = (await db.execute( + select(func.count()).select_from(stmt.order_by(None).subquery()) + )).scalar() or 0 + stmt = stmt.order_by(Product.id.desc()).offset((page - 1) * size).limit(size) + rows = list((await db.execute(stmt)).scalars().all()) + return rows, total + + +async def get(db: AsyncSession, pid: int) -> Product: + stmt = ( + select(Product) + .options(selectinload(Product.channel_codes)) + .where(Product.id == pid) + ) + p = (await db.execute(stmt)).scalar_one_or_none() + if p is None: + raise AppError("产品不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + return p + + +async def to_read(db: AsyncSession, p: Product) -> ProductRead: + plat = await db.get(Platform, p.platform_id) if p.platform_id else None + dept = await db.get(Organization, p.dept_id) if p.dept_id else None + owner = await db.get(User, p.owner_id) if p.owner_id else None + return ProductRead( + id=p.id, + code=p.code, + name=p.name, + platform_id=p.platform_id, + platform_name=plat.name if plat else None, + dept_id=p.dept_id, + dept_name=dept.name if dept else None, + owner_id=p.owner_id, + owner_name=owner.username if owner else None, + description=p.description, + status=p.status, + channel_code_ids=[c.id for c in p.channel_codes], + channel_code_names=[c.name for c in p.channel_codes], + ) + + +async def _bind_channels(db: AsyncSession, product_id: int, channel_ids: list[int]) -> None: + if channel_ids: + res = await db.execute(select(ChannelCode.id).where(ChannelCode.id.in_(channel_ids))) + found = {r[0] for r in res.all()} + missing = set(channel_ids) - found + if missing: + raise AppError(f"渠道码不存在: {sorted(missing)}", + code=400, http_status=status.HTTP_400_BAD_REQUEST) + await db.execute( + sa_delete(product_channel_code).where(product_channel_code.c.product_id == product_id) + ) + if channel_ids: + await db.execute( + insert(product_channel_code), + [{"product_id": product_id, "channel_code_id": cid} for cid in channel_ids], + ) + + +async def create(db: AsyncSession, data: ProductCreate) -> Product: + res = await db.execute(select(Product).where(Product.code == data.code)) + if res.scalar_one_or_none(): + raise AppError("code 已存在", code=409, http_status=status.HTTP_409_CONFLICT) + p = Product(**data.model_dump(exclude={"channel_code_ids"})) + db.add(p) + await db.flush() + await _bind_channels(db, p.id, data.channel_code_ids) + await db.commit() + return await get(db, p.id) + + +async def update(db: AsyncSession, pid: int, data: ProductUpdate) -> Product: + p = await get(db, pid) + changes = data.model_dump(exclude_unset=True) + channels = changes.pop("channel_code_ids", None) + for k, v in changes.items(): + setattr(p, k, v) + if channels is not None: + await _bind_channels(db, p.id, channels) + # 关联表经绕过 ORM 变更,让 session 抛弃缓存重读 + await db.refresh(p, ["channel_codes"]) + await db.commit() + return await get(db, pid) + + +async def delete(db: AsyncSession, pid: int) -> None: + p = await get(db, pid) + await db.delete(p) + await db.commit() diff --git a/backend/app/services/rbac_service.py b/backend/app/services/rbac_service.py new file mode 100644 index 0000000..81049ac --- /dev/null +++ b/backend/app/services/rbac_service.py @@ -0,0 +1,105 @@ +"""权限解析:聚合用户所有角色的菜单权限,构建菜单树。""" +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models import Menu, User +from app.schemas.auth import MenuTreeNode + + +async def load_user_with_perms(db: AsyncSession, user_id: int) -> User | None: + """载入 user 及其 roles 与 roles.menus(eager)。""" + stmt = ( + select(User) + .options(selectinload(User.roles).selectinload(User.roles.property.mapper.class_.menus)) + .options(selectinload(User.dept)) + .where(User.id == user_id) + ) + return (await db.execute(stmt)).scalar_one_or_none() + + +async def get_user_perm_codes(db: AsyncSession, user: User) -> list[str]: + """汇总用户所有角色的 perm_code。超管直接返回通配 *。""" + if user.is_superuser: + return ["*"] + codes: set[str] = set() + for role in user.roles: + for m in role.menus: + if m.perm_code: + codes.add(m.perm_code) + return sorted(codes) + + +async def get_user_menus(db: AsyncSession, user: User) -> list[Menu]: + """返回用户可见的菜单(已去重、排序)。超管返回全量。""" + if user.is_superuser: + stmt = select(Menu).where(Menu.status == "ACTIVE").order_by(Menu.sort, Menu.id) + return list((await db.execute(stmt)).scalars().all()) + + menu_ids: set[int] = set() + for role in user.roles: + for m in role.menus: + if m.status == "ACTIVE": + menu_ids.add(m.id) + + if not menu_ids: + return [] + + # 同时把父节点一路补上,确保菜单树完整 + stmt = select(Menu).where(Menu.status == "ACTIVE").order_by(Menu.sort, Menu.id) + all_menus = list((await db.execute(stmt)).scalars().all()) + menu_by_id = {m.id: m for m in all_menus} + result_ids: set[int] = set() + for mid in menu_ids: + cur: Menu | None = menu_by_id.get(mid) + while cur and cur.id not in result_ids: + result_ids.add(cur.id) + cur = menu_by_id.get(cur.parent_id) if cur.parent_id else None + + return [m for m in all_menus if m.id in result_ids] + + +def build_menu_tree(menus: list[Menu]) -> list[MenuTreeNode]: + """扁平列表 → 嵌套树。仅保留 type != 'button' 的项作为菜单节点。""" + nodes: dict[int, MenuTreeNode] = {} + for m in menus: + # 显式构造,不从 ORM 属性取 children(未预加载 → lazy load → MissingGreenlet) + nodes[m.id] = MenuTreeNode( + id=m.id, + parent_id=m.parent_id, + name=m.name, + type=m.type, + path=m.path, + component=m.component, + icon=m.icon, + perm_code=m.perm_code, + sort=m.sort, + ) + + roots: list[MenuTreeNode] = [] + for m in menus: + node = nodes[m.id] + if m.parent_id and m.parent_id in nodes: + nodes[m.parent_id].children.append(node) + else: + roots.append(node) + + def sort_tree(items: list[MenuTreeNode]) -> None: + items.sort(key=lambda x: (x.sort, x.id)) + for it in items: + sort_tree(it.children) + + sort_tree(roots) + # 仅返回 dir / menu;按钮级 (type='button') 不参与菜单树,仅出 perm_code + def strip_buttons(items: list[MenuTreeNode]) -> list[MenuTreeNode]: + res: list[MenuTreeNode] = [] + for it in items: + if it.type == "button": + continue + it.children = strip_buttons(it.children) + res.append(it) + return res + + return strip_buttons(roots) diff --git a/backend/app/services/role_service.py b/backend/app/services/role_service.py new file mode 100644 index 0000000..e75aaeb --- /dev/null +++ b/backend/app/services/role_service.py @@ -0,0 +1,164 @@ +"""角色 CRUD + 4 维权限绑定(菜单 + 产品范围 + Skill 范围 + 平台账号)。""" +from __future__ import annotations + +from fastapi import status +from sqlalchemy import Table, delete as sa_delete, insert, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.exceptions import AppError +from app.models import ( + Menu, + Organization, + PlatformAccount, + Product, + Role, + Skill, + role_account_scope, + role_menu, + role_product_scope, + role_skill_scope, +) +from app.schemas.role import RoleCreate, RoleDetail, RoleUpdate + + +async def list_all(db: AsyncSession) -> list[Role]: + res = await db.execute(select(Role).order_by(Role.id)) + return list(res.scalars().all()) + + +async def get_with_menus(db: AsyncSession, role_id: int) -> Role: + stmt = select(Role).options(selectinload(Role.menus)).where(Role.id == role_id) + role = (await db.execute(stmt)).scalar_one_or_none() + if role is None: + raise AppError("角色不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + return role + + +async def _load_scope_ids( + db: AsyncSession, table: Table, role_id: int, target_col: str +) -> list[int]: + res = await db.execute( + select(table.c[target_col]).where(table.c.role_id == role_id) + ) + return [row[0] for row in res.all()] + + +async def to_detail(db: AsyncSession, role: Role) -> RoleDetail: + dept_name = None + if role.dept_id: + o = await db.get(Organization, role.dept_id) + dept_name = o.name if o else None + + product_ids = await _load_scope_ids(db, role_product_scope, role.id, "product_id") + skill_ids = await _load_scope_ids(db, role_skill_scope, role.id, "skill_id") + account_ids = await _load_scope_ids(db, role_account_scope, role.id, "account_id") + + return RoleDetail( + id=role.id, name=role.name, code=role.code, dept_id=role.dept_id, + description=role.description, status=role.status, is_builtin=role.is_builtin, + menu_ids=[m.id for m in role.menus], + product_scope_ids=product_ids, + skill_scope_ids=skill_ids, + account_scope_ids=account_ids, + dept_name=dept_name, + ) + + +async def _bind_ids( + db: AsyncSession, + role_id: int, + target_model, + target_id_col: str, + target_table: Table, + ids: list[int], + entity_name: str, +) -> None: + """通用:绑定关联表。先校验 id 存在,再删后插。""" + if ids: + res = await db.execute( + select(target_model.id).where(target_model.id.in_(ids)) + ) + found = {row[0] for row in res.all()} + missing = set(ids) - found + if missing: + raise AppError( + f"{entity_name}不存在: {sorted(missing)}", + code=400, http_status=status.HTTP_400_BAD_REQUEST, + ) + await db.execute( + sa_delete(target_table).where(target_table.c.role_id == role_id) + ) + if ids: + await db.execute( + insert(target_table), + [{"role_id": role_id, target_id_col: i} for i in ids], + ) + + +async def _bind_menus(db: AsyncSession, role_id: int, menu_ids: list[int]) -> None: + await _bind_ids(db, role_id, Menu, "menu_id", role_menu, menu_ids, "菜单") + + +async def _bind_products(db: AsyncSession, role_id: int, ids: list[int]) -> None: + await _bind_ids(db, role_id, Product, "product_id", role_product_scope, ids, "产品") + + +async def _bind_skills(db: AsyncSession, role_id: int, ids: list[int]) -> None: + await _bind_ids(db, role_id, Skill, "skill_id", role_skill_scope, ids, "Skill") + + +async def _bind_accounts(db: AsyncSession, role_id: int, ids: list[int]) -> None: + await _bind_ids( + db, role_id, PlatformAccount, "account_id", role_account_scope, ids, "平台账号", + ) + + +async def create(db: AsyncSession, data: RoleCreate) -> Role: + exists = await db.execute( + select(Role).where(Role.code == data.code, Role.dept_id == data.dept_id) + ) + if exists.scalar_one_or_none(): + raise AppError("同部门下 code 已存在", code=409, http_status=status.HTTP_409_CONFLICT) + role = Role( + name=data.name, code=data.code, dept_id=data.dept_id, + description=data.description, status=data.status, + ) + db.add(role) + await db.flush() + await _bind_menus(db, role.id, data.menu_ids) + await _bind_products(db, role.id, data.product_scope_ids) + await _bind_skills(db, role.id, data.skill_scope_ids) + await _bind_accounts(db, role.id, data.account_scope_ids) + await db.commit() + return await get_with_menus(db, role.id) + + +async def update(db: AsyncSession, role_id: int, data: RoleUpdate) -> Role: + role = await get_with_menus(db, role_id) + if role.is_builtin and data.status == "DISABLED": + raise AppError("内置角色不可禁用", code=409, http_status=status.HTTP_409_CONFLICT) + for k in ("name", "dept_id", "description", "status"): + v = getattr(data, k) + if v is not None: + setattr(role, k, v) + if data.menu_ids is not None: + await _bind_menus(db, role.id, data.menu_ids) + if data.product_scope_ids is not None: + await _bind_products(db, role.id, data.product_scope_ids) + if data.skill_scope_ids is not None: + await _bind_skills(db, role.id, data.skill_scope_ids) + if data.account_scope_ids is not None: + await _bind_accounts(db, role.id, data.account_scope_ids) + await db.commit() + return await get_with_menus(db, role_id) + + +async def delete(db: AsyncSession, role_id: int) -> None: + role = await db.get(Role, role_id) + if role is None: + raise AppError("角色不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + if role.is_builtin: + raise AppError("内置角色不可删除", code=409, http_status=status.HTTP_409_CONFLICT) + await db.delete(role) + await db.commit() diff --git a/backend/app/services/schedule_service.py b/backend/app/services/schedule_service.py new file mode 100644 index 0000000..7694f87 --- /dev/null +++ b/backend/app/services/schedule_service.py @@ -0,0 +1,289 @@ +"""定时任务:cron 解析 + 触发循环(APScheduler 风格的简化版)。""" +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta, timezone +from typing import Any + +from fastapi import status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import AppError +from app.core.logging import logger +from app.db.session import AsyncSessionLocal +from app.models import Product, Schedule, ScheduleLog, Skill, User +from app.schemas.schedule import ScheduleCreate, ScheduleUpdate +from app.services import task_service + + +def _parse_cron(expr: str) -> tuple[set[int], set[int], set[int], set[int], set[int]]: + """支持 5 段 cron:minute hour day month weekday。每段仅支持 * / 数字 / 列表 / 区间 / 步长。""" + parts = expr.strip().split() + if len(parts) != 5: + raise AppError("cron 必须是 5 段:分 时 日 月 周", + code=400, http_status=status.HTTP_400_BAD_REQUEST) + ranges = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 6)] + + def parse_field(s: str, lo: int, hi: int) -> set[int]: + if s == "*": + return set(range(lo, hi + 1)) + out: set[int] = set() + for piece in s.split(","): + step = 1 + rng = piece + if "/" in piece: + rng, step_s = piece.split("/", 1) + step = int(step_s) + if rng == "*": + start, end = lo, hi + elif "-" in rng: + a, b = rng.split("-", 1) + start, end = int(a), int(b) + else: + start = end = int(rng) + out.update(range(start, end + 1, step)) + return {v for v in out if lo <= v <= hi} + + return tuple(parse_field(p, lo, hi) for p, (lo, hi) in zip(parts, ranges)) # type: ignore[return-value] + + +def _next_fire(expr: str, after: datetime) -> datetime: + """找到 after 之后下一次 cron 触发时间(最多向后推 366 天)。""" + minutes, hours, days, months, weekdays = _parse_cron(expr) + cur = (after.replace(second=0, microsecond=0) + timedelta(minutes=1)) + for _ in range(60 * 24 * 366): + if ( + cur.minute in minutes + and cur.hour in hours + and cur.day in days + and cur.month in months + and cur.weekday() % 7 in weekdays # Python Mon=0 / cron Mon=1,这里保持 0-6 + ): + return cur + cur += timedelta(minutes=1) + raise RuntimeError("cron 在未来 1 年内没有触发点") + + +def _validate_cron(expr: str) -> None: + try: + _parse_cron(expr) + except AppError: + raise + except Exception as e: # ValueError 等 + raise AppError( + f"cron 解析失败: {e}", code=400, http_status=status.HTTP_400_BAD_REQUEST + ) from e + + +async def _attach_rel(db: AsyncSession, s: Schedule) -> dict[str, Any]: + skill = await db.get(Skill, s.skill_id) + product = await db.get(Product, s.product_id) if s.product_id else None + return { + "id": s.id, + "name": s.name, + "skill_id": s.skill_id, + "skill_code": skill.code if skill else None, + "skill_name": skill.name if skill else None, + "product_id": s.product_id, + "product_name": product.name if product else None, + "input_data": s.input_data, + "cron": s.cron, + "enabled": s.enabled, + "last_run_at": s.last_run_at.isoformat() if s.last_run_at else None, + "next_run_at": s.next_run_at.isoformat() if s.next_run_at else None, + "last_task_id": s.last_task_id, + "owner_id": s.owner_id, + } + + +async def list_all(db: AsyncSession) -> list[dict]: + res = await db.execute(select(Schedule).order_by(Schedule.id.desc())) + items: list[dict] = [] + for s in res.scalars().all(): + items.append(await _attach_rel(db, s)) + return items + + +async def create(db: AsyncSession, data: ScheduleCreate, owner: User) -> dict: + _validate_cron(data.cron) + if (await db.get(Skill, data.skill_id)) is None: + raise AppError("Skill 不存在", code=400, http_status=status.HTTP_400_BAD_REQUEST) + s = Schedule( + name=data.name, + skill_id=data.skill_id, + product_id=data.product_id, + input_data=data.input_data, + cron=data.cron, + enabled=data.enabled, + owner_id=owner.id, + next_run_at=_next_fire(data.cron, datetime.now(timezone.utc)), + ) + db.add(s) + await db.commit() + await db.refresh(s) + return await _attach_rel(db, s) + + +async def update(db: AsyncSession, sid: int, data: ScheduleUpdate) -> dict: + s = await db.get(Schedule, sid) + if s is None: + raise AppError("定时任务不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + changes = data.model_dump(exclude_unset=True) + if "cron" in changes and changes["cron"]: + _validate_cron(changes["cron"]) + for k, v in changes.items(): + setattr(s, k, v) + if "cron" in changes or "enabled" in changes: + s.next_run_at = _next_fire(s.cron, datetime.now(timezone.utc)) if s.enabled else None + await db.commit() + await db.refresh(s) + return await _attach_rel(db, s) + + +async def delete(db: AsyncSession, sid: int) -> None: + s = await db.get(Schedule, sid) + if s is None: + raise AppError("定时任务不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + await db.delete(s) + await db.commit() + + +async def toggle(db: AsyncSession, sid: int, enabled: bool) -> dict: + s = await db.get(Schedule, sid) + if s is None: + raise AppError("定时任务不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + s.enabled = enabled + s.next_run_at = _next_fire(s.cron, datetime.now(timezone.utc)) if enabled else None + await db.commit() + await db.refresh(s) + return await _attach_rel(db, s) + + +async def trigger_now(db: AsyncSession, sid: int, operator: User) -> dict: + s = await db.get(Schedule, sid) + if s is None: + raise AppError("定时任务不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + t = await task_service.create_task( + db, + name=f"[手动触发]{s.name}", + skill_id=s.skill_id, + product_id=s.product_id, + input_data=s.input_data or {}, + timeout_seconds=300, + operator=operator, + source="schedule", + schedule_id=s.id, + ) + await task_service.run_task(db, t.id) + s.last_run_at = datetime.now(timezone.utc) + s.last_task_id = t.id + db.add(ScheduleLog(schedule_id=s.id, task_id=t.id, result="ok", message="手动触发")) + await db.commit() + return await _attach_rel(db, s) + + +async def list_logs(db: AsyncSession, sid: int, limit: int = 50) -> list[dict]: + stmt = ( + select(ScheduleLog) + .where(ScheduleLog.schedule_id == sid) + .order_by(ScheduleLog.id.desc()) + .limit(limit) + ) + rows = list((await db.execute(stmt)).scalars().all()) + return [ + { + "id": l.id, + "schedule_id": l.schedule_id, + "task_id": l.task_id, + "result": l.result, + "message": l.message, + "triggered_at": l.triggered_at.isoformat() if l.triggered_at else None, + "cost_ms": l.cost_ms, + } + for l in rows + ] + + +# ---- 后台调度循环(进程内实现) ---- + +async def _scheduler_loop() -> None: + """每分钟扫描一次 due 的 schedule 并触发任务。""" + logger.info("scheduler_loop_start") + while True: + try: + await _tick() + except Exception: # noqa: BLE001 + logger.exception("scheduler_tick_failed") + # 对齐到下一个分钟起始 + 小抖动 + now = datetime.now(timezone.utc) + sleep = 60 - now.second + 0.5 + await asyncio.sleep(sleep) + + +async def _tick() -> None: + async with AsyncSessionLocal() as db: + now = datetime.now(timezone.utc) + stmt = select(Schedule).where( + Schedule.enabled.is_(True), + Schedule.next_run_at.is_not(None), + Schedule.next_run_at <= now, + ) + due: list[Schedule] = list((await db.execute(stmt)).scalars().all()) + if not due: + return + + # 系统用户(用第一个超管) + sys_user = ( + await db.execute(select(User).where(User.is_superuser.is_(True)).limit(1)) + ).scalar_one_or_none() + + for s in due: + try: + if sys_user is None: + db.add(ScheduleLog( + schedule_id=s.id, result="failed", + message="系统内没有超级管理员,无法创建任务", + )) + continue + t = await task_service.create_task( + db, + name=f"[定时]{s.name}", + skill_id=s.skill_id, + product_id=s.product_id, + input_data=s.input_data or {}, + timeout_seconds=300, + operator=sys_user, + source="schedule", + schedule_id=s.id, + ) + await task_service.run_task(db, t.id) + s.last_run_at = now + s.last_task_id = t.id + s.next_run_at = _next_fire(s.cron, now) + db.add(ScheduleLog( + schedule_id=s.id, task_id=t.id, result="ok", + message="定时触发", + )) + except Exception as e: # noqa: BLE001 + db.add(ScheduleLog( + schedule_id=s.id, result="failed", + message=f"{e.__class__.__name__}: {e}", + )) + await db.commit() + + +async def get(db: AsyncSession, sid: int) -> dict: + s = await db.get(Schedule, sid) + if s is None: + raise AppError("定时任务不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + return await _attach_rel(db, s) + + +async def last_schedule_log_count(db: AsyncSession) -> int: + total = (await db.execute(select(func.count(ScheduleLog.id)))).scalar() + return int(total or 0) + + +def start_scheduler_task() -> asyncio.Task: + return asyncio.create_task(_scheduler_loop()) diff --git a/backend/app/services/script_template_service.py b/backend/app/services/script_template_service.py new file mode 100644 index 0000000..43a6742 --- /dev/null +++ b/backend/app/services/script_template_service.py @@ -0,0 +1,73 @@ +"""脚本模板 CRUD。每次 update 内容变化自增 version。""" +from __future__ import annotations + +from fastapi import status +from sqlalchemy import func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import AppError +from app.models import ScriptTemplate +from app.schemas.script_template import ScriptCreate, ScriptUpdate + + +async def paginate( + db: AsyncSession, page: int, size: int, keyword: str | None = None, language: str | None = None +) -> tuple[list[ScriptTemplate], int]: + stmt = select(ScriptTemplate) + if keyword: + like = f"%{keyword}%" + stmt = stmt.where(or_(ScriptTemplate.code.ilike(like), ScriptTemplate.name.ilike(like))) + if language: + stmt = stmt.where(ScriptTemplate.language == language) + total = (await db.execute( + select(func.count()).select_from(stmt.order_by(None).subquery()) + )).scalar() or 0 + stmt = stmt.order_by(ScriptTemplate.id.desc()).offset((page - 1) * size).limit(size) + rows = list((await db.execute(stmt)).scalars().all()) + return rows, total + + +async def list_all(db: AsyncSession) -> list[ScriptTemplate]: + res = await db.execute(select(ScriptTemplate).order_by(ScriptTemplate.id)) + return list(res.scalars().all()) + + +async def get(db: AsyncSession, sid: int) -> ScriptTemplate: + s = await db.get(ScriptTemplate, sid) + if s is None: + raise AppError("脚本模板不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + return s + + +async def create( + db: AsyncSession, data: ScriptCreate, owner_id: int | None = None +) -> ScriptTemplate: + res = await db.execute(select(ScriptTemplate).where(ScriptTemplate.code == data.code)) + if res.scalar_one_or_none(): + raise AppError("code 已存在", code=409, http_status=status.HTTP_409_CONFLICT) + s = ScriptTemplate(**data.model_dump(), owner_id=owner_id, version=1) + db.add(s) + await db.commit() + await db.refresh(s) + return s + + +async def update(db: AsyncSession, sid: int, data: ScriptUpdate) -> ScriptTemplate: + s = await get(db, sid) + changes = data.model_dump(exclude_unset=True) + content_changed = ( + "content" in changes and changes["content"] is not None and changes["content"] != s.content + ) + for k, v in changes.items(): + setattr(s, k, v) + if content_changed: + s.version = s.version + 1 + await db.commit() + await db.refresh(s) + return s + + +async def delete(db: AsyncSession, sid: int) -> None: + s = await get(db, sid) + await db.delete(s) + await db.commit() diff --git a/backend/app/services/skill_service.py b/backend/app/services/skill_service.py new file mode 100644 index 0000000..29f8ec1 --- /dev/null +++ b/backend/app/services/skill_service.py @@ -0,0 +1,347 @@ +"""Skill:双执行器 + 产品范围绑定 + 测试接口(走真实执行器短链路,与任务引擎同一套)。""" +from __future__ import annotations + +import time +from typing import Any + +from fastapi import status +from sqlalchemy import delete as sa_delete, func, insert, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.exceptions import AppError +from app.models import ( + Product, + ScriptTemplate, + Skill, + SkillCallLog, + User, + skill_product_scope, +) +from app.schemas.skill import SkillCreate, SkillRead, SkillUpdate + + +async def paginate( + db: AsyncSession, + page: int, + size: int, + keyword: str | None = None, + executor_type: str | None = None, + category: str | None = None, +) -> tuple[list[Skill], int]: + stmt = select(Skill).options(selectinload(Skill.product_scope)) + if keyword: + like = f"%{keyword}%" + stmt = stmt.where(or_(Skill.code.ilike(like), Skill.name.ilike(like))) + if executor_type: + stmt = stmt.where(Skill.executor_type == executor_type) + if category: + stmt = stmt.where(Skill.category == category) + total = (await db.execute( + select(func.count()).select_from(stmt.order_by(None).subquery()) + )).scalar() or 0 + stmt = stmt.order_by(Skill.id.desc()).offset((page - 1) * size).limit(size) + rows = list((await db.execute(stmt)).scalars().all()) + return rows, total + + +async def get(db: AsyncSession, sid: int) -> Skill: + stmt = select(Skill).options(selectinload(Skill.product_scope)).where(Skill.id == sid) + s = (await db.execute(stmt)).scalar_one_or_none() + if s is None: + raise AppError("Skill 不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + return s + + +def to_read(s: Skill) -> SkillRead: + return SkillRead( + id=s.id, + code=s.code, + name=s.name, + category=s.category, + executor_type=s.executor_type, + description=s.description, + input_schema=s.input_schema, + param_mapping=s.param_mapping, + lobster_template_id=s.lobster_template_id, + script_template_id=s.script_template_id, + status=s.status, + doc=s.doc, + product_scope_ids=[p.id for p in s.product_scope], + product_scope_names=[p.name for p in s.product_scope], + ) + + +def _validate_executor_config(s: SkillCreate | SkillUpdate, existing: Skill | None = None) -> None: + """RB-13:executor_type 决定填哪个模板字段,不可混填。""" + et = getattr(s, "executor_type", None) or (existing and existing.executor_type) or None + if not et: + return + if et == "LOBSTER": + # 脚本模板 id 不应同时存在 + if getattr(s, "script_template_id", None): + raise AppError( + "LOBSTER 执行器不可绑定脚本模板", code=400, http_status=status.HTTP_400_BAD_REQUEST + ) + elif et == "SCRIPT": + if getattr(s, "lobster_template_id", None): + raise AppError( + "SCRIPT 执行器不可绑定龙虾模板", code=400, http_status=status.HTTP_400_BAD_REQUEST + ) + + +async def _validate_script_template(db: AsyncSession, script_template_id: int | None) -> None: + if script_template_id is None: + return + if (await db.get(ScriptTemplate, script_template_id)) is None: + raise AppError("脚本模板不存在", code=400, http_status=status.HTTP_400_BAD_REQUEST) + + +async def _bind_products(db: AsyncSession, skill_id: int, product_ids: list[int]) -> None: + if product_ids: + res = await db.execute(select(Product.id).where(Product.id.in_(product_ids))) + found = {r[0] for r in res.all()} + missing = set(product_ids) - found + if missing: + raise AppError( + f"产品不存在: {sorted(missing)}", code=400, http_status=status.HTTP_400_BAD_REQUEST + ) + await db.execute( + sa_delete(skill_product_scope).where(skill_product_scope.c.skill_id == skill_id) + ) + if product_ids: + await db.execute( + insert(skill_product_scope), + [{"skill_id": skill_id, "product_id": pid} for pid in product_ids], + ) + + +async def create(db: AsyncSession, data: SkillCreate) -> Skill: + res = await db.execute(select(Skill).where(Skill.code == data.code)) + if res.scalar_one_or_none(): + raise AppError("code 已存在", code=409, http_status=status.HTTP_409_CONFLICT) + _validate_executor_config(data) + await _validate_script_template(db, data.script_template_id) + s = Skill(**data.model_dump(exclude={"product_scope_ids"})) + db.add(s) + await db.flush() + await _bind_products(db, s.id, data.product_scope_ids) + await db.commit() + return await get(db, s.id) + + +async def update(db: AsyncSession, sid: int, data: SkillUpdate) -> Skill: + s = await get(db, sid) + _validate_executor_config(data, existing=s) + await _validate_script_template(db, data.script_template_id) + changes = data.model_dump(exclude_unset=True) + products = changes.pop("product_scope_ids", None) + for k, v in changes.items(): + setattr(s, k, v) + if products is not None: + await _bind_products(db, s.id, products) + await db.refresh(s, ["product_scope"]) + await db.commit() + return await get(db, sid) + + +async def delete(db: AsyncSession, sid: int) -> None: + from app.models import Task + + s = await get(db, sid) + cnt = (await db.execute( + select(func.count()).select_from(Task).where(Task.skill_id == sid) + )).scalar() or 0 + if cnt > 0: + raise AppError( + f"该 Skill 已产生 {cnt} 个任务记录,不允许删除;可改为禁用", + code=409, http_status=status.HTTP_409_CONFLICT, + ) + await db.delete(s) + await db.commit() + + +async def test_skill( + db: AsyncSession, + sid: int, + input_data: dict[str, Any], + product_id: int | None, + operator: User, +) -> SkillCallLog: + """真实执行器短连接测试:走与任务引擎一致的路径。 + + - LOBSTER:submit 到龙虾 API(配了 key 则真调;未配走 client 内置 mock),短轮询最多 30s + 拿到 SUCCEEDED / FAILED 即返回。 + - SCRIPT:直接调 run_script 执行脚本模板。 + + 结果写 SkillCallLog(source=test)。不创建 Task,对业务数据无污染。 + """ + from app.integrations.lobster_client import build_lobster_client + from app.integrations.script_executor import run_script + from app.models import ScriptTemplate + from app.services.task_service import _resolve_mapping, _validate_input_against_schema + + skill = await get(db, sid) + start = time.perf_counter() + + # 先做 input_schema 必填校验 + try: + _validate_input_against_schema(skill.input_schema, input_data) + except AppError as e: + return await _log_skill_call( + db, skill, operator, product_id, + input_data=input_data, output_data=None, + result="failed", message=e.message, + cost_ms=int((time.perf_counter() - start) * 1000), + ) + + # 参数映射(与任务引擎同一套) + resolved = _resolve_mapping(skill.param_mapping, input_data) + + output_data: dict[str, Any] | None = None + result = "ok" + message: str | None = None + + try: + if skill.executor_type == "LOBSTER": + if not skill.lobster_template_id: + raise AppError("Skill 未配置龙虾模板 ID", code=400, http_status=400) + client = await build_lobster_client(db) + submit = await client.submit( + skill.lobster_template_id, resolved, task_no=f"test-{skill.code}-{int(time.time())}" + ) + # 最多轮询 30 秒 + import asyncio + + deadline = time.time() + 30 + poll = submit + while time.time() < deadline and poll.status in ("QUEUED", "RUNNING"): + await asyncio.sleep(2) + poll = await client.poll(submit.run_id) + output_data = { + "executor": "LOBSTER", + "mode": "mock" if client.is_mock else "real", + "run_id": submit.run_id, + "status": poll.status, + "output": poll.output, + "resolved_input": resolved, + } + if poll.status == "SUCCEEDED": + message = poll.message or "龙虾 Skill 执行成功" + else: + result = "failed" + message = poll.message or f"龙虾执行未成功:{poll.status}" + + elif skill.executor_type == "SCRIPT": + if not skill.script_template_id: + raise AppError("Skill 未配置脚本模板", code=400, http_status=400) + tpl = await db.get(ScriptTemplate, skill.script_template_id) + if tpl is None or tpl.status != "ACTIVE": + raise AppError("脚本模板不存在或已禁用", code=400, http_status=400) + run = await run_script( + language=tpl.language, + code=tpl.content, + params=resolved, + timeout_seconds=min(tpl.timeout_seconds, 30), + ) + output_data = { + "executor": "SCRIPT", + "language": tpl.language, + "template_code": tpl.code, + "status": run.status, + "returncode": run.returncode, + "stdout_tail": run.stdout[-500:] if run.stdout else "", + "stderr_tail": run.stderr[-500:] if run.stderr else "", + "resolved_input": resolved, + } + if run.status == "SUCCEEDED": + message = "脚本执行成功" + else: + result = "failed" + message = ( + f"脚本未成功({run.status}):{run.stderr[-200:] or run.stdout[-200:] or ''}" + ) + else: + raise AppError(f"未知执行器类型:{skill.executor_type}", code=400, http_status=400) + + except AppError as e: + result = "failed" + message = e.message + except Exception as e: # noqa: BLE001 + result = "failed" + message = f"{e.__class__.__name__}: {e}" + + cost_ms = int((time.perf_counter() - start) * 1000) + log = SkillCallLog( + skill_id=skill.id, + skill_code=skill.code, + operator_id=operator.id, + operator_name=operator.username, + executor_type=skill.executor_type, + product_id=product_id, + input_data=input_data, + output_data=output_data if result == "ok" else None, + result=result, + message=message, + cost_ms=cost_ms, + source="test", + ) + db.add(log) + await db.commit() + await db.refresh(log) + return log + + +async def _log_skill_call( + db: AsyncSession, + skill: Skill, + operator: User, + product_id: int | None, + *, + input_data: dict[str, Any], + output_data: dict[str, Any] | None, + result: str, + message: str | None, + cost_ms: int, +) -> SkillCallLog: + log = SkillCallLog( + skill_id=skill.id, + skill_code=skill.code, + operator_id=operator.id, + operator_name=operator.username, + executor_type=skill.executor_type, + product_id=product_id, + input_data=input_data, + output_data=output_data, + result=result, + message=message, + cost_ms=cost_ms, + source="test", + ) + db.add(log) + await db.commit() + await db.refresh(log) + return log + + +async def paginate_logs( + db: AsyncSession, + page: int, + size: int, + skill_id: int | None = None, + result: str | None = None, + source: str | None = None, +) -> tuple[list[SkillCallLog], int]: + stmt = select(SkillCallLog) + if skill_id: + stmt = stmt.where(SkillCallLog.skill_id == skill_id) + if result: + stmt = stmt.where(SkillCallLog.result == result) + if source: + stmt = stmt.where(SkillCallLog.source == source) + total = (await db.execute( + select(func.count()).select_from(stmt.order_by(None).subquery()) + )).scalar() or 0 + stmt = stmt.order_by(SkillCallLog.id.desc()).offset((page - 1) * size).limit(size) + rows = list((await db.execute(stmt)).scalars().all()) + return rows, total diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py new file mode 100644 index 0000000..87fe9b2 --- /dev/null +++ b/backend/app/services/task_service.py @@ -0,0 +1,397 @@ +"""任务核心逻辑:创建 / 运行 / 取消 / 重试 + 状态机 + 日志写入。""" +from __future__ import annotations + +import asyncio +import re +import time +from datetime import datetime, timezone +from typing import Any + +from fastapi import status +from sqlalchemy import func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.exceptions import AppError +from app.db.session import AsyncSessionLocal +from app.integrations.lobster_client import LobsterRunResult, build_lobster_client +from app.integrations.script_executor import ScriptChunk, run_script +from app.models import Product, ScriptTemplate, Skill, Task, TaskLog, User + +# 状态机:只允许单向流转 +TERMINAL = {"SUCCEEDED", "FAILED", "TIMEOUT", "CANCELED"} +VALID_TRANSITIONS: dict[str, set[str]] = { + "PENDING": {"QUEUED", "RUNNING", "CANCELED", "FAILED"}, + "QUEUED": {"RUNNING", "CANCELED", "FAILED"}, + "RUNNING": {"SUCCEEDED", "FAILED", "TIMEOUT", "CANCELED"}, +} + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +async def _next_task_no(db: AsyncSession) -> str: + today = _now().strftime("%Y%m%d") + prefix = f"T{today}-" + res = await db.execute( + select(func.count()).select_from(Task).where(Task.task_no.like(f"{prefix}%")) + ) + seq = int(res.scalar() or 0) + 1 + return f"{prefix}{seq:04d}" + + +def _resolve_mapping(mapping: dict[str, Any] | None, input_data: dict[str, Any]) -> dict[str, Any]: + """把 param_mapping 模板里的 {{input.xxx}} 替换成实际输入值。""" + if not mapping: + return dict(input_data) + pattern = re.compile(r"\{\{\s*input\.([\w.\[\]]+)\s*\}\}") + + def _render(val: Any) -> Any: + if isinstance(val, str): + match = pattern.fullmatch(val.strip()) + if match: + return input_data.get(match.group(1)) + return pattern.sub(lambda m: str(input_data.get(m.group(1), "")), val) + if isinstance(val, dict): + return {k: _render(v) for k, v in val.items()} + if isinstance(val, list): + return [_render(v) for v in val] + return val + + return {k: _render(v) for k, v in mapping.items()} + + +def _validate_input_against_schema(schema: dict | None, input_data: dict[str, Any]) -> None: + if not schema or not isinstance(schema, dict): + return + required = schema.get("required") or [] + for k in required: + if k not in input_data or input_data[k] in (None, ""): + raise AppError(f"必填字段缺失: {k}", + code=400, http_status=status.HTTP_400_BAD_REQUEST) + + +async def _load_skill_with_scope(db: AsyncSession, skill_id: int) -> Skill: + stmt = select(Skill).options(selectinload(Skill.product_scope)).where(Skill.id == skill_id) + s = (await db.execute(stmt)).scalar_one_or_none() + if s is None: + raise AppError("Skill 不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + if s.status != "ACTIVE": + raise AppError("Skill 已禁用", code=400, http_status=status.HTTP_400_BAD_REQUEST) + return s + + +async def create_task( + db: AsyncSession, + *, + name: str, + skill_id: int, + product_id: int | None, + input_data: dict[str, Any], + timeout_seconds: int, + operator: User, + source: str = "wizard", + schedule_id: int | None = None, + retry_of_id: int | None = None, +) -> Task: + skill = await _load_skill_with_scope(db, skill_id) + _validate_input_against_schema(skill.input_schema, input_data) + + if skill.product_scope and product_id is None: + raise AppError( + "该 Skill 限定了产品范围,必须选择产品", code=400, + http_status=status.HTTP_400_BAD_REQUEST, + ) + if product_id: + if (await db.get(Product, product_id)) is None: + raise AppError("产品不存在", code=400, http_status=status.HTTP_400_BAD_REQUEST) + if skill.product_scope and all(p.id != product_id for p in skill.product_scope): + raise AppError( + "产品不在该 Skill 的允许范围", code=403, + http_status=status.HTTP_403_FORBIDDEN, + ) + + resolved = _resolve_mapping(skill.param_mapping, input_data) + task_no = await _next_task_no(db) + task = Task( + task_no=task_no, + name=name, + skill_id=skill.id, + skill_code=skill.code, + product_id=product_id, + executor_type=skill.executor_type, + input_data=input_data, + resolved_input=resolved, + status="PENDING", + timeout_seconds=timeout_seconds, + operator_id=operator.id, + operator_name=operator.username, + source=source, + schedule_id=schedule_id, + retry_of_id=retry_of_id, + ) + db.add(task) + await db.commit() + await db.refresh(task) + await _append_log(db, task.id, "INFO", f"任务创建:skill={skill.code} product={product_id}") + return task + + +async def _append_log( + db: AsyncSession, + task_id: int, + level: str, + message: str, + external_event_id: str | None = None, +) -> None: + # UniqueConstraint(task_id, external_event_id) 去重 + if external_event_id: + exists = await db.execute( + select(TaskLog.id).where( + TaskLog.task_id == task_id, + TaskLog.external_event_id == external_event_id, + ) + ) + if exists.scalar_one_or_none(): + return + db.add(TaskLog(task_id=task_id, level=level, message=message, + external_event_id=external_event_id)) + await db.commit() + + +async def _transition(db: AsyncSession, task: Task, new_status: str) -> None: + if task.status == new_status: + return + allowed = VALID_TRANSITIONS.get(task.status, set()) + if new_status not in allowed: + # 从终态再 transition 无效;非法状态跳过 + return + task.status = new_status + if new_status == "RUNNING" and task.started_at is None: + task.started_at = _now() + if new_status in TERMINAL: + task.finished_at = _now() + if task.started_at: + task.cost_ms = int((task.finished_at - task.started_at).total_seconds() * 1000) + await db.commit() + + +async def run_task(db: AsyncSession, task_id: int) -> Task: + task = await db.get(Task, task_id) + if task is None: + raise AppError("任务不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + if task.status != "PENDING": + raise AppError( + f"当前状态不可运行:{task.status}", code=400, + http_status=status.HTTP_400_BAD_REQUEST, + ) + await _transition(db, task, "QUEUED") + # 异步触发,不阻塞 HTTP 响应 + asyncio.create_task(_drive_task(task.id)) + return task + + +async def cancel_task(db: AsyncSession, task_id: int) -> Task: + task = await db.get(Task, task_id) + if task is None: + raise AppError("任务不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + if task.status in TERMINAL: + raise AppError( + f"任务已终态 ({task.status}),无法取消", code=400, + http_status=status.HTTP_400_BAD_REQUEST, + ) + await _append_log(db, task.id, "STATUS", "任务被取消") + await _transition(db, task, "CANCELED") + return task + + +async def retry_task(db: AsyncSession, task_id: int, operator: User) -> Task: + old = await db.get(Task, task_id) + if old is None: + raise AppError("任务不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + if old.status not in TERMINAL: + raise AppError("任务未终态,无需重试", code=400, http_status=status.HTTP_400_BAD_REQUEST) + new = await create_task( + db, + name=f"{old.name}(重试)", + skill_id=old.skill_id, + product_id=old.product_id, + input_data=old.input_data or {}, + timeout_seconds=old.timeout_seconds, + operator=operator, + source="retry", + retry_of_id=old.id, + ) + await run_task(db, new.id) + return new + + +# ---- 执行驱动(后台任务)---- + +async def _drive_task(task_id: int) -> None: + """在新 session 里驱动任务执行。""" + async with AsyncSessionLocal() as db: + task = await db.get(Task, task_id) + if task is None or task.status in TERMINAL: + return + try: + if task.executor_type == "LOBSTER": + await _run_lobster(db, task) + elif task.executor_type == "SCRIPT": + await _run_script(db, task) + else: + await _append_log(db, task.id, "ERROR", + f"未知执行器类型:{task.executor_type}") + task.error_message = "未知执行器类型" + await _transition(db, task, "FAILED") + except Exception as e: # noqa: BLE001 + task.error_message = f"{e.__class__.__name__}: {e}" + await _append_log(db, task.id, "ERROR", task.error_message) + await _transition(db, task, "FAILED") + + +async def _run_lobster(db: AsyncSession, task: Task) -> None: + skill = await db.get(Skill, task.skill_id) + if skill is None or not skill.lobster_template_id: + task.error_message = "Skill 未配置龙虾模板 ID" + await _append_log(db, task.id, "ERROR", task.error_message) + await _transition(db, task, "FAILED") + return + + client = await build_lobster_client(db) + if client.is_mock: + await _append_log(db, task.id, "INFO", + "龙虾配置未完整(UI/.env 均未设置 api_base+api_key),走 mock 执行") + else: + await _append_log(db, task.id, "INFO", + f"提交到龙虾 API {client.base}") + + submit: LobsterRunResult = await client.submit( + skill.lobster_template_id, task.resolved_input or {}, task.task_no + ) + task.external_ref = submit.run_id + await _append_log(db, task.id, "INFO", f"龙虾 run_id={submit.run_id}", submit.run_id) + await _transition(db, task, "RUNNING") + + deadline = time.time() + task.timeout_seconds + interval = max(1, int((0)) or 2) # 固定 2s 轮询节奏 + while True: + if time.time() > deadline: + task.error_message = f"轮询超时({task.timeout_seconds}s)" + await _append_log(db, task.id, "ERROR", task.error_message) + await _transition(db, task, "TIMEOUT") + return + await asyncio.sleep(2) + # 再拿一遍,确保未被外部取消 + await db.refresh(task) + if task.status == "CANCELED": + return + poll = await client.poll(submit.run_id) + for l in poll.logs or []: + await _append_log( + db, task.id, + l.get("level", "INFO"), + l.get("message", ""), + l.get("event_id"), + ) + if poll.status == "SUCCEEDED": + task.result_data = poll.output + await _append_log(db, task.id, "INFO", poll.message or "执行成功") + await _transition(db, task, "SUCCEEDED") + return + if poll.status in ("FAILED", "TIMEOUT"): + task.error_message = poll.message or "执行失败" + await _append_log(db, task.id, "ERROR", task.error_message) + await _transition(db, task, "FAILED" if poll.status == "FAILED" else "TIMEOUT") + return + # RUNNING / QUEUED → 继续轮询 + + +async def _run_script(db: AsyncSession, task: Task) -> None: + skill = await db.get(Skill, task.skill_id) + if skill is None or not skill.script_template_id: + task.error_message = "Skill 未配置脚本模板" + await _append_log(db, task.id, "ERROR", task.error_message) + await _transition(db, task, "FAILED") + return + tpl = await db.get(ScriptTemplate, skill.script_template_id) + if tpl is None or tpl.status != "ACTIVE": + task.error_message = "脚本模板不存在或已禁用" + await _append_log(db, task.id, "ERROR", task.error_message) + await _transition(db, task, "FAILED") + return + + await _transition(db, task, "RUNNING") + await _append_log( + db, task.id, "INFO", + f"subprocess 执行脚本 {tpl.code} (v{tpl.version}, {tpl.language})", + ) + + async def _on_chunk(chunk: ScriptChunk) -> None: + await _append_log(db, task.id, chunk.level, chunk.message) + + result = await run_script( + language=tpl.language, + code=tpl.content, + params=task.resolved_input or {}, + timeout_seconds=task.timeout_seconds, + on_chunk=_on_chunk, + ) + task.result_data = result.output + if result.status == "SUCCEEDED": + await _append_log(db, task.id, "INFO", "脚本执行成功") + await _transition(db, task, "SUCCEEDED") + else: + task.error_message = ( + result.stderr[:500] if result.stderr else f"returncode={result.returncode}" + ) + await _append_log(db, task.id, "ERROR", task.error_message) + await _transition( + db, task, "TIMEOUT" if result.status == "TIMEOUT" else "FAILED" + ) + + +# ---- 查询 ---- + +async def paginate( + db: AsyncSession, + page: int, + size: int, + keyword: str | None = None, + status_: str | None = None, + skill_id: int | None = None, + product_id: int | None = None, +) -> tuple[list[Task], int]: + stmt = select(Task) + if keyword: + like = f"%{keyword}%" + stmt = stmt.where(or_(Task.task_no.ilike(like), Task.name.ilike(like))) + if status_: + stmt = stmt.where(Task.status == status_) + if skill_id: + stmt = stmt.where(Task.skill_id == skill_id) + if product_id: + stmt = stmt.where(Task.product_id == product_id) + total = (await db.execute( + select(func.count()).select_from(stmt.order_by(None).subquery()) + )).scalar() or 0 + stmt = stmt.order_by(Task.id.desc()).offset((page - 1) * size).limit(size) + rows = list((await db.execute(stmt)).scalars().all()) + return rows, total + + +async def get(db: AsyncSession, task_id: int) -> Task: + t = await db.get(Task, task_id) + if t is None: + raise AppError("任务不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + return t + + +async def get_logs(db: AsyncSession, task_id: int, after_id: int = 0) -> list[TaskLog]: + stmt = ( + select(TaskLog) + .where(TaskLog.task_id == task_id, TaskLog.id > after_id) + .order_by(TaskLog.id.asc()) + ) + return list((await db.execute(stmt)).scalars().all()) diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..59d030d --- /dev/null +++ b/backend/app/services/user_service.py @@ -0,0 +1,123 @@ +"""用户 CRUD + 角色绑定 + 重置密码。""" +from __future__ import annotations + +from fastapi import status +from sqlalchemy import delete as sa_delete, func, insert, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.exceptions import AppError +from app.core.security import hash_password +from app.models import Organization, Role, User, user_role +from app.schemas.user import UserCreate, UserRead, UserUpdate + + +async def paginate( + db: AsyncSession, page: int, size: int, keyword: str | None = None, dept_id: int | None = None +) -> tuple[list[User], int]: + stmt = select(User).options(selectinload(User.roles), selectinload(User.dept)) + if keyword: + like = f"%{keyword}%" + stmt = stmt.where(or_(User.username.ilike(like), User.nickname.ilike(like))) + if dept_id: + stmt = stmt.where(User.dept_id == dept_id) + + total_stmt = select(func.count()).select_from(stmt.order_by(None).subquery()) + total = (await db.execute(total_stmt)).scalar() or 0 + stmt = stmt.order_by(User.id.desc()).offset((page - 1) * size).limit(size) + rows = list((await db.execute(stmt)).scalars().all()) + return rows, total + + +async def get_with_rels(db: AsyncSession, uid: int) -> User: + stmt = ( + select(User) + .options(selectinload(User.roles), selectinload(User.dept)) + .where(User.id == uid) + ) + user = (await db.execute(stmt)).scalar_one_or_none() + if user is None: + raise AppError("用户不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + return user + + +def to_read(u: User) -> UserRead: + return UserRead( + id=u.id, + username=u.username, + nickname=u.nickname, + email=u.email, + dept_id=u.dept_id, + dept_name=u.dept.name if u.dept else None, + status=u.status, + is_superuser=u.is_superuser, + role_ids=[r.id for r in u.roles], + role_names=[r.name for r in u.roles], + ) + + +async def _bind_roles(db: AsyncSession, user_id: int, role_ids: list[int]) -> None: + if role_ids: + res = await db.execute(select(Role.id).where(Role.id.in_(role_ids))) + found = {r[0] for r in res.all()} + missing = set(role_ids) - found + if missing: + raise AppError(f"角色不存在: {sorted(missing)}", + code=400, http_status=status.HTTP_400_BAD_REQUEST) + await db.execute(sa_delete(user_role).where(user_role.c.user_id == user_id)) + if role_ids: + await db.execute( + insert(user_role), + [{"user_id": user_id, "role_id": rid} for rid in role_ids], + ) + + +async def create(db: AsyncSession, data: UserCreate) -> User: + res = await db.execute(select(User).where(User.username == data.username)) + if res.scalar_one_or_none(): + raise AppError("用户名已存在", code=409, http_status=status.HTTP_409_CONFLICT) + if data.dept_id and (await db.get(Organization, data.dept_id)) is None: + raise AppError("部门不存在", code=400, http_status=status.HTTP_400_BAD_REQUEST) + user = User( + username=data.username, + password_hash=hash_password(data.password), + nickname=data.nickname, + email=data.email, + dept_id=data.dept_id, + status=data.status, + ) + db.add(user) + await db.flush() + await _bind_roles(db, user.id, data.role_ids) + await db.commit() + return await get_with_rels(db, user.id) + + +async def update(db: AsyncSession, uid: int, data: UserUpdate) -> User: + user = await get_with_rels(db, uid) + for k in ("nickname", "email", "dept_id", "status"): + v = getattr(data, k) + if v is not None: + setattr(user, k, v) + if data.role_ids is not None: + await _bind_roles(db, user.id, data.role_ids) + await db.commit() + return await get_with_rels(db, uid) + + +async def reset_password(db: AsyncSession, uid: int, new_pwd: str) -> None: + user = await db.get(User, uid) + if user is None: + raise AppError("用户不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + user.password_hash = hash_password(new_pwd) + await db.commit() + + +async def delete(db: AsyncSession, uid: int) -> None: + user = await db.get(User, uid) + if user is None: + raise AppError("用户不存在", code=404, http_status=status.HTTP_404_NOT_FOUND) + if user.is_superuser: + raise AppError("超级管理员不可删除", code=409, http_status=status.HTTP_409_CONFLICT) + await db.delete(user) + await db.commit() diff --git a/backend/app/tasks/__init__.py b/backend/app/tasks/__init__.py new file mode 100644 index 0000000..1885fd4 --- /dev/null +++ b/backend/app/tasks/__init__.py @@ -0,0 +1,8 @@ +"""后台任务。 + +当前实现:脚本执行走 `app.integrations.script_executor.run_script` +(asyncio.create_subprocess_exec,在后端进程内直接跑);定时任务由 +`app.services.schedule_service._scheduler_loop` 在 lifespan 内的后台 +协程里触发。为降低运维复杂度暂未引入 Celery broker。 +需要横向扩展 worker 时可把 drive 函数改成 celery.task 然后启 worker。 +""" diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/docker/Dockerfile b/backend/docker/Dockerfile new file mode 100644 index 0000000..b07d8e7 --- /dev/null +++ b/backend/docker/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml ./ +RUN pip install --upgrade pip && pip install -e . + +COPY . . + +EXPOSE 8000 + +CMD ["sh", "docker/entrypoint.sh"] diff --git a/backend/docker/entrypoint.sh b/backend/docker/entrypoint.sh new file mode 100755 index 0000000..fa128e7 --- /dev/null +++ b/backend/docker/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -e + +echo "[entrypoint] alembic upgrade head..." +alembic upgrade head + +# 首次启动时尝试跑 seed(重复运行幂等) +if [ "${SEED_ON_BOOT:-true}" = "true" ]; then + echo "[entrypoint] seeding default data..." + python -m app.scripts.seed || echo "[entrypoint] seed P2 skipped" + python -m app.scripts.seed_p4 || echo "[entrypoint] seed P4 skipped" + python -m app.scripts.seed_p6 || echo "[entrypoint] seed P6 skipped" +fi + +echo "[entrypoint] starting uvicorn..." +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips="*" diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..44ad14a --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,53 @@ +[project] +name = "loom-backend" +version = "0.1.0" +description = "Loom Agent 任务自动化平台后端" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "pydantic>=2.9.0", + "pydantic-settings>=2.5.0", + "sqlalchemy[asyncio]>=2.0.35", + "asyncpg>=0.30.0", + "psycopg[binary]>=3.2.0", + "alembic>=1.13.0", + "python-jose[cryptography]>=3.3.0", + "bcrypt>=4.2.0", + "cryptography>=43.0.0", + "celery[redis]>=5.4.0", + "redis>=5.0.0", + "httpx>=0.27.0", + "structlog>=24.4.0", + "python-multipart>=0.0.12", + "email-validator>=2.2.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", + "httpx>=0.27.0", + "ruff>=0.6.0", + "mypy>=1.11.0", +] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["app*"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "B", "UP"] +ignore = ["B008"] diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..74f480d --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,8 @@ +"""pytest 启动钩子:在任何 app 模块 import 前设置 LOOM_TESTING=1, +使 `app.db.session` 使用 NullPool,避免 TestClient 的 anyio portal loop +与 asyncpg 连接池复用导致的 `Future attached to a different loop`。""" +from __future__ import annotations + +import os + +os.environ.setdefault("LOOM_TESTING", "1") diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..fbdf34e --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,93 @@ +"""P2 验证:login / me / 权限差异。依赖种子数据(admin / operator)。 + +前置: + python -m app.scripts.seed +""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@pytest.fixture(scope="module") +def client() -> TestClient: + with TestClient(app) as c: + yield c + + +def _login(client: TestClient, username: str, password: str) -> str: + r = client.post( + "/api/v1/auth/login", + json={"username": username, "password": password}, + ) + assert r.status_code == 200, r.text + return r.json()["data"]["access_token"] + + +def test_login_success_returns_token_pair(client: TestClient) -> None: + r = client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "admin123"}, + ) + assert r.status_code == 200 + d = r.json()["data"] + assert d["access_token"] and d["refresh_token"] + assert d["token_type"] == "Bearer" + + +def test_login_bad_password_returns_401(client: TestClient) -> None: + r = client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "wrong"}, + ) + assert r.status_code == 401 + assert r.json()["code"] == 401 + + +def test_me_without_token_returns_401(client: TestClient) -> None: + r = client.get("/api/v1/auth/me") + assert r.status_code == 401 + + +def test_me_admin_sees_all_menus_and_wildcard_perm(client: TestClient) -> None: + token = _login(client, "admin", "admin123") + r = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 200 + d = r.json()["data"] + assert d["user"]["is_superuser"] is True + assert d["user"]["perm_codes"] == ["*"] + top_names = [n["name"] for n in d["menu_tree"]] + assert "仪表盘" in top_names + assert "权限管理" in top_names + assert "系统设置" in top_names + + +def test_me_operator_only_sees_allowed_menus(client: TestClient) -> None: + token = _login(client, "operator", "operator123") + r = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 200 + d = r.json()["data"] + assert d["user"]["is_superuser"] is False + assert set(d["user"]["perm_codes"]) == { + "dashboard:view", "skill:view", "task:create", "task:list", + } + top_names = {n["name"] for n in d["menu_tree"]} + # 业务员可见:仪表盘 + Skill 中心 + 任务中心 + assert top_names == {"仪表盘", "Skill 中心", "任务中心"} + # 禁区:权限管理、系统设置 + assert "权限管理" not in top_names + assert "系统设置" not in top_names + + +def test_refresh_returns_new_pair(client: TestClient) -> None: + r = client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "admin123"}, + ) + refresh = r.json()["data"]["refresh_token"] + r2 = client.post("/api/v1/auth/refresh", json={"refresh_token": refresh}) + assert r2.status_code == 200 + d = r2.json()["data"] + assert d["access_token"] and d["refresh_token"] diff --git a/backend/tests/test_crud.py b/backend/tests/test_crud.py new file mode 100644 index 0000000..5d62694 --- /dev/null +++ b/backend/tests/test_crud.py @@ -0,0 +1,222 @@ +"""P3 CRUD + 权限 + OpLog 集成测试。依赖种子数据。""" +from __future__ import annotations + +import time + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@pytest.fixture(scope="module") +def client() -> TestClient: + with TestClient(app) as c: + yield c + + +def _login(client: TestClient, username: str, password: str) -> str: + r = client.post("/api/v1/auth/login", json={"username": username, "password": password}) + assert r.status_code == 200 + return r.json()["data"]["access_token"] + + +@pytest.fixture(scope="module") +def admin_token(client: TestClient) -> str: + return _login(client, "admin", "admin123") + + +@pytest.fixture(scope="module") +def operator_token(client: TestClient) -> str: + return _login(client, "operator", "operator123") + + +def _h(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +# ========== Organizations ========== + +def test_org_tree_admin_ok(client: TestClient, admin_token: str) -> None: + r = client.get("/api/v1/organizations/tree", headers=_h(admin_token)) + assert r.status_code == 200 + tree = r.json()["data"] + # 种子有 1 个公司(LOOM)含 1 子部门(HQ) + assert any(n["code"] == "LOOM" for n in tree) + + +def test_org_crud_admin(client: TestClient, admin_token: str) -> None: + code = f"QA_{int(time.time())}" + r = client.post( + "/api/v1/organizations", + headers=_h(admin_token), + json={"name": "测试部门", "code": code, "type": "department"}, + ) + assert r.status_code == 200 + oid = r.json()["data"]["id"] + + r = client.patch( + f"/api/v1/organizations/{oid}", + headers=_h(admin_token), + json={"name": "测试部门改"}, + ) + assert r.status_code == 200 + assert r.json()["data"]["name"] == "测试部门改" + + r = client.delete(f"/api/v1/organizations/{oid}", headers=_h(admin_token)) + assert r.status_code == 200 + + +def test_org_operator_is_403(client: TestClient, operator_token: str) -> None: + r = client.get("/api/v1/organizations/tree", headers=_h(operator_token)) + assert r.status_code == 403 + + +# ========== Menus ========== + +def test_menu_tree_admin_ok(client: TestClient, admin_token: str) -> None: + r = client.get("/api/v1/menus/tree", headers=_h(admin_token)) + assert r.status_code == 200 + tree = r.json()["data"] + assert len(tree) >= 7 + + +# ========== Users ========== + +def test_user_list_and_crud(client: TestClient, admin_token: str) -> None: + r = client.get("/api/v1/users?page=1&size=20", headers=_h(admin_token)) + assert r.status_code == 200 + body = r.json()["data"] + assert body["total"] >= 2 + + uname = f"qa_user_{int(time.time())}" + r = client.post( + "/api/v1/users", + headers=_h(admin_token), + json={ + "username": uname, + "password": "qaqaqa", + "nickname": "测试账号", + "role_ids": [], + }, + ) + assert r.status_code == 200 + uid = r.json()["data"]["id"] + + # 重置密码 + r = client.post( + f"/api/v1/users/{uid}/reset-password", + headers=_h(admin_token), + json={"password": "newpass"}, + ) + assert r.status_code == 200 + + # 新密码登录 + r = client.post( + "/api/v1/auth/login", + json={"username": uname, "password": "newpass"}, + ) + assert r.status_code == 200 + + r = client.delete(f"/api/v1/users/{uid}", headers=_h(admin_token)) + assert r.status_code == 200 + + +# ========== Platform Account ========== + +def test_platform_account_encryption_and_test(client: TestClient, admin_token: str) -> None: + r = client.post( + "/api/v1/platform-accounts", + headers=_h(admin_token), + json={ + "platform_name": "facebook", + "login": "qa@example.com", + "password": "supersecret", + "google_key": "JBSWY3DPEHPK3PXP", + }, + ) + assert r.status_code == 200 + d = r.json()["data"] + # 明文不能出口 + assert "supersecret" not in r.text + assert "JBSWY3DPEHPK3PXP" not in r.text + assert d["password_mask"] and d["password_mask"] != "supersecret" + assert d["has_google_key"] is True + aid = d["id"] + + # 连通测试 + r = client.post(f"/api/v1/platform-accounts/{aid}/test", headers=_h(admin_token)) + assert r.status_code == 200 + assert r.json()["data"]["last_test_result"] == "ok" + + r = client.delete(f"/api/v1/platform-accounts/{aid}", headers=_h(admin_token)) + assert r.status_code == 200 + + +# ========== Roles ========== + +def test_role_crud_with_menus(client: TestClient, admin_token: str) -> None: + # 先拿几个菜单 id + r = client.get("/api/v1/menus", headers=_h(admin_token)) + menu_ids = [m["id"] for m in r.json()["data"] if m["perm_code"] == "task:list"] + assert menu_ids + + code = f"qa_role_{int(time.time())}" + r = client.post( + "/api/v1/roles", + headers=_h(admin_token), + json={"name": "QA 角色", "code": code, "menu_ids": menu_ids}, + ) + assert r.status_code == 200 + rid = r.json()["data"]["id"] + assert r.json()["data"]["menu_ids"] == menu_ids + + r = client.patch( + f"/api/v1/roles/{rid}", + headers=_h(admin_token), + json={"description": "updated"}, + ) + assert r.status_code == 200 + + r = client.delete(f"/api/v1/roles/{rid}", headers=_h(admin_token)) + assert r.status_code == 200 + + +def test_role_builtin_cannot_be_deleted(client: TestClient, admin_token: str) -> None: + # super_admin 是内置 + r = client.get("/api/v1/roles", headers=_h(admin_token)) + super_id = next(x["id"] for x in r.json()["data"] if x["code"] == "super_admin") + r = client.delete(f"/api/v1/roles/{super_id}", headers=_h(admin_token)) + assert r.status_code == 409 + + +# ========== OpLog ========== + +def test_oplog_captures_mutations(client: TestClient, admin_token: str) -> None: + # 触发一次创建 + 删除 + r = client.post( + "/api/v1/organizations", + headers=_h(admin_token), + json={"name": "Ephemeral", "code": f"EPH_{int(time.time())}"}, + ) + oid = r.json()["data"]["id"] + client.delete(f"/api/v1/organizations/{oid}", headers=_h(admin_token)) + + # middleware 是异步写入,等一小会儿 + for _ in range(10): + r = client.get("/api/v1/op-logs?module=organizations&page=1&size=20", + headers=_h(admin_token)) + logs = r.json()["data"]["list"] + if len(logs) >= 2: + break + time.sleep(0.2) + + assert len(logs) >= 2 + actions = {l["action"] for l in logs} + assert "create" in actions and "delete" in actions + # 验证脱敏:其它测试里 create user 时 password 字段应该是 *** + r = client.get("/api/v1/op-logs?module=users&page=1&size=20", headers=_h(admin_token)) + user_logs = r.json()["data"]["list"] + for l in user_logs: + if l["action"] == "create" and isinstance(l["payload"], dict): + assert l["payload"].get("password") == "***" diff --git a/backend/tests/test_healthz.py b/backend/tests/test_healthz.py new file mode 100644 index 0000000..306f3c4 --- /dev/null +++ b/backend/tests/test_healthz.py @@ -0,0 +1,35 @@ +"""基础健康检查:确认 app 能启动、路由挂载正确、异常 handler 生效。""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.main import app + + +def test_healthz_returns_200() -> None: + with TestClient(app) as client: + resp = client.get("/api/v1/healthz") + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 0 + assert "db" in body["data"] + assert "redis" in body["data"] + + +def test_login_placeholder_returns_401() -> None: + with TestClient(app) as client: + resp = client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "admin"}, + ) + assert resp.status_code == 401 + body = resp.json() + assert body["code"] == 401 + + +def test_validation_error_returns_422() -> None: + with TestClient(app) as client: + resp = client.post("/api/v1/auth/login", json={}) + assert resp.status_code == 422 + body = resp.json() + assert body["code"] == 422 diff --git a/backend/tests/test_lobster_config.py b/backend/tests/test_lobster_config.py new file mode 100644 index 0000000..97dc536 --- /dev/null +++ b/backend/tests/test_lobster_config.py @@ -0,0 +1,129 @@ +"""龙虾运行时配置测试。""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@pytest.fixture(scope="module") +def client() -> TestClient: + with TestClient(app) as c: + yield c + + +def _h(t: str) -> dict[str, str]: + return {"Authorization": f"Bearer {t}"} + + +@pytest.fixture(scope="module") +def admin_token(client: TestClient) -> str: + r = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"}) + return r.json()["data"]["access_token"] + + +def test_get_default_from_env(client: TestClient, admin_token: str) -> None: + """全新状态下应该回落 .env(当前本地 .env 里 LOBSTER_* 都留空 → mock 模式)。""" + r = client.get("/api/v1/lobster-config", headers=_h(admin_token)) + assert r.status_code == 200 + d = r.json()["data"] + assert d["mode"] == "mock" + assert d["has_api_key"] is False + + +def test_update_in_ui_overrides_env(client: TestClient, admin_token: str) -> None: + r = client.put( + "/api/v1/lobster-config", headers=_h(admin_token), + json={ + "api_base": "https://lobster.example.com/v1", + "api_key": "sk-lobster-UI-FAKE-123456", + "timeout_seconds": 45, + "note": "由 pytest 写入", + }, + ) + assert r.status_code == 200 + d = r.json()["data"] + assert d["mode"] == "real" + assert d["api_base"] == "https://lobster.example.com/v1" + assert d["api_base_source"] == "db" + assert d["api_key_source"] == "db" + assert d["has_api_key"] is True + assert d["api_key_mask"].startswith("sk") + # 明文永不出口 + assert "sk-lobster-UI-FAKE-123456" not in r.text + + # executor/status 应当反映真实模式 + r = client.get("/api/v1/executors/status", headers=_h(admin_token)) + e = r.json()["data"]["lobster"] + assert e["mode"] == "real" + assert e["api_base"] == "https://lobster.example.com/v1" + assert e["api_base_source"] == "db" + + +def test_clear_falls_back_to_env(client: TestClient, admin_token: str) -> None: + r = client.put( + "/api/v1/lobster-config", headers=_h(admin_token), + json={"api_base": "", "api_key": ""}, + ) + assert r.status_code == 200 + d = r.json()["data"] + # .env 里 LOBSTER_* 留空 → 应退回 unset / env + assert d["api_base_source"] in {"env", "unset"} + assert d["mode"] == "mock" + + +def test_operator_cannot_read_or_update(client: TestClient) -> None: + otok = client.post( + "/api/v1/auth/login", json={"username": "operator", "password": "operator123"} + ).json()["data"]["access_token"] + assert client.get("/api/v1/lobster-config", headers=_h(otok)).status_code == 403 + assert client.put( + "/api/v1/lobster-config", headers=_h(otok), json={"api_base": "x"} + ).status_code == 403 + + +def test_callback_signature_uses_db_secret(client: TestClient, admin_token: str) -> None: + """把 callback_secret 写入 DB 后,未带签名的回调应 401。""" + import hashlib + import hmac + import json + + secret = "qa-callback-secret-unique-value" + r = client.put( + "/api/v1/lobster-config", headers=_h(admin_token), + json={"callback_secret": secret}, + ) + assert r.status_code == 200 + assert r.json()["data"]["has_callback_secret"] is True + + # 无签名 → 401 + body = {"run_id": "whatever", "status": "SUCCEEDED"} + r = client.post("/api/v1/callbacks/lobster", json=body) + assert r.status_code == 401 + + # 错签名 → 401 + r = client.post( + "/api/v1/callbacks/lobster", json=body, + headers={"X-Loom-Signature": "deadbeef"}, + ) + assert r.status_code == 401 + + # 正确签名 → 200(即使 run_id 未知,callback 也只是 handled=False) + raw = json.dumps(body, separators=(",", ":")).encode() + sig = hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest() + r = client.post( + "/api/v1/callbacks/lobster", content=raw, + headers={ + "Content-Type": "application/json", + "X-Loom-Signature": sig, + }, + ) + assert r.status_code == 200 + assert r.json()["data"]["handled"] is False + + # 清空 + client.put( + "/api/v1/lobster-config", headers=_h(admin_token), + json={"callback_secret": ""}, + ) diff --git a/backend/tests/test_p4.py b/backend/tests/test_p4.py new file mode 100644 index 0000000..1e60575 --- /dev/null +++ b/backend/tests/test_p4.py @@ -0,0 +1,232 @@ +"""P4 测试:Platform / ChannelCode / Product / Skill / Script / Skill test。依赖 P2+P4 种子。""" +from __future__ import annotations + +import time + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@pytest.fixture(scope="module") +def client() -> TestClient: + with TestClient(app) as c: + yield c + + +def _h(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture(scope="module") +def admin_token(client: TestClient) -> str: + r = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"}) + return r.json()["data"]["access_token"] + + +@pytest.fixture(scope="module") +def operator_token(client: TestClient) -> str: + r = client.post( + "/api/v1/auth/login", json={"username": "operator", "password": "operator123"} + ) + return r.json()["data"]["access_token"] + + +# -------- Platform -------- + +def test_platforms_list_admin(client: TestClient, admin_token: str) -> None: + r = client.get("/api/v1/platforms", headers=_h(admin_token)) + assert r.status_code == 200 + data = r.json()["data"] + codes = {p["code"] for p in data} + assert {"facebook", "tiktok", "google"} <= codes + fb = next(p for p in data if p["code"] == "facebook") + assert fb["product_count"] >= 1 + + +# -------- Channel Code -------- + +def test_channel_crud_and_type_filter(client: TestClient, admin_token: str) -> None: + # 列表 + r = client.get("/api/v1/channel-codes?page=1&size=50&type=INTERNAL", headers=_h(admin_token)) + assert r.status_code == 200 + # 新建 + code = f"CH_{int(time.time())}" + r = client.post( + "/api/v1/channel-codes", + headers=_h(admin_token), + json={"code": code, "name": "QA渠道", "type": "INTERNAL"}, + ) + assert r.status_code == 200 + cid = r.json()["data"]["id"] + r = client.patch( + f"/api/v1/channel-codes/{cid}", headers=_h(admin_token), + json={"name": "QA渠道改"}, + ) + assert r.json()["data"]["name"] == "QA渠道改" + r = client.delete(f"/api/v1/channel-codes/{cid}", headers=_h(admin_token)) + assert r.status_code == 200 + + +# -------- Product -------- + +def test_product_crud_with_channels(client: TestClient, admin_token: str) -> None: + # 获取一个渠道码 id + r = client.get("/api/v1/channel-codes/all", headers=_h(admin_token)) + channel = r.json()["data"][0] + # 平台 id + r = client.get("/api/v1/platforms", headers=_h(admin_token)) + plat = r.json()["data"][0] + + code = f"P_{int(time.time())}" + r = client.post( + "/api/v1/products", + headers=_h(admin_token), + json={ + "code": code, + "name": "QA 产品", + "platform_id": plat["id"], + "channel_code_ids": [channel["id"]], + }, + ) + assert r.status_code == 200 + pid = r.json()["data"]["id"] + assert r.json()["data"]["channel_code_ids"] == [channel["id"]] + + # 清空渠道码 + r = client.patch( + f"/api/v1/products/{pid}", headers=_h(admin_token), + json={"channel_code_ids": []}, + ) + assert r.json()["data"]["channel_code_ids"] == [] + + r = client.delete(f"/api/v1/products/{pid}", headers=_h(admin_token)) + assert r.status_code == 200 + + +def test_product_operator_403(client: TestClient, operator_token: str) -> None: + r = client.get("/api/v1/products", headers=_h(operator_token)) + assert r.status_code == 403 + + +# -------- Script Template -------- + +def test_script_version_bumps_on_content_change(client: TestClient, admin_token: str) -> None: + code = f"s_{int(time.time())}" + r = client.post( + "/api/v1/scripts", headers=_h(admin_token), + json={"code": code, "name": "QA", "language": "python", "content": "print(1)"}, + ) + sid = r.json()["data"]["id"] + v0 = r.json()["data"]["version"] + # 仅改描述,version 不变 + r = client.patch(f"/api/v1/scripts/{sid}", headers=_h(admin_token), json={"description": "x"}) + assert r.json()["data"]["version"] == v0 + # 改 content,version +1 + r = client.patch(f"/api/v1/scripts/{sid}", headers=_h(admin_token), json={"content": "print(2)"}) + assert r.json()["data"]["version"] == v0 + 1 + client.delete(f"/api/v1/scripts/{sid}", headers=_h(admin_token)) + + +# -------- Skill -------- + +def test_skill_rb13_executor_exclusive(client: TestClient, admin_token: str) -> None: + code = f"sk_{int(time.time())}" + # LOBSTER 不可携带 script_template_id + r = client.post( + "/api/v1/skills", headers=_h(admin_token), + json={ + "code": code, "name": "QA", + "executor_type": "LOBSTER", + "lobster_template_id": "abc", + "script_template_id": 1, + }, + ) + assert r.status_code == 400 + assert "不可绑定" in r.json()["message"] + + +def test_skill_test_endpoint_ok_and_missing_required( + client: TestClient, admin_token: str +) -> None: + # 用种子 skill + r = client.get("/api/v1/skills?keyword=fb_lobster_post", headers=_h(admin_token)) + skill = r.json()["data"]["list"][0] + sid = skill["id"] + + # 正确输入 → ok + r = client.post( + f"/api/v1/skills/{sid}/test", headers=_h(admin_token), + json={"input_data": {"title": "hi", "content": "body"}}, + ) + assert r.status_code == 200 + body = r.json()["data"] + assert body["result"] == "ok" + assert body["executor_type"] == "LOBSTER" + + # 缺必填 → failed + r = client.post( + f"/api/v1/skills/{sid}/test", headers=_h(admin_token), + json={"input_data": {"title": "hi"}}, + ) + assert r.status_code == 200 + body = r.json()["data"] + assert body["result"] == "failed" + assert "content" in (body.get("message") or "") + + +def test_skill_call_log_visible(client: TestClient, admin_token: str) -> None: + r = client.get("/api/v1/skill-logs?page=1&size=10", headers=_h(admin_token)) + assert r.status_code == 200 + body = r.json()["data"] + # 由上一个测试产生 + assert body["total"] >= 2 + assert body["list"][0]["skill_code"] + + +def test_skill_operator_cannot_test(client: TestClient, operator_token: str) -> None: + # operator 只有 skill:view,没有 skill:test + r = client.get("/api/v1/skills", headers=_h(operator_token)) + assert r.status_code == 200 + sid = r.json()["data"]["list"][0]["id"] + r = client.post( + f"/api/v1/skills/{sid}/test", headers=_h(operator_token), + json={"input_data": {"title": "x", "content": "y"}}, + ) + assert r.status_code == 403 + + +# -------- 新增:Skill 测试台走真实 SCRIPT 分支 -------- + +def test_skill_test_runs_real_script_executor(client: TestClient, admin_token: str) -> None: + """P4 mock 改为真实执行器短链路后,SCRIPT 分支应真的调 subprocess。""" + import time as _t + + # 找一个 python 脚本模板 + r = client.get("/api/v1/scripts/all", headers=_h(admin_token)) + script = next(s for s in r.json()["data"] if s["code"] == "py_hello") + + # 建一个 SCRIPT skill + code = f"skill_real_test_{int(_t.time())}" + r = client.post( + "/api/v1/skills", headers=_h(admin_token), + json={ + "code": code, "name": "Real Skill Test", + "executor_type": "SCRIPT", + "script_template_id": script["id"], + "input_schema": {"type": "object", "properties": {}}, + }, + ) + sid = r.json()["data"]["id"] + + r = client.post( + f"/api/v1/skills/{sid}/test", headers=_h(admin_token), + json={"input_data": {}}, + ) + assert r.status_code == 200 + body = r.json()["data"] + assert body["result"] == "ok" + # 真实执行必然有 returncode 和 stdout_tail + assert body["output_data"]["returncode"] == 0 + assert "hello from loom script" in body["output_data"]["stdout_tail"] diff --git a/backend/tests/test_p5.py b/backend/tests/test_p5.py new file mode 100644 index 0000000..f9d8b83 --- /dev/null +++ b/backend/tests/test_p5.py @@ -0,0 +1,252 @@ +"""P5 测试:Task 生命周期(LOBSTER mock + SCRIPT subprocess)、Schedule、Dashboard。""" +from __future__ import annotations + +import asyncio +import time + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@pytest.fixture(scope="module") +def client() -> TestClient: + with TestClient(app) as c: + yield c + + +def _h(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture(scope="module") +def admin_token(client: TestClient) -> str: + r = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"}) + return r.json()["data"]["access_token"] + + +def _wait_status(client: TestClient, token: str, tid: int, targets: set[str], timeout: float = 30.0): + """轮询直到达到目标状态或超时。""" + end = time.time() + timeout + while time.time() < end: + r = client.get(f"/api/v1/tasks/{tid}", headers=_h(token)) + st = r.json()["data"]["status"] + if st in targets: + return r.json()["data"] + time.sleep(0.5) + raise AssertionError(f"task {tid} 未在 {timeout}s 内到达 {targets}") + + +# -------- LOBSTER 任务 -------- + +def test_lobster_task_end_to_end(client: TestClient, admin_token: str) -> None: + r = client.get("/api/v1/skills?keyword=fb_lobster_post", headers=_h(admin_token)) + skill = r.json()["data"]["list"][0] + r = client.get("/api/v1/products?page=1&size=20", headers=_h(admin_token)) + product = next(p for p in r.json()["data"]["list"] if p["code"] == "fb_demo") + + r = client.post( + "/api/v1/tasks", + headers=_h(admin_token), + json={ + "name": "P5 龙虾 mock 测试", + "skill_id": skill["id"], + "product_id": product["id"], + "input_data": {"title": "hi", "content": "body"}, + "timeout_seconds": 30, + }, + ) + assert r.status_code == 200, r.text + task = r.json()["data"] + assert task["status"] == "PENDING" + assert task["executor_type"] == "LOBSTER" + tid = task["id"] + + r = client.post(f"/api/v1/tasks/{tid}/run", headers=_h(admin_token)) + assert r.status_code == 200 + + final = _wait_status(client, admin_token, tid, {"SUCCEEDED", "FAILED", "TIMEOUT"}, timeout=20) + assert final["status"] == "SUCCEEDED" + assert final["external_ref"] + assert (final["result_data"] or {}).get("mock") is True + + r = client.get(f"/api/v1/tasks/{tid}/logs", headers=_h(admin_token)) + logs = r.json()["data"] + assert any("mock" in (l["message"] or "").lower() or "龙虾" in (l["message"] or "") for l in logs) + + +def test_lobster_missing_required_rejected_at_create( + client: TestClient, admin_token: str +) -> None: + r = client.get("/api/v1/skills?keyword=fb_lobster_post", headers=_h(admin_token)) + skill = r.json()["data"]["list"][0] + r = client.get("/api/v1/products?page=1&size=20", headers=_h(admin_token)) + product = next(p for p in r.json()["data"]["list"] if p["code"] == "fb_demo") + + r = client.post( + "/api/v1/tasks", + headers=_h(admin_token), + json={ + "name": "缺必填", + "skill_id": skill["id"], + "product_id": product["id"], + "input_data": {"title": "x"}, + "timeout_seconds": 10, + }, + ) + assert r.status_code == 400 + assert "必填字段缺失" in r.json()["message"] + + +# -------- SCRIPT 任务 -------- + +def test_script_task_end_to_end(client: TestClient, admin_token: str) -> None: + # 找一个 python 脚本模板 + r = client.get("/api/v1/scripts/all", headers=_h(admin_token)) + script = next(s for s in r.json()["data"] if s["code"] == "py_hello") + + # 建一个 SCRIPT 类型 Skill + code = f"script_test_{int(time.time())}" + r = client.post( + "/api/v1/skills", + headers=_h(admin_token), + json={ + "code": code, + "name": "QA 脚本 Skill", + "executor_type": "SCRIPT", + "script_template_id": script["id"], + "input_schema": {"type": "object", "properties": {"name": {"type": "string"}}}, + }, + ) + assert r.status_code == 200 + skill_id = r.json()["data"]["id"] + + r = client.post( + "/api/v1/tasks", + headers=_h(admin_token), + json={ + "name": "Python 脚本任务", + "skill_id": skill_id, + "input_data": {"name": "loom"}, + "timeout_seconds": 15, + }, + ) + assert r.status_code == 200 + tid = r.json()["data"]["id"] + + client.post(f"/api/v1/tasks/{tid}/run", headers=_h(admin_token)) + final = _wait_status(client, admin_token, tid, {"SUCCEEDED", "FAILED", "TIMEOUT"}, timeout=30) + assert final["status"] == "SUCCEEDED", final + r = client.get(f"/api/v1/tasks/{tid}/logs", headers=_h(admin_token)) + logs = r.json()["data"] + assert any("hello from loom script" in (l["message"] or "") for l in logs) + + # 清理:Skill 已被 Task 引用,删会 409;改为禁用 + r = client.patch( + f"/api/v1/skills/{skill_id}", headers=_h(admin_token), + json={"status": "DISABLED"}, + ) + assert r.status_code == 200 + + +def test_task_cancel_and_retry(client: TestClient, admin_token: str) -> None: + r = client.get("/api/v1/skills?keyword=fb_lobster_post", headers=_h(admin_token)) + skill = r.json()["data"]["list"][0] + r = client.get("/api/v1/products?page=1&size=20", headers=_h(admin_token)) + product = next(p for p in r.json()["data"]["list"] if p["code"] == "fb_demo") + + r = client.post( + "/api/v1/tasks", + headers=_h(admin_token), + json={ + "name": "取消测试", + "skill_id": skill["id"], + "product_id": product["id"], + "input_data": {"title": "x", "content": "y"}, + "timeout_seconds": 30, + }, + ) + tid = r.json()["data"]["id"] + # 不 run,直接 cancel(允许从 PENDING 取消) + r = client.post(f"/api/v1/tasks/{tid}/cancel", headers=_h(admin_token)) + assert r.status_code == 200 + assert r.json()["data"]["status"] == "CANCELED" + + # retry 取消过的任务(已终态) + r = client.post(f"/api/v1/tasks/{tid}/retry", headers=_h(admin_token)) + assert r.status_code == 200 + new = r.json()["data"] + assert new["id"] != tid + # 等它跑完 + _wait_status(client, admin_token, new["id"], {"SUCCEEDED", "FAILED", "TIMEOUT"}, timeout=20) + + +# -------- Schedule -------- + +def test_schedule_crud_and_trigger(client: TestClient, admin_token: str) -> None: + r = client.get("/api/v1/skills?keyword=fb_lobster_post", headers=_h(admin_token)) + skill = r.json()["data"]["list"][0] + r = client.get("/api/v1/products?page=1&size=20", headers=_h(admin_token)) + product = next(p for p in r.json()["data"]["list"] if p["code"] == "fb_demo") + + # 每小时 0 分 + r = client.post( + "/api/v1/schedules", + headers=_h(admin_token), + json={ + "name": "hourly_fb_demo", + "skill_id": skill["id"], + "product_id": product["id"], + "input_data": {"title": "cron", "content": "cron body"}, + "cron": "0 * * * *", + }, + ) + assert r.status_code == 200 + sid = r.json()["data"]["id"] + assert r.json()["data"]["next_run_at"] + + # 非法 cron(长度够 9 但段数不对 → 进入 service 层 400) + r = client.patch( + f"/api/v1/schedules/{sid}", headers=_h(admin_token), + json={"cron": "not a valid cron expression"}, + ) + assert r.status_code == 400 + assert "cron" in r.json()["message"].lower() or "5 段" in r.json()["message"] + + # 手动触发 + r = client.post(f"/api/v1/schedules/{sid}/trigger", headers=_h(admin_token)) + assert r.status_code == 200 + + # 关闭 + r = client.post( + f"/api/v1/schedules/{sid}/toggle?enabled=false", headers=_h(admin_token) + ) + assert r.status_code == 200 + assert r.json()["data"]["enabled"] is False + + r = client.get(f"/api/v1/schedules/{sid}/logs", headers=_h(admin_token)) + assert len(r.json()["data"]) >= 1 + + client.delete(f"/api/v1/schedules/{sid}", headers=_h(admin_token)) + + +# -------- Dashboard -------- + +def test_dashboard_summary(client: TestClient, admin_token: str) -> None: + r = client.get("/api/v1/dashboard/summary", headers=_h(admin_token)) + assert r.status_code == 200 + d = r.json()["data"] + assert d["total_users"] >= 2 + assert d["total_skills"] >= 1 + assert d["total_tasks"] >= 1 + assert isinstance(d["by_status"], dict) + + +def test_dashboard_report(client: TestClient, admin_token: str) -> None: + r = client.get("/api/v1/dashboard/report?days=7", headers=_h(admin_token)) + assert r.status_code == 200 + d = r.json()["data"] + assert isinstance(d["daily"], dict) + assert isinstance(d["top_skills"], list) + assert "avg_cost_ms" in d diff --git a/backend/tests/test_p6.py b/backend/tests/test_p6.py new file mode 100644 index 0000000..290c943 --- /dev/null +++ b/backend/tests/test_p6.py @@ -0,0 +1,193 @@ +"""P6 测试:LLM Provider/Model/Routing/Chat + Executor health。""" +from __future__ import annotations + +import time + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@pytest.fixture(scope="module") +def client() -> TestClient: + with TestClient(app) as c: + yield c + + +@pytest.fixture(scope="module") +def admin_token(client: TestClient) -> str: + r = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"}) + return r.json()["data"]["access_token"] + + +@pytest.fixture(scope="module") +def operator_token(client: TestClient) -> str: + r = client.post( + "/api/v1/auth/login", json={"username": "operator", "password": "operator123"} + ) + return r.json()["data"]["access_token"] + + +def _h(t: str) -> dict[str, str]: + return {"Authorization": f"Bearer {t}"} + + +# -------- Executor -------- + +def test_executor_status(client: TestClient, admin_token: str) -> None: + r = client.get("/api/v1/executors/status", headers=_h(admin_token)) + assert r.status_code == 200 + d = r.json()["data"] + assert d["lobster"]["mode"] in {"real", "mock"} + assert d["script"]["mode"] == "subprocess" + assert d["llm"]["providers_active"] >= 1 + + +def test_executor_status_operator_403(client: TestClient, operator_token: str) -> None: + r = client.get("/api/v1/executors/status", headers=_h(operator_token)) + assert r.status_code == 403 + + +# -------- LLM CRUD -------- + +def test_providers_list_includes_seed(client: TestClient, admin_token: str) -> None: + r = client.get("/api/v1/llm/providers", headers=_h(admin_token)) + codes = {p["code"] for p in r.json()["data"]} + assert "openai" in codes + + +def test_models_and_rules_list(client: TestClient, admin_token: str) -> None: + r = client.get("/api/v1/llm/models", headers=_h(admin_token)) + models = r.json()["data"] + assert any(m["model"] == "gpt-4o-mini" for m in models) + + r = client.get("/api/v1/llm/rules", headers=_h(admin_token)) + rules = r.json()["data"] + assert any(r["name"] == "default" for r in rules) + + +def test_provider_update_api_key_and_mask(client: TestClient, admin_token: str) -> None: + r = client.get("/api/v1/llm/providers", headers=_h(admin_token)) + openai = next(p for p in r.json()["data"] if p["code"] == "openai") + pid = openai["id"] + + r = client.patch( + f"/api/v1/llm/providers/{pid}", headers=_h(admin_token), + json={"api_key": "sk-fake-keytest12345"}, + ) + assert r.status_code == 200 + assert r.json()["data"]["has_api_key"] is True + assert r.json()["data"]["api_key_mask"].startswith("sk") + # 明文不应出现在响应 + assert "sk-fake-keytest12345" not in r.text + + # 清空,避免后续 chat 测试走真实调用 + r = client.patch( + f"/api/v1/llm/providers/{pid}", headers=_h(admin_token), + json={"api_key": ""}, + ) + assert r.json()["data"]["has_api_key"] is False + + +# -------- Chat (mock) -------- + +def test_chat_with_default_rule(client: TestClient, admin_token: str) -> None: + r = client.post( + "/api/v1/llm/chat", headers=_h(admin_token), + json={ + "messages": [ + {"role": "system", "content": "你是测试助手"}, + {"role": "user", "content": "hello"}, + ], + }, + ) + assert r.status_code == 200 + d = r.json()["data"] + assert d["provider"] == "openai" + assert d["model"] in {"gpt-4o-mini", "gpt-4o"} + assert d["total_tokens"] > 0 + assert "mock" in d["content"].lower() or len(d["content"]) > 0 + + +def test_chat_route_by_conditions(client: TestClient, admin_token: str) -> None: + r = client.post( + "/api/v1/llm/chat", headers=_h(admin_token), + json={ + "messages": [{"role": "user", "content": "test"}], + "conditions": {"category": "reasoning"}, + }, + ) + assert r.status_code == 200 + d = r.json()["data"] + assert d["model"] == "gpt-4o" # premium 规则命中 + + +def test_chat_explicit_model(client: TestClient, admin_token: str) -> None: + r = client.post( + "/api/v1/llm/chat", headers=_h(admin_token), + json={ + "messages": [{"role": "user", "content": "x"}], + "model": "openai/gpt-4o-mini", + }, + ) + assert r.status_code == 200 + assert r.json()["data"]["model"] == "gpt-4o-mini" + + +def test_usage_summary_grows(client: TestClient, admin_token: str) -> None: + r = client.get("/api/v1/llm/usage/summary", headers=_h(admin_token)) + assert r.status_code == 200 + d = r.json()["data"] + assert d["total_calls"] >= 3 + assert isinstance(d["by_model"], list) + + +def test_usage_list(client: TestClient, admin_token: str) -> None: + r = client.get("/api/v1/llm/usage?page=1&size=20", headers=_h(admin_token)) + assert r.status_code == 200 + rows = r.json()["data"]["list"] + assert len(rows) > 0 + assert all(r.get("total_tokens") is not None for r in rows) + assert any(r.get("provider_code") == "openai" for r in rows) + + +# -------- 新建自定义 provider 并运行 chat(mock 回退)-------- + +def test_custom_provider_mock_chat(client: TestClient, admin_token: str) -> None: + code = f"qa_prov_{int(time.time())}" + r = client.post( + "/api/v1/llm/providers", headers=_h(admin_token), + json={ + "code": code, + "name": "QA Provider", + "api_base": "https://invalid.example.com/v1", + # 不传 api_key → 走 mock + }, + ) + pid = r.json()["data"]["id"] + + r = client.post( + "/api/v1/llm/models", headers=_h(admin_token), + json={ + "provider_id": pid, "model": "qa-model", + "name": "QA Model", "max_context": 4096, + }, + ) + mid = r.json()["data"]["id"] + + r = client.post( + "/api/v1/llm/chat", headers=_h(admin_token), + json={ + "messages": [{"role": "user", "content": "ping"}], + "model": f"{code}/qa-model", + }, + ) + assert r.status_code == 200 + assert "[mock:" in r.json()["data"]["content"] + + # 清理:有模型在用就不让删 provider + r = client.delete(f"/api/v1/llm/providers/{pid}", headers=_h(admin_token)) + assert r.status_code == 409 + client.delete(f"/api/v1/llm/models/{mid}", headers=_h(admin_token)) + client.delete(f"/api/v1/llm/providers/{pid}", headers=_h(admin_token)) diff --git a/backend/tests/test_role_scope.py b/backend/tests/test_role_scope.py new file mode 100644 index 0000000..33bd163 --- /dev/null +++ b/backend/tests/test_role_scope.py @@ -0,0 +1,92 @@ +"""角色 4 维权限(菜单 + 产品 + Skill + 平台账号)绑定测试。""" +from __future__ import annotations + +import time + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@pytest.fixture(scope="module") +def client() -> TestClient: + with TestClient(app) as c: + yield c + + +@pytest.fixture(scope="module") +def admin_token(client: TestClient) -> str: + r = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"}) + return r.json()["data"]["access_token"] + + +def _h(t: str) -> dict[str, str]: + return {"Authorization": f"Bearer {t}"} + + +def test_role_with_full_4d_scope(client: TestClient, admin_token: str) -> None: + # 拿到可用的 menu / product / skill / account id + menus = client.get("/api/v1/menus", headers=_h(admin_token)).json()["data"] + menu_ids = [m["id"] for m in menus[:3]] + + products = client.get("/api/v1/products?page=1&size=5", headers=_h(admin_token)).json()["data"]["list"] + product_ids = [p["id"] for p in products[:2]] + + skills = client.get("/api/v1/skills?page=1&size=5", headers=_h(admin_token)).json()["data"]["list"] + skill_ids = [s["id"] for s in skills[:2]] + + # 先建一个平台账号供绑定 + acc_resp = client.post( + "/api/v1/platform-accounts", headers=_h(admin_token), + json={ + "platform_name": "qa_platform", + "login": f"qa_{int(time.time())}@e.com", + "password": "x", + }, + ) + account_id = acc_resp.json()["data"]["id"] + + code = f"qa_4d_{int(time.time())}" + resp = client.post( + "/api/v1/roles", headers=_h(admin_token), + json={ + "name": "4D QA 角色", "code": code, + "menu_ids": menu_ids, + "product_scope_ids": product_ids, + "skill_scope_ids": skill_ids, + "account_scope_ids": [account_id], + }, + ) + assert resp.status_code == 200, resp.text + body = resp.json()["data"] + assert set(body["menu_ids"]) == set(menu_ids) + assert set(body["product_scope_ids"]) == set(product_ids) + assert set(body["skill_scope_ids"]) == set(skill_ids) + assert body["account_scope_ids"] == [account_id] + rid = body["id"] + + # 单独校验 GET 同样返回 4 维 + resp = client.get(f"/api/v1/roles/{rid}", headers=_h(admin_token)) + body = resp.json()["data"] + assert set(body["product_scope_ids"]) == set(product_ids) + assert set(body["skill_scope_ids"]) == set(skill_ids) + + # 清空产品范围 + resp = client.patch( + f"/api/v1/roles/{rid}", headers=_h(admin_token), + json={"product_scope_ids": []}, + ) + assert resp.json()["data"]["product_scope_ids"] == [] + # skill 未传 → 不变 + assert set(resp.json()["data"]["skill_scope_ids"]) == set(skill_ids) + + # 绑定不存在的 product → 400 + resp = client.patch( + f"/api/v1/roles/{rid}", headers=_h(admin_token), + json={"product_scope_ids": [9_999_999]}, + ) + assert resp.status_code == 400 + + client.delete(f"/api/v1/roles/{rid}", headers=_h(admin_token)) + client.delete(f"/api/v1/platform-accounts/{account_id}", headers=_h(admin_token)) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..22ed682 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,47 @@ +# 生产覆盖层: +# docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile full up -d --build +# +# 变化点: +# - postgres/redis 不再暴露到公网(去掉 ports) +# - frontend 监听 80 + 443,挂载 Let's Encrypt 证书 + ACME 挑战目录 + 生产 nginx.conf +# - backend 不再暴露到公网(由 nginx 反代) +# - 加上 certbot 容器,定期续签 +services: + postgres: + ports: !reset [] + restart: always + + redis: + ports: !reset [] + restart: always + + backend: + ports: !reset [] + restart: always + environment: + APP_ENV: prod + APP_BASE_URL: https://ai.aidfxn.com + CORS_ALLOW_ORIGINS: https://ai.aidfxn.com + + frontend: + restart: always + ports: !override + - "80:80" + - "443:443" + volumes: + - ./docker/nginx.prod.conf:/etc/nginx/conf.d/default.conf:ro + - ./certbot/conf:/etc/letsencrypt:ro + - ./certbot/www:/var/www/certbot:ro + + certbot: + image: certbot/certbot:latest + container_name: loom-certbot + restart: unless-stopped + volumes: + - ./certbot/conf:/etc/letsencrypt + - ./certbot/www:/var/www/certbot + # 12h 检查一次是否续签 + entrypoint: /bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot --quiet; sleep 12h & wait $${!}; done' + profiles: ["full"] + depends_on: + - frontend diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6c2bf7b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,98 @@ +services: + postgres: + image: postgres:16-alpine + container_name: loom-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-loom} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-loom} + POSTGRES_DB: ${POSTGRES_DB:-loom} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-loom} -d ${POSTGRES_DB:-loom}"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: loom-redis + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis_data:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: docker/Dockerfile + container_name: loom-backend + restart: unless-stopped + env_file: .env + environment: + # 容器网络内的服务名覆盖 + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-loom}:${POSTGRES_PASSWORD:-loom}@postgres:5432/${POSTGRES_DB:-loom} + REDIS_URL: redis://redis:6379/0 + CELERY_BROKER_URL: redis://redis:6379/1 + CELERY_RESULT_BACKEND: redis://redis:6379/2 + # 允许从前端容器 + ai.aidfxn.com 访问 + CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-http://localhost,http://frontend,https://ai.aidfxn.com} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + ports: + - "8000:8000" + profiles: ["full"] + + # Celery worker / beat 暂未启用(P5 的 subprocess 模式已满足脚本执行需求)。 + # 如需后续切换到 Celery 版,取消下面两段注释 + 在 app/tasks/ 下添加 celery_app。 + # worker: + # build: + # context: ./backend + # dockerfile: docker/Dockerfile + # env_file: .env + # command: ["celery", "-A", "app.tasks.celery_app", "worker", "--loglevel=info"] + # depends_on: + # - backend + # - redis + # profiles: ["full"] + # beat: + # build: + # context: ./backend + # dockerfile: docker/Dockerfile + # env_file: .env + # command: ["celery", "-A", "app.tasks.celery_app", "beat", "--loglevel=info"] + # depends_on: + # - backend + # - redis + # profiles: ["full"] + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: loom-frontend + restart: unless-stopped + volumes: + - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro + ports: + - "80:80" + depends_on: + - backend + profiles: ["full"] + +volumes: + postgres_data: + redis_data: diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..d315178 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,35 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # SPA: 所有前端路由回退 index.html + location / { + try_files $uri $uri/ /index.html; + } + + # API 反向代理到 backend 服务 + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # SSE / 长连接 + proxy_buffering off; + proxy_read_timeout 3600s; + } + + # WebSocket(任务实时日志) + location /api/v1/ws/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + } +} diff --git a/docker/nginx.prod.conf b/docker/nginx.prod.conf new file mode 100644 index 0000000..e915aec --- /dev/null +++ b/docker/nginx.prod.conf @@ -0,0 +1,82 @@ +# Loom 生产 nginx:ai.aidfxn.com +# 1) 80 端口:承载 Let's Encrypt ACME HTTP-01 挑战 + 其余 301 到 HTTPS +# 2) 443 端口:TLS + SPA 回退 + API 反代 + WebSocket / SSE + +server { + listen 80; + server_name ai.aidfxn.com; + + # Let's Encrypt 需要的挑战路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其余全部 301 到 HTTPS + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + server_name ai.aidfxn.com; + + ssl_certificate /etc/letsencrypt/live/ai.aidfxn.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/ai.aidfxn.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_prefer_server_ciphers off; + + # 安全响应头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + client_max_body_size 20m; + + # 前端静态资源 + root /usr/share/nginx/html; + index index.html; + + # SPA 路由回退 + location / { + try_files $uri $uri/ /index.html; + } + + # API 反代到 backend 服务 + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE / 任务实时日志(关闭缓冲 + 长连接) + proxy_buffering off; + proxy_read_timeout 3600s; + } + + # WebSocket(未来任务实时日志走 WS 时生效) + location /api/v1/ws/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + } + + # 缓存静态资源 + location ~* \.(?:js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico|webp)$ { + expires 7d; + access_log off; + try_files $uri =404; + } +} diff --git a/docs/Agent任务系统_v2.1_PRD_merged.html b/docs/Agent任务系统_v2.1_PRD_merged.html new file mode 100644 index 0000000..de4d204 --- /dev/null +++ b/docs/Agent任务系统_v2.1_PRD_merged.html @@ -0,0 +1,3454 @@ + + + + + +Agent 任务系统 v2.1 产品需求文档(PRD) + + + +
+ +
+

Agent 任务系统 v2.1 产品需求文档(PRD)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
项内容
文档版本v2.1
产出时间2026-04-24
产品形态Web 管理后台(Next.js 14 + NestJS + PostgreSQL)
文档来源基于高保真原型 agent_task_system_merged.html 反向拆解
说明本文档按模块编写 PRD,每个模块包含:页面目标、页面结构、数据字段、按钮级操作细节、表单字段、交互规则、提示反馈
+
+

一、产品概述

+

1.1 产品定位

+

企业级 Agent 任务自动化平台,提供基于角色权限的 Skill 调度与管理能力,支持龙虾 Agent + 脚本引擎双执行器架构。

+

1.2 核心能力

+
    +
  • 权限体系:组织(公司 → 部门 → 小组)→ 角色(部门级)→ 菜单/产品/Skill/账号四维权限
  • +
  • 产品链路:产品 → 渠道码(内导 + 自然)→ 平台产品账号 → 归属用户
  • +
  • Skill 中心:龙虾 Agent + 脚本模板双执行器,支持参数映射与测试台
  • +
  • 任务调度:四步向导创建任务、交集校验、实时执行、定时调度
  • +
  • 观测能力:任务列表、Skill 调用日志、操作日志、统计报表
  • +
  • 系统设置:执行器节点管理、脚本模板库、定时任务
  • +
+

1.3 默认账号

+
    +
  • 用户名:admin / 密码:admin123
  • +
  • 登录成功后进入仪表盘
  • +
+
+

二、系统模块总览

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
编号模块名分组核心职责
01登录页入口账号密码 + 验证码登录
02仪表盘首页系统运行概览、快捷入口、实时数据
03组织架构权限管理公司/部门/小组三级树
04角色管理权限管理部门级角色 + 四维权限配置
05用户管理权限管理用户 CRUD、多角色绑定、跨部门授权
12菜单管理权限管理菜单树 + 按钮权限维护
06产品管理产品管理产品 CRUD、渠道码绑定、批量导入
07渠道码管理产品管理内导/自然渠道码独立管理、产品绑定
13平台套件产品管理平台 + 产品代号两级关系维护
08平台产品账号平台账号用户自管平台登录凭据、连通测试
09Skill 管理Skill 中心双执行器 Skill 注册、参数映射、测试
14Skill 调用日志Skill 中心历史调用审计、详情与日志回放
10任务创建任务中心四步向导:Skill → 产品 → 参数 → 确认
11任务列表任务中心任务检索、取消、重试、详情查看
15操作日志日志系统增删改 + 登录审计
16统计报表日志多维度可视化统计
17执行器配置系统设置龙虾/脚本执行器节点配置
18脚本模板管理系统设置Python/Node/Shell 模板库
19定时任务系统设置Cron 调度、执行记录、手动触发
+
+

三、全局业务规则(RB)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
编号规则说明
RB-01角色归属部门角色分"全局角色"与"部门级角色",部门级角色仅在所属部门内可选
RB-02用户多角色取并集用户可绑定多个角色,有效权限 = ∪ 所有角色的权限
RB-03密码规则至少 6 位;重置后原 Token 失效
RB-04删除用户级联用户删除后,角色绑定与平台产品账号一并清除
RB-06菜单权限角色的菜单权限决定左侧导航可见项
RB-07Skill 权限并集任务创建仅展示当前用户所有角色 skill_scope 的并集
RB-08部门切换清空角色用户切换部门时自动清空已选角色(避免权限跨部门带入)
RB-09渠道码必填新建产品时 内导渠道码 与 自然渠道码 均必填
RB-12产品范围交集校验任务可选产品 = Skill.product_scope ∩ 用户角色.data_scope
RB-13执行器类型二选一Skill 的执行器 = 龙虾 Agent 或 脚本模板(互斥);执行器配置仅超级管理员可改
+
+

四、全局交互规范

+

4.1 Toast 反馈

+
    +
  • 成功:绿底绿字(#dcfce7 / #166534),文案格式 ✅ xxx 成功
  • +
  • 错误:红底红字(#fee2e2 / #991b1b),文案格式 ❌ xxx 或 请输入 xxx
  • +
  • 显示时长:2.5 秒自动隐藏,从顶部透明下滑渐现
  • +
+

4.2 Modal 弹窗

+
    +
  • 所有模态框右上角提供 ✕ 关闭
  • +
  • 点击半透明遮罩关闭弹窗
  • +
  • 保存按钮通常位于页脚右侧(主按钮紫色 #6366f1),取消按钮左侧(灰色边框)
  • +
  • 删除确认弹窗独立样式:红色主按钮、🗑️ 图标、说明"此操作不可恢复"
  • +
+

4.3 表格通用

+
    +
  • 表头支持批量选择(复选框)
  • +
  • 行内操作列放在最右:常见为 编辑 / 删除 / 其他
  • +
  • 搜索 + 下拉筛选均实时触发(oninput / onchange → filterT())
  • +
  • 分页栏位于底部:共 X 条 + 页码
  • +
+

4.4 权限与可见性

+
    +
  • 左侧菜单由当前角色的 menu_scope 决定(RB-06)
  • +
  • 按钮级权限由 button_code 决定(如 product:create)
  • +
  • 超级管理员 (super_admin) 可见全部模块
  • +
+
+

五、模块 PRD 详情

+
+

01. 登录页

+

1.1 页面目标

+

通过账号密码(+ 验证码)验证用户身份,登录成功后签发 JWT Token 并跳转仪表盘。

+

1.2 页面结构

+ + + + + + + + + + + + + + + + + + + + + +
区块内容
左侧品牌区产品名 🤖 Agent任务系统、Slogan、4 条核心特性
右侧登录表单标题、欢迎语、用户名 / 密码 / 验证码输入、选项栏、登录按钮、页脚
Toast 反馈区右上角,登录结果提示
+

1.3 表单字段

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填默认校验
用户名 / 邮箱text是admin非空,否则显示"请输入用户名"
密码password是admin123非空,否则显示"请输入密码"
验证码text(4 位)否(前端未强制)空最多 4 位
记住登录checkbox否已勾选—
+

1.4 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
验证码图片A7X2 验证码图点击刷新验证码(随机生成 4 位大写字母数字,排除易混字符)始终可用无
选项栏忘记密码?Toast:请联系管理员重置密码始终可用无
表单页脚登 录1. 前端校验用户名/密码非空;2. 置 disabled + loading;3. 1.5s 后:若 admin/admin123 → Toast ✅ 登录成功!正在跳转... + 1s 后 postMessage('login-success') 通知父容器;否则 → Toast ❌ 用户名或密码错误 + 刷新验证码校验通过后无
+

1.5 交互规则

+
    +
  • 登录接口失败时,清空验证码并刷新
  • +
  • 嵌入 iframe 中时通过 window.parent.postMessage('login-success','*') 通知宿主应用展开侧边栏并跳转仪表盘
  • +
+
+

02. 仪表盘(首页)

+

2.1 页面目标

+

系统运行概览 + 实时数据统计 + 快捷入口,提供用户日常首屏视图。

+

2.2 页面结构

+
    +
  1. 面包屑 + 用户信息 顶栏
  2. +
  3. 快捷入口区:4 个方块(创建任务 / 管理 Skill / 平台账号 / 任务列表)
  4. +
  5. 今日指标卡:6 张卡(今日任务总数 / 成功 / 失败 / 运行中 / 活跃用户 / 已注册 Skill)
  6. +
  7. 图表行 1:最近 7 天任务趋势(柱状图)+ 执行器分布(环形 Legend)
  8. +
  9. 图表行 2:热门 Skill Top 5 + 部门任务统计(进度条)
  10. +
  11. 最新任务动态:任务列表(8 行,ID / Skill / 执行器 / 产品 / 创建人 / 状态 / 耗时 / 时间)
  12. +
  13. 系统状态:4 组(龙虾 / 脚本引擎 / 数据库 / 消息队列)
  14. +
+

2.3 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
快捷入口⚡ 创建任务跳转"10. 任务创建"始终无
快捷入口🧩 管理 Skill跳转"09. Skill 管理"始终无
快捷入口🔗 平台账号跳转"08. 平台产品账号"始终无
快捷入口📋 任务列表跳转"11. 任务列表"始终无
最新任务动态查看全部 →跳转"11. 任务列表"始终无
图表柱子柱子 hover高亮对应柱子并展示数值鼠标悬停无
+

2.4 交互规则

+
    +
  • 柱状图进入视图后自带高度缓动动画(100ms 起)
  • +
  • 每 3 秒模拟一次实时刷新:有 30% 概率 今日任务总数 +1,其中 80% 加到"成功",20% 加到"失败"
  • +
  • 指标卡 hover 显示轻微阴影提升
  • +
+

2.5 提示反馈

+
    +
  • 无负面反馈(只读页面)
  • +
+
+

03. 组织架构

+

3.1 页面目标

+

维护 公司 → 部门 → 小组 三级树形结构,部门节点挂载角色与成员。

+

3.2 页面结构

+
    +
  1. 左栏 · 组织树:搜索框 + 树(含展开/收起、当前选中高亮、新增按钮)
  2. +
  3. 右栏 · 详情面板:组织信息(类型 / 成员数 / 下辖组数 / 负责人 / 状态 / 创建时间)+ 编辑/删除按钮
  4. +
  5. 部门角色列表:角色名称 / 角色编码 / 成员数 / Skill 数 / 状态
  6. +
  7. 部门成员列表:姓名 / 工号 / 所属小组 / 角色 / 入职日期 / 状态 + 导出按钮
  8. +
  9. 弹窗:新增/编辑组织、确认删除
  10. +
+

3.3 数据字段

+
    +
  • 组织树节点:组织名、图标(🏢 公司 / 🏬 部门 / 👥 小组)、成员数徽章
  • +
  • 详情:类型(公司/部门/小组)、成员数、下辖组数、负责人、状态、创建时间
  • +
  • 角色列表列:角色名称、角色编码、成员数、Skill 数、状态
  • +
  • 成员列表列:姓名、工号、所属小组、角色(多标签)、入职日期、状态
  • +
+

3.4 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
组织树卡头+ 新增打开 新增组织 弹窗始终无
树节点▼/▶ 折叠符toggleTree() 切换子节点展开/折叠节点有子项无
树节点节点行selectOrg() 选中该节点、更新右侧详情面板(类型/成员/下辖组/徽章)始终无
详情面板编辑打开 编辑组织 弹窗(标题动态)始终无
详情面板删除打开 ⚠️ 确认删除 弹窗始终有
成员列表导出导出当前部门成员(CSV)始终无
删除弹窗确认删除Toast 已删除,关闭弹窗;子部门/人员自动移至上级始终—
新增/编辑弹窗保存Toast 保存成功,关闭校验通过无
新增/编辑弹窗取消 / ✕关闭不保存始终无
+

3.5 表单字段(新增/编辑组织弹窗)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填默认选项备注
上级组织select否星辰科技有限公司公司/部门下拉根节点无上级
组织名称input是——非空
组织类型select是—公司 / 部门 / 小组必选
负责人select否—用户列表—
排序号number否0——
状态select否启用启用 / 禁用—
备注textarea(2 行)否———
+

3.6 交互规则

+
    +
  • 删除节点时提示"删除后该组织下的子部门和人员将移至上级组织"
  • +
  • 树节点高亮通过 .sel 类控制
  • +
  • 搜索框实时过滤树节点(前端)
  • +
+
+

04. 角色管理

+

4.1 页面目标

+

部门级角色管理,通过 4 个维度配置权限(菜单 + 产品范围 + Skill + 平台账号),支持多角色并集。

+

4.2 页面结构

+
    +
  1. 筛选栏:搜索框(角色名/编码)+ 部门下拉 + 状态下拉 + + 新增角色 按钮
  2. +
  3. 角色列表:表格 10 列(含批量 checkbox)
  4. +
  5. 新增/编辑弹窗:4 个 Tab
  6. +
  7. 业务规则说明卡:RB-01 ~ RB-06
  8. +
+

4.3 列表字段

+

复选框、角色名称、角色编码、所属部门、菜单权限(X 项/全部)、产品范围(X 个/全部)、Skill 数、平台账号(X 个/全部)、成员数、状态、操作

+

4.4 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
筛选栏+ 新增角色打开弹窗,标题 新增角色、表单清空始终无
列表行编辑打开弹窗,标题 编辑角色 — {名},回填每行无
列表行删除删除(超级管理员行隐藏此按钮)每行(非 super_admin)—
弹窗 Tab📋 菜单权限 / 📦 产品范围 / 🧩 Skill 权限 / 🔗 平台账号switchRoleTab(i) 切换弹窗打开时无
菜单权限 Tab全选 checkbox级联选中所有子菜单始终无
弹窗取消 / ✕关闭始终无
弹窗保存Toast 保存成功、关闭必填校验通过无
表头全选 checkbox批量勾选所有行始终无
+

4.5 表单字段(弹窗)

+

基本信息(表单顶部,非 Tab 内)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填选项
角色名称input是—
角色编码input是唯一
所属部门select是投放部 / 运营部 / 技术部 / 产品部
状态select否启用 / 禁用
描述textarea否—
+

Tab 0 菜单权限:菜单树 + 全选 checkbox,一级包含:首页、权限管理、产品管理、Skill 中心、任务中心。

+

Tab 1 产品范围:多选 checkbox,选项 ProductA ~ ProductD。

+

Tab 2 Skill 权限:多选 checkbox,选项:竞品分析 Agent、数据采集脚本、素材生成 Agent、评论监控脚本、投放优化 Agent、价格监控 Agent。

+

Tab 3 平台账号:表格(平台 / 账号 / 状态 / 绑定 checkbox)。

+

4.6 提示反馈

+
    +
  • 成功:保存成功
  • +
  • 失败:输入非空校验不通过时显示 请输入角色名称 等
  • +
+
+

05. 用户管理

+

5.1 页面目标

+

用户 CRUD、部门-角色联动、跨组协作的额外产品授权、多角色绑定。

+

5.2 页面结构

+
    +
  1. 筛选栏:搜索框 + 部门/角色/状态下拉 + 导出 + + 新增用户
  2. +
  3. 用户列表:表格(复选框、用户名、工号、所属部门/分组、角色、平台产品账号数、额外授权数、状态、创建时间、操作)
  4. +
  5. 新增/编辑用户弹窗
  6. +
  7. 重置密码弹窗 / 删除确认弹窗
  8. +
  9. 业务规则说明:RB-02、RB-03、RB-04、RB-08
  10. +
+

5.3 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
筛选栏导出导出用户列表(CSV)始终无
筛选栏+ 新增用户打开新增弹窗(清空)始终无
列表行编辑打开编辑弹窗(回填)每行无
列表行重置密码打开重置密码弹窗每行是(弹窗内)
列表行删除打开删除确认弹窗每行是(弹窗内)
表头全选 checkbox批量选中所有行始终无
编辑弹窗·部门select changeonDeptChange():清空已选角色、刷新角色列表、Toast ⚠️ 已切换部门,角色已清空(RB-08)切换部门—
编辑弹窗·角色多选框展开下拉,勾选/取消角色已选部门无
编辑弹窗+ 添加授权addGrant() 追加一行(产品 select + 有效期 date + 删除按钮)始终无
编辑弹窗·授权行删除移除该授权行行存在无
编辑弹窗取消关闭始终无
编辑弹窗保存校验后 Toast ✅ 保存成功必填通过无
重置密码弹窗确认重置Toast ✅ 密码已重置;原 Token 失效两次密码一致—
删除确认弹窗确认删除Toast ✅ 已删除;级联清除角色绑定与账号始终—
+

5.4 表单字段(新增/编辑)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填默认说明
用户名input是—唯一
密码password是(新增时)—至少 6 位(RB-03)
邮箱email否—格式校验
手机号tel否—格式校验
所属部门select是—tf/yy/tech/pm
状态select否启用启用 / 禁用
绑定角色multi-select是—根据部门动态加载,至少选 1 个
额外产品授权动态行(产品 select + 有效期 date)否空跨部门/小组协作用(RB-02 并集)
+

部门-角色联动字典: +- tf(投放部)→ 投放主管、投放专员、数据分析师 +- yy(运营部)→ 运营主管、运营专员 +- tech(技术部)→ 开发工程师、运维工程师、测试工程师 +- pm(产品部)→ 产品经理、UI 设计师

+

5.5 提示反馈

+
    +
  • 保存成功 / 已删除 / 密码已重置(绿色)
  • +
  • 请输入用户名 / 请至少选择一个角色 / ⚠️ 已切换部门,角色已清空(RB-08)(红色)
  • +
+
+

06. 产品管理

+

6.1 页面目标

+

产品 CRUD、渠道码(内导/自然)必填绑定、CSV 批量导入、多维筛选。

+

6.2 页面结构

+
    +
  1. 统计卡:产品总数 / 启用中 / 已禁用 / 平台数
  2. +
  3. 筛选栏:搜索 + 平台/部门/状态下拉 + 导出 + 📤 导入 + + 新增产品
  4. +
  5. 产品列表:8 列 + 操作
  6. +
  7. 新增/编辑弹窗 / 批量导入弹窗 / 删除确认弹窗
  8. +
+

6.3 列表字段

+

复选框、产品名称、产品代号(唯一)、平台、部门/小组、内导渠道码(如 IC001)、自然渠道码(如 NC001)、负责人、状态

+

6.4 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
筛选栏导出Toast 📥 导出成功始终无
筛选栏📤 导入打开 批量导入产品 弹窗始终无
筛选栏+ 新增产品打开新增弹窗始终无
列表行编辑 / 删除同模式每行删除是
导入弹窗上传区fileInput.click() 触发文件选择始终无
导入弹窗清除文件清空已选文件,禁用 开始导入文件已选无
导入弹窗📥 下载导入模板Toast 📥 模板已下载始终无
导入弹窗开始导入Toast ✅ 成功导入 21 条,覆盖 2 条文件已选无
导入弹窗取消关闭始终无
新增/编辑弹窗保存校验渠道码必填(RB-09),Toast ✅ 保存成功校验通过无
+

6.5 表单字段

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填选项
产品名称input是—
产品代号input是(唯一)—
所属平台select是小红书 / 抖音 / 微信公众号 / 微博 / Facebook / TikTok
所属部门select是投放部 / 运营部 / 技术部
所属小组select否投放一/二组、内容运营组 等
负责人select否用户列表
内导渠道码input是(RB-09)唯一
自然渠道码input是(RB-09)唯一
状态select否启用 / 禁用
+

6.6 提示反馈

+
    +
  • 成功:✅ 保存成功 / ✅ 成功导入 X 条,覆盖 Y 条
  • +
  • 失败:内导渠道码必填(RB-09) / 自然渠道码必填(RB-09)
  • +
+
+

07. 渠道码管理

+

7.1 页面目标

+

按 Tab 分别管理 内导渠道码 与 自然渠道码,支持与产品的双向绑定。

+

7.2 页面结构

+
    +
  1. 统计卡:渠道码总数 / 内导数 / 自然数 / 关联产品数
  2. +
  3. Tab:内导渠道码 | 自然渠道码(互斥显示)
  4. +
  5. 每个 Tab 内:搜索 + 状态下拉 + + 新增 {类型} 渠道码 + 列表
  6. +
  7. 新增/编辑渠道码弹窗 / 绑定产品弹窗 / 删除确认弹窗
  8. +
+

7.3 列表字段

+

渠道码(IC001 代码样式)、渠道名称、类型(内导/自然)、关联产品数、状态、创建时间、操作(编辑 / 绑定 / 删除)

+

7.4 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
Tab内导 / 自然switchTab(i) 切换列表始终无
Tab 头+ 新增内导/自然渠道码打开新增弹窗(type 预设)始终无
列表行编辑 / 绑定 / 删除分别打开对应弹窗每行删除是
绑定弹窗解绑(每行)移除该产品绑定,Toast 已解绑存在绑定无
绑定弹窗绑定将下拉选中产品加入已绑定列表,Toast ✅ 绑定成功选中产品无
绑定弹窗关闭关闭始终无
+

7.5 表单字段(新增/编辑)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填选项
渠道码input是(唯一)—
渠道名称input是—
渠道类型select是内导(internal)/ 自然(natural)
状态select否启用 / 禁用
备注input否—
+

7.6 提示反馈

+
    +
  • ✅ 保存成功 / ✅ 绑定成功 / 已解绑
  • +
  • 请输入渠道码 / 请输入渠道名称
  • +
+
+

08. 平台产品账号

+

8.1 页面目标

+

v2.1 变更:平台产品账号归属 用户(非角色),由用户自行维护各平台登录凭据,支持连通测试。

+

8.2 页面结构

+
    +
  1. 变更说明条(顶部 note)
  2. +
  3. 统计卡:我的账号总数 / 启用中 / 已禁用 / 覆盖平台
  4. +
  5. 筛选栏:搜索 + 平台/状态下拉 + + 添加平台产品账号
  6. +
  7. 账号列表:6 行、8 列
  8. +
  9. 新增/编辑弹窗(含密码加密显示 + 连通测试)
  10. +
  11. 连通测试结果弹窗 / 删除确认弹窗
  12. +
+

8.3 列表字段

+

平台(图标+文字)、平台地址(URL)、产品代号(PROD001)、产品名称、登录账号、登录密码(●●●●●●)、谷歌密钥(●●●●5A2F 部分显示)、状态

+

8.4 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
筛选栏+ 添加平台产品账号打开新增弹窗始终无
列表行编辑打开编辑弹窗,openEdit(plat, code, login) 回填每行无
列表行测试执行连通测试,打开测试结果弹窗(随机成功/失败)每行无
列表行删除打开删除弹窗(有任务引用的账号禁止删除)每行是
弹窗选择平台(select)onPlatChange() 自动填充平台名/地址、按平台过滤产品列表始终—
弹窗👁️ 显示/隐藏密码togglePwd() 切换密码字段 type=password↔text始终无
弹窗🔌 连通测试1.5s 后随机 Toast ✅ 连通成功! 或 连通失败平台/账号已填无
弹窗保存 / 取消保存后 ✅ 保存成功校验通过无
+

8.5 表单字段

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填说明
选择平台select是小红书(xhs) / 抖音(dy) / 微信公众号(wx) / 微博(wb) / Facebook(fb) / TikTok(tt)
平台名称input readonly—随平台联动
平台地址input readonly—随平台联动(如 pro.xiaohongshu.com)
产品代号select是按所选平台 + 用户 data_scope 动态加载
登录账号input是—
登录密码password是👁️ 可切换显隐
谷歌密钥(2FA)password否—
状态select否启用 / 禁用
+

平台信息字典(PLAT_INFO): +- xhs → 小红书 / pro.xiaohongshu.com / 产品 [PROD001, PROD002] +- dy → 抖音 / business.douyin.com / [PROD003, PROD004] +- wx → 微信公众号 / mp.weixin.qq.com / [PROD005] +- wb → 微博 / weibo.com / [PROD008] +- fb → Facebook / business.facebook.com / [PROD006] +- tt → TikTok / ads.tiktok.com / [PROD007]

+

8.6 提示反馈

+
    +
  • ✅ 已加载 {平台}平台信息 / ✅ 保存成功 / ✅ 已删除
  • +
  • ✅ 连通成功! / 连通失败
  • +
  • 请选择平台 / 请选择产品代号
  • +
+
+

09. Skill 管理

+

9.1 页面目标

+

Skill 注册与管理,支持 双执行器(龙虾 Agent / 脚本模板)、产品范围绑定、JSON Schema 参数映射、内置测试台。

+

9.2 页面结构

+
    +
  1. 统计卡:Skill 总数 / 龙虾数 / 脚本数 / 启用中
  2. +
  3. 筛选栏:搜索 + 分类/执行器/状态下拉 + + 注册 Skill
  4. +
  5. Skill 列表:6 行、7 列
  6. +
  7. 注册/编辑 Skill 弹窗:5 Tab(基本信息 / 执行器配置 / 产品范围 / 参数映射 / 测试台)
  8. +
  9. 删除确认弹窗
  10. +
+

9.3 列表字段

+

Skill 名称、分类、执行器(🦞 龙虾 / 📜 脚本)、产品范围(X 个或全部)、调用次数、成功率(带颜色)、状态

+

9.4 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
筛选栏+ 注册 Skill打开弹窗,标题 注册 Skill始终无
列表行编辑 / 删除弹窗回填 / 删除弹窗每行删除是;已被角色引用需先解除
弹窗 Tab5 Tab 切换switchST(i)始终无
Tab 0执行器类型 radio(龙虾/脚本)selExec() 显隐对应配置区(RB-13)必选—
Tab 1 龙虾🔌 测试连通性Toast ✅ 龙虾平台连通成功配置完整无
Tab 1 脚本脚本模板 selectonScriptTpl() 联动脚本类型选中模板—
Tab 1 脚本+ 添加环境变量addEnv() 追加 KV 行脚本模式无
Tab 1 脚本环境变量行·删除移除该行行存在无
Tab 2产品多选框展开下拉,勾选/取消始终无
Tab 3inputSchema textarea可编辑 JSON Schema始终合法 JSON
Tab 3参数映射行为每个字段选择「表达式/常量」始终—
Tab 4测试参数 JSON textarea可编辑始终合法 JSON
Tab 4▶️ 执行测试runTest():每 500ms 追加日志,2.5s 完成始终无
弹窗保存Toast ✅ Skill 已保存校验通过无
+

9.5 表单字段

+

Tab 0 基本信息

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填
Skill 名称input是
分类select否(内容发布 / 数据采集 / 账号运营 / 广告投放 / 数据分析)
描述textarea否
执行器类型radio是(RB-13,二选一)
+

Tab 1 执行器配置 · 龙虾模式

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填默认
API 地址url是https://api.lobster-agent.com/v1
API Keypassword是sk-lobster-xxxx
Agent IDinput是agent_xhs_publish_01
+

Tab 1 执行器配置 · 脚本模式

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填默认
脚本模板select是Python-通用HTTP / Python-数据清洗 / Shell-文件处理 / Node.js-API 调用
环境变量(KV 动态行)动态组否预填 1 行 API_KEY
超时时间(秒)number否30
脚本类型input readonly否联动自填 python/shell/node
+

Tab 2 产品范围:多选 PROD001 ~ PROD006

+

Tab 3 参数映射:JSON Schema + 逐字段映射(表达式/常量)

+

Tab 4 测试台:测试参数 JSON + 日志输出区

+

9.6 提示反馈

+
    +
  • ✅ 龙虾平台连通成功 / ✅ Skill 已保存 / ✅ 已删除
  • +
  • 点击弹窗外关闭所有多选下拉 .ms-drop
  • +
+
+

10. 任务创建(四步向导)

+

10.1 页面目标

+

通过 4 步向导 创建任务:选择 Skill → 选择产品 → 填写参数 → 确认提交,过程包含 RB-07/RB-12 权限与交集校验。

+

10.2 页面结构

+
    +
  1. 步骤条(4 个节点 + 连线)
  2. +
  3. Step 1:Skill 卡片列表(搜索 + 选择)+ 规则提示条
  4. +
  5. Step 2:产品卡片列表(搜索 + 选择)+ 交集提示条 + 渠道码自动填充提示
  6. +
  7. Step 3:参数表单(自动回填 Skill + 产品 + 渠道码)
  8. +
  9. Step 4:确认信息预览 + 校验通过条
  10. +
  11. 成功弹窗:任务 ID + 基本信息 + "继续创建/查看任务列表"
  12. +
+

10.3 关键字段

+
    +
  • Skill 卡:名称、执行器、分类、描述
  • +
  • 产品卡:名称、代号、平台、投放组
  • +
  • 参数表单:产品(只读)、发布内容、标签、发布时间、内导渠道码(只读,自动填充)
  • +
  • 渠道码映射(PROD_CH):PROD001→IC001/NC001 等
  • +
+

10.4 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
Step 1 搜索框输入filterSkills() 实时过滤卡片始终无
Step 1Skill 卡selSkill() 记录选中,解锁下一步始终无
Step 1 右下下一步 →goStep(2):当前步骤 done、下一步 active已选 Skill无
Step 2 搜索框输入filterProds() 实时过滤始终无
Step 2产品卡selProd() 记录选中 + 显示渠道码填充提示交集内产品—
Step 2 右下上一步 / 下一步 →前后切换下一步需已选产品无
Step 3标签输入Enter 键 addTag() 追加标签始终无
Step 3标签 ×删除该标签标签存在无
Step 3 右下上一步 / 下一步 →切换下一步需内容非空无
Step 4 右下🚀 提交任务打开成功弹窗(任务 ID TASK-20260423-001)始终无
成功弹窗继续创建resetWizard() 回到 Step 1、清空所有状态始终无
成功弹窗查看任务列表跳转"11. 任务列表"始终无
成功弹窗✕关闭弹窗始终无
+

10.5 交互规则 / 校验

+
    +
  • Step 1 提示:📋 仅展示您角色 skill_scope 中的 Skill(RB-07),合并所有角色取并集
  • +
  • Step 2 提示:⚠️ 产品范围 = Skill.product_scope ∩ 用户角色.data_scope(RB-12 交集校验)
  • +
  • 渠道码提示:选中产品后出现 ✅ 渠道码已自动填充 — 内导: IC001 · 自然: NC001
  • +
  • Step 4 校验条:✅ Skill 权限校验通过 · 产品范围交集校验通过 · 参数校验通过
  • +
  • 步骤切换使用 .step-ct.show 类控制显示;步骤节点三态:active / done / pending
  • +
+

10.6 提示反馈

+
    +
  • 成功弹窗文案:🎉 任务已提交 + 任务创建成功! + 任务 ID
  • +
  • 搜索占位:🔍 搜索 Skill 名称... / 🔍 搜索产品...
  • +
+
+

11. 任务列表

+

11.1 页面目标

+

展示所有任务,支持状态/执行器/时间范围筛选,提供详情、取消、重试行内操作。

+

11.2 页面结构

+
    +
  1. 统计卡 × 5:任务总数(1,234)/ 成功(1,089)/ 失败(45)/ 运行中(8)/ 超时(92)
  2. +
  3. 筛选栏:搜索(任务 ID/Skill 名)+ 状态/执行器/时间范围下拉 + 导出
  4. +
  5. 列表:任务 ID / Skill / 执行器 / 产品 / 创建人 / 状态 / 耗时 / 创建时间 / 操作
  6. +
  7. 详情弹窗(4 Tab:基本信息 / 输入参数 / 执行结果 / 执行日志)
  8. +
  9. 取消确认弹窗
  10. +
  11. 分页
  12. +
+

11.3 状态枚举

+

SUCCEEDED(绿)/ FAILED(红)/ RUNNING(黄,带 ...)/ PENDING(灰)/ TIMEOUT(橙)

+

11.4 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
筛选栏搜索 / 状态 / 执行器 / 时间范围filterT() 按 data-q / data-st / data-exec 过滤始终无
筛选栏导出Toast 📥 导出成功始终无
列表行详情打开 📋 任务详情 弹窗每行无
列表行取消打开取消确认弹窗 → Toast ✅ 任务已取消仅 RUNNING / PENDING是
列表行重试Toast 🔄 已重新提交仅 FAILED / TIMEOUT无
详情弹窗 Tab基本信息 / 输入参数 / 执行结果 / 执行日志switchDT(i)弹窗打开时无
分页页码切换当前页始终无
取消弹窗取消 / 确认取消关闭 / 向执行器发停止信号始终—
+

11.5 详情弹窗内容

+
    +
  • Tab 0 基本信息:任务 ID、Skill、执行器、产品、创建人、状态(带色标签)、创建时间、耗时
  • +
  • Tab 1 输入参数:JSON 展示(product_id / content / tags / channel_code)
  • +
  • Tab 2 执行结果:JSON 展示(status / post_id / post_url / published_at)
  • +
  • Tab 3 执行日志:深色日志盒,带时间戳 + 彩色级别(状态变更用黄色/绿色)
  • +
+

11.6 按钮显示规则

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
状态显示按钮
SUCCEEDED详情
FAILED详情 + 重试
RUNNING详情 + 取消
PENDING详情 + 取消
TIMEOUT详情 + 重试
+

11.7 提示反馈

+
    +
  • 📥 导出成功 / ✅ 任务已取消 / 🔄 已重新提交
  • +
  • 取消弹窗文案:⚠️ 取消任务 / 确定取消运行中的任务? / 将向执行器发送停止信号
  • +
+
+

12. 菜单管理

+

12.1 页面目标

+

维护系统菜单树(目录/菜单/外链)+ 每个菜单下的按钮权限码。

+

12.2 页面结构

+
    +
  1. 操作栏:+ 新增菜单 / 全部展开 / 全部收起
  2. +
  3. 左侧 菜单树:可展开/收起、行内编辑按钮、点击行切换右侧按钮视图
  4. +
  5. 右侧 按钮管理:标题显示当前菜单名、按钮列表表格、+ 新增按钮
  6. +
  7. 菜单编辑弹窗 / 按钮编辑弹窗 / 删除确认弹窗
  8. +
+

12.3 数据字段

+
    +
  • 菜单树:图标(📁 目录 / 📄 菜单 / 🔗 外链)、菜单名、行内"编辑"链接
  • +
  • 按钮权限表:按钮名称、权限标识(product:create)、类型(primary/default/danger/warning)、状态
  • +
+

按钮权限库(BTN_DATA)示例: +- product:新增 / 编辑 / 删除 / 导入 / 导出 +- role:新增 / 编辑 / 删除 / 复制 +- user:新增 / 编辑 / 删除 / 重置密码 +- skill:注册 / 编辑 / 删除 / 测试 +- channel:新增 / 编辑 / 绑定 +- menu-mgmt:新增菜单 / 编辑菜单 / 新增按钮

+

12.4 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
操作栏+ 新增菜单打开菜单弹窗(清空)始终无
操作栏全部展开 / 全部收起expandAll() / collapseAll() 更新树始终无
菜单树▼/▶toggleTree() 局部折叠节点有子项无
菜单树行点击showBtns(key) 更新右侧按钮表始终无
菜单树编辑(每行)打开菜单弹窗(回填)每行无
按钮表+ 新增按钮打开按钮弹窗(自动填充所属菜单)当前已选菜单无
按钮表编辑(每行)打开按钮弹窗(回填)每行无
按钮表删除(每行)删除确认弹窗 → Toast ✅ 已删除每行是
弹窗保存Toast ✅ 菜单已保存 / ✅ 按钮已保存校验通过无
+

12.5 表单字段

+

菜单编辑弹窗

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填默认说明
菜单类型select是directorydirectory / menu / link;link 时隐藏组件路径与排序
菜单名称input是——
父级菜单select否顶级顶级 / 权限管理 / 产品管理 / …
路由路径input是—形如 /product
图标input否——
前端组件路径input否—link 类型时隐藏
排序号number是1—
是否可见select否显示显示/隐藏
状态select否启用启用/禁用
+

按钮编辑弹窗

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填说明
所属菜单input readonly—自动填充
按钮名称input是—
权限标识input是唯一,形如 product:create
按钮类型select否primary / default / danger / warning
状态select否启用 / 禁用
+

12.6 提示反馈

+
    +
  • ✅ 菜单已保存 / ✅ 按钮已保存 / ✅ 已删除
  • +
  • 删除确认说明:删除需确保无子菜单、无角色引用
  • +
+
+

13. 平台套件

+

13.1 页面目标

+

维护 平台 + 产品代号 两级关系,为"08 平台产品账号"提供选择源。

+

13.2 页面结构

+
    +
  1. 搜索栏:搜索平台名 + + 新增平台
  2. +
  3. 平台卡片网格(主视图):5 个平台卡(小红书/抖音/Facebook/TikTok/微信公众号)
  4. +
  5. 平台详情(点卡片后进入):← 返回 + 平台名 + 编辑/删除 + 信息块 + 产品代号表
  6. +
  7. 平台编辑弹窗 / 产品代号编辑弹窗 / 删除确认弹窗
  8. +
+

13.3 数据字段

+
    +
  • 平台卡:图标+名称、平台地址、产品数、账号数、状态
  • +
  • 详情块:平台地址、状态、关联账号数
  • +
  • 产品代号表:产品代号(PROD001)、产品名称、关联账号数、状态
  • +
+

13.4 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
搜索栏搜索filterCards() 按 data-q 过滤始终无
搜索栏+ 新增平台打开平台弹窗(清空)始终无
平台卡卡片点击showPlatDetail(key) 进入详情视图始终无
详情← 返回hidePlatDetail() 回到列表始终无
详情编辑平台打开平台弹窗(回填名称/地址)始终无
详情删除平台删除确认弹窗 → Toast ✅ 已删除无关联账号是
产品代号表+ 新增产品代号打开产品代号弹窗(自动填充平台)始终无
产品代号表编辑 / 删除(每行)弹窗每行删除是
弹窗保存Toast ✅ 平台已保存 / ✅ 产品代号已保存校验通过无
+

13.5 表单字段

+

平台编辑弹窗

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填
平台名称input是
平台地址input是(https://...)
平台图标input否(emoji 或 URL)
备注textarea否
状态select否(启用/禁用)
+

产品代号编辑弹窗

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填
所属平台input readonly—
产品代号input是(PROD001 格式)
产品名称input是
状态select否
+

13.6 提示反馈

+
    +
  • ✅ 平台已保存 / ✅ 产品代号已保存 / ✅ 已删除
  • +
  • 删除说明:已关联账号的平台/产品需先解除
  • +
+
+

14. Skill 调用日志

+

14.1 页面目标

+

展示全部 Skill 调用历史,支持多维筛选、查看详情与日志回放、CSV 导出。

+

14.2 页面结构

+
    +
  1. 统计卡 × 4:总调用(12,456)/ 成功(12,102)/ 失败(354)/ 平均耗时(2.4s)
  2. +
  3. 筛选栏:搜索(Skill/调用者)+ 状态/执行器/时间范围下拉 + 导出 CSV
  4. +
  5. 列表:调用 ID / Skill / 执行器 / 产品 / 调用者 / 状态 / 耗时 / 时间 / 操作
  6. +
  7. 详情弹窗:4 Tab(基本信息 / 请求参数 / 响应结果 / 执行日志)
  8. +
  9. 分页
  10. +
+

14.3 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
筛选栏搜索 / 状态 / 执行器 / 时间范围filterT()始终无
筛选栏导出 CSVToast 📥 导出成功始终无
列表行详情打开详情弹窗每行无
详情弹窗 Tab切换switchLT(i)弹窗打开无
+

14.4 详情弹窗

+
    +
  • 基本信息:调用 ID(LOG-20260423-001)、Skill、执行器、调用者(含分组)、状态、耗时、时间
  • +
  • 请求参数:JSON
  • +
  • 响应结果:JSON(post_id / post_url 等)
  • +
  • 执行日志:带时间戳、彩色级别的日志盒
  • +
+

14.5 状态枚举

+

SUCCESS / FAILED / TIMEOUT

+

14.6 提示反馈

+
    +
  • 📥 导出成功
  • +
+
+

15. 操作日志(审计)

+

15.1 页面目标

+

记录全局的新增/编辑/删除/登录/导出行为,支持审计追踪与变更对比。

+

15.2 页面结构

+
    +
  1. 筛选栏:搜索(操作人/模块)+ 模块/操作/时间下拉 + 导出
  2. +
  3. 列表:日志 ID(OPLOG-xxx)/ 操作人 / 模块 / 操作 / 描述 / IP / 时间
  4. +
  5. 详情弹窗:基本信息 + 请求参数 JSON + 变更 before/after JSON 对比
  6. +
  7. 分页
  8. +
+

15.3 模块与操作枚举

+
    +
  • 模块:用户管理 / 角色管理 / 产品管理 / Skill 管理 / 任务管理 / 平台产品账号 / 登录
  • +
  • 操作:新增 / 编辑 / 删除 / 登录 / 导出
  • +
+

15.4 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
筛选栏搜索 / 模块 / 操作filterT() 过滤始终无
筛选栏导出Toast 📥 导出成功始终无
列表行详情打开详情弹窗每行无
详情弹窗关闭关闭弹窗始终无
+

15.5 提示反馈

+
    +
  • 📥 导出成功
  • +
+
+

16. 统计报表

+

16.1 页面目标

+

多维度可视化展板:任务趋势、执行器分布、状态分布、Skill/产品/用户排行。

+

16.2 页面结构

+
    +
  1. KPI 卡 × 4:任务总数 / 成功率 / 平均耗时 / 活跃 Skill(含环比)
  2. +
  3. 图表行 1:任务执行趋势(7 日柱状)+ 执行器分布(饼图 SVG)
  4. +
  5. 图表行 2:任务状态分布(柱状)+ Skill 调用 Top 5(排名列表)
  6. +
  7. 图表行 3:产品任务量 Top X + 用户活跃 Top X
  8. +
  9. 控制栏:时间范围下拉 + 刷新 + 导出 PDF
  10. +
+

16.3 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
控制栏时间范围 selectonchange → refreshCharts() 重绘始终无
控制栏刷新refreshCharts() 所有柱子归零 → 50ms 后恢复(动画)始终无
控制栏导出 PDFToast 📥 报表已导出始终无
+

16.4 指标示例

+
    +
  • 任务总数:1,234
  • +
  • 成功率:96.3%
  • +
  • 平均耗时:2.4s
  • +
  • 活跃 Skill:18
  • +
  • 7 日趋势:[156, 189, 145, 201, 178, 210, 155]
  • +
  • 执行器分布:龙虾 67% / 脚本 33%
  • +
  • 状态分布:成功 1089 / 失败 45 / 运行中 8 / 超时 92
  • +
  • Top 5 Skill:数据清洗脚本 5123、竞品数据采集 3401、小红书笔记发布 1256 …
  • +
  • 时间范围选项:最近 7 天 / 最近 30 天 / 本月 / 上月
  • +
+

16.5 提示反馈

+
    +
  • 📥 报表已导出 / 📊 数据已刷新
  • +
+
+

17. 执行器配置

+

17.1 页面目标

+

管理 龙虾 Agent 与 脚本引擎 两个执行器的节点配置、健康监控、并发/超时/重试策略。

+

17.2 页面结构

+
    +
  1. 权限提示条:仅超级管理员可修改(RB-13)
  2. +
  3. 执行器卡片 × 2:龙虾执行器 / 脚本执行器
  4. +
  5. 每卡片包含:状态标签、描述、参数块、当前任务占用(n/N)、最近 24 小时健康度(24 个圆点 timeline)、3 个操作按钮
  6. +
  7. 龙虾配置弹窗 / 脚本配置弹窗
  8. +
+

17.3 卡片字段

+

龙虾执行器:API 地址 / 超时(30s)/ 重试(3 次)/ 并发上限(10)/ 当前 3/10

+

脚本执行器:工作目录 / 超时(60s)/ 并发上限(5)/ 沙箱模式 / 当前 1/5

+

17.4 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
龙虾卡片⚙️ 配置打开龙虾配置弹窗超级管理员无
龙虾卡片🔌 测试Toast ✅ 龙虾平台连通正常始终无
龙虾卡片🔄 重启Toast 🔄 已重启超级管理员无
脚本卡片⚙️ 配置 / 🔌 测试 / 🔄 重启同上(脚本版)同上无
龙虾弹窗🔌 测试连通性Toast ✅ 龙虾平台连通正常始终无
配置弹窗取消关闭始终无
配置弹窗保存Toast ✅ 配置已保存校验通过无
+

17.5 表单字段

+

龙虾配置弹窗

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填默认
API 地址text是示例值
API Keypassword是示例值
超时时间(秒)number否30
重试次数number否3
并发上限number否10
轮询间隔(ms)number否2000
回调 URLtext否示例值
+

脚本配置弹窗

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填默认
工作目录text是—
Python 路径text否/usr/bin/python3
Node 路径text否/usr/bin/node
全局超时(秒)number否60
并发上限number否5
沙箱模式select否Docker 容器 / 本地进程 / 虚拟环境
日志目录text否—
+

17.6 交互规则

+
    +
  • initHealth(id) 生成 24 个小圆点,随机展示成功/异常状态,悬停显示 tooltip
  • +
+

17.7 提示反馈

+
    +
  • ✅ 龙虾平台连通正常 / ✅ 脚本引擎正常 / 🔄 已重启 / ✅ 配置已保存
  • +
+
+

18. 脚本模板管理

+

18.1 页面目标

+

维护脚本执行器使用的预置模板,支持 Python/Node.js/Shell 代码编辑 + 环境变量声明 + Skill 引用追踪。

+

18.2 页面结构

+
    +
  1. 筛选栏:搜索(模板名)+ 脚本类型下拉 + + 新增模板
  2. +
  3. 模板列表:模板名 / 脚本类型 / 环境变量(逗号列表)/ 引用 Skill 数 / 状态 / 更新时间
  4. +
  5. 新增/编辑弹窗:表单 + 代码编辑器(code-area)+ 环境变量动态行
  6. +
  7. 删除确认弹窗
  8. +
+

18.3 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
筛选栏搜索 / 脚本类型filterT()始终无
筛选栏+ 新增模板打开弹窗(清空)始终无
列表行编辑打开弹窗(回填)每行无
列表行删除删除确认弹窗每行是(需先解除 Skill 引用)
弹窗·代码区格式化Toast ✅ 代码已格式化始终无
弹窗·环境变量+ 添加变量追加变量行始终无
弹窗取消 / 保存关闭 / Toast ✅ 模板已保存校验通过无
+

18.4 表单字段

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填说明
模板名称input是placeholder 如 通用 HTTP 请求
脚本类型select是Python / Node.js / Shell(切换时联动示例代码)
描述textarea(2 行)否—
脚本代码textarea(大)是类型切换时预填示例模板(CODE 字典)
环境变量声明动态表否每行:变量名(只读)/ 描述 / 必填(select 必填/可选)
状态select否启用 / 禁用
+

18.5 交互规则

+
    +
  • 切换脚本类型 → onTplType() 自动填充示例代码(Python/Node/Shell)
  • +
  • 动态添加环境变量行:appendChild 到 envList
  • +
+

18.6 提示反馈

+
    +
  • ✅ 代码已格式化 / ✅ 模板已保存 / ✅ 已删除
  • +
  • 删除前警告:已被 Skill 引用的模板需先解除关联
  • +
+
+

19. 定时任务

+

19.1 页面目标

+

管理 Cron 定时调度任务,关联 Skill + 产品 + 参数,支持执行记录查看、启停、手动触发。

+

19.2 页面结构

+
    +
  1. 统计卡 × 4:定时任务(8)/ 运行中(6)/ 已暂停(2)/ 7 日成功率(94.2%)
  2. +
  3. 筛选栏:搜索 + 状态下拉 + + 新增定时任务
  4. +
  5. 列表:任务名 / Skill / Cron 表达式 / 产品 / 下次执行 / 最近 7 次(timeline)/ 状态 / 开关
  6. +
  7. 新增/编辑弹窗(含 Cron 解析)
  8. +
  9. 执行记录弹窗 / 删除确认弹窗
  10. +
+

19.3 操作细节

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置按钮行为条件二次确认
筛选栏搜索 / 状态filterT()始终无
筛选栏+ 新增定时任务打开弹窗(清空)始终无
列表行编辑打开弹窗(回填)每行无
列表行记录打开执行记录弹窗(展示最近 5 次)每行无
列表行触发Toast ▶️ 手动触发成功每行无
列表行删除删除确认弹窗 → Toast ✅ 已删除每行是
列表行开关 toggle启用/暂停切换,同步状态标签;Toast ✅ 已启用 / ⏸️ 已暂停每行无
弹窗保存Toast ✅ 定时任务已保存校验通过无
+

19.4 表单字段

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必填默认选项/说明
任务名称input是—如 每日竞品数据采集
关联 Skillselect是—竞品数据采集 / 小红书笔记发布 / 数据清洗脚本 / 抖音视频上传 / Facebook广告投放
关联产品select是—PROD001 / PROD002 / PROD003 / 全部产品
Cron 表达式input是0 8 * * *—
帮助文本动态文本—实时解析下述对照表
执行参数(JSON)textarea(3 行)否示例 JSON必须为合法 JSON
失败重试select否—重试 3 次 / 重试 1 次 / 不重试
超时(秒)number否120—
状态select否启用启用 / 暂停
+

Cron 解析对照: +- 0 8 * * * → 📅 每天 08:00 执行 +- 0 */2 * * * → 📅 每 2 小时执行 +- 0 9 * * 1 → 📅 每周一 09:00 执行 +- 0 0 1 * * → 📅 每月 1 号 00:00 执行 +- 30 10 * * * → 📅 每天 10:30 执行 +- 0 18 * * 5 → 📅 每周五 18:00 执行 +- 0 */4 * * * → 📅 每 4 小时执行 +- 其他 → 📅 自定义表达式: {输入值}

+

19.5 执行记录弹窗

+

表格:执行时间(YYYY-MM-DD HH:MM:SS)/ 状态(成功/失败 标签)/ 耗时(s)/ 结果文本

+

19.6 交互规则

+
    +
  • Cron 表达式输入实时解析 → parseCron(value) 更新帮助文本
  • +
  • 开关 toggle:启用 → tg tg-ok 运行中;暂停 → tg tg-w 已暂停;同步更新 data-st
  • +
  • initTimelines() 为每行生成 7 个圆点(近 7 日执行成功/失败)
  • +
+

19.7 提示反馈

+
    +
  • ✅ 定时任务已保存 / ▶️ 手动触发成功 / ✅ 已删除 / ✅ 已启用 / ⏸️ 已暂停
  • +
+
+

六、待完善 / 开发注意事项

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
项说明
数据权限隔离后端需严格按 RB-07/RB-12 做 Skill 权限并集 + 产品范围交集校验,前端仅展示性过滤
密码与密钥加密平台账号密码、API Key、谷歌 2FA 密钥需服务端加密存储(AES),仅返回掩码
删除前置校验用户/Skill/平台/产品/模板/菜单删除前都需检查引用关系,前端原型已提示
导入文件格式产品批量导入需约定 CSV/Excel 模板字段与校验规则
日志规模操作日志、Skill 调用日志量级大,需分库或冷热分层存储
权限按钮码button_code 需跟前端按钮统一字典,前后端共享枚举
执行器选择RB-13 执行器类型互斥,后端需校验保存时 executor_type 只能设置一份配置
时区Cron 执行、日志时间建议统一使用服务器本地时区 + UTC 存储,前端按当前用户时区展示
二次确认统一所有删除/取消类操作都需弹窗确认(本原型已遵循)
响应式支持登录页已做移动端隐藏左栏,其他后台页默认 PC 端 1280+,移动端不在当前版本范围
+
+

文档结束。 本 PRD 覆盖 19 个原型页面、按钮级操作细节、字段清单与业务规则,可直接用于开发排期、测试用例编写与原型评审。

+
+
+ + + + diff --git a/docs/Agent任务系统_架构设计方案.html b/docs/Agent任务系统_架构设计方案.html new file mode 100644 index 0000000..9186372 --- /dev/null +++ b/docs/Agent任务系统_架构设计方案.html @@ -0,0 +1,984 @@ + + + + + +Agent任务系统 · 架构设计方案 + + + +
+ +

🤖 Agent任务系统 · 架构设计方案

+
+ 版本 v1.0 | 2026-04-23 | 前后端分离架构
+ 技术栈:React + FastAPI + PostgreSQL + Redis + Celery +
+ + + + + +

一、架构选型分析

+ +
+

1.1 需求特点

+

从功能清单来看,这是一个中等复杂度的企业级SaaS应用:

+ + + + + + + +
需求维度特点描述
权限系统部门级RBAC、多角色权限并集、产品/Skill双重范围校验
任务执行脚本执行器异步执行任务,支持Python/Shell
LLM集成多Provider路由、Token统计、成本计算
实时通知SSE/Telegram推送
部署要求前后端分离式独立部署
+
+ +
+

1.2 推荐架构

+

前后端分离 + 分层后端 + 模块化设计

+ + + + + + + +
架构特点说明
前后端分离React SPA + FastAPI REST API,独立部署
后端分层API层 → Service层 → Repository层
异步任务Celery + Redis 实现脚本异步执行
缓存层Redis 缓存权限数据、Session
容器化Docker Compose 一键部署
+
+ + +

二、技术栈选型

+ +
+

2.1 前端技术栈 React

+ + + + + + + + + + + +
组件技术选型说明
框架React 18 + TypeScript类型安全,生态成熟
构建工具Vite 5快速启动,热更新快
UI组件库Ant Design 5企业级组件,RBAC友好
状态管理Zustand轻量,比Redux简洁
路由React Router 6路由守卫+权限控制
表单react-jsonschema-form动态表单渲染
HTTPAxios + React Query请求拦截、缓存
图表@ant-design/charts用量统计图表
CSSTailwind CSS工具类,快速开发
+
+ +
+

2.2 后端技术栈 Python FastAPI

+ + + + + + + + + + + +
组件技术选型说明
Web框架FastAPI异步、高性能、自动API文档
ORMSQLAlchemy 2.0 + asyncpg异步数据库操作
认证Python-Jose + PasslibJWT签发/验证
验证Pydantic v2数据校验
任务队列Celery + Redis异步脚本执行
定时任务Celery BeatCron任务调度
缓存Redis会话、权限缓存
加密CryptographyAES-256 API Key加密
LLM SDKOpenAI SDK + 各Provider SDK统一接口封装
+
+ +
+

2.3 基础设施

+ + + + + + + + +
组件技术选型说明
数据库PostgreSQL 16JSON支持,复杂查询
缓存/队列Redis 7多用途
对象存储MinIO / S3脚本模板存储
反向代理Nginx静态资源、负载均衡
容器化Docker + Docker Compose开发/生产环境
CI/CDGitHub Actions自动构建部署
+
+ + +

三、系统架构图

+ +
+
+┌─────────────────────────────────────────────────────────────────────────────┐
+│                               客户端层                                       │
+│  ┌─────────────────────────┐              ┌─────────────────────────────┐   │
+│  │      React SPA           │              │      Telegram Bot           │   │
+│  │    (Web管理后台)         │              │      (消息通知)             │   │
+│  │  ┌─────────────────────┐ │              │  /start /help /task         │   │
+│  │  │   Ant Design UI     │ │              └──────────────┬──────────────┘   │
+│  │  │   Zustand Store     │ │                             │                  │
+│  │  │   React Router      │ │                             │  Bot API         │
+│  │  └─────────────────────┘ │                             │                  │
+│  └─────────────┬───────────┘                             │                  │
+└────────────────┼──────────────────────────────────────────┼──────────────────┘
+                 │ HTTPS                                     │
+┌────────────────▼──────────────────────────────────────────▼──────────────────┐
+│                         API网关层 (Nginx)                                    │
+│  • SSL终结                     • 静态资源服务 (Web)                          │
+│  • API路由转发                  • 请求限流/鉴权                               │
+│  • WebSocket代理               • SSE支持                                     │
+└─────────────────────────────────────────────────────────────────┬────────────┘
+                                                                  │
+┌─────────────────────────────────────────────────────────────────▼────────────┐
+│                           后端服务层 (Python FastAPI)                              │
+│  ┌───────────────────────────────────────────────────────────────────────────┐ │
+│  │                         FastAPI Application                              │ │
+│  │  ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐            │ │
+│  │  │  Auth   │ │  User   │ │  Role   │ │  Menu   │ │ Product │            │ │
+│  │  │ Module  │ │ Module  │ │ Module  │ │ Module  │ │ Module  │            │ │
+│  │  └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘            │ │
+│  │  ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐            │ │
+│  │  │  Skill  │ │  Task   │ │   LLM   │ │Platform │ │Schedule │            │ │
+│  │  │ Module  │ │ Module  │ │ Module  │ │ Account │ │ Module  │            │ │
+│  │  └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘            │ │
+│  │       └──────────┬┴───────────┬┴───────────┬┴──────────┬┘               │ │
+│  │  ┌─────────────────────────┴─────────────────────────────────────────┐  │ │
+│  │  │                    Core Services (权限/加密/缓存)                  │  │ │
+│  │  └────────────────────────────────────────┬────────────────────────┘  │ │
+│  └───────────────────────────────────────────┼────────────────────────────┘ │
+└──────────────────────────────────────────────┼──────────────────────────────┘
+                                               │
+┌──────────────────────────────────────────────▼──────────────────────────────┐
+│                           基础设施层                                         │
+│                                                                             │
+│   ┌──────────────┐   ┌──────────────┐   ┌──────────────────────────────┐   │
+│   │  PostgreSQL  │   │    Redis     │   │    Script Executor           │   │
+│   │  (主数据库)   │   │  (缓存/队列)  │   │    (Celery Worker)          │   │
+│   │              │   │              │   │  ┌────────────────────────┐ │   │
+│   │  • 用户数据   │   │  • 会话缓存   │   │  │  Python/Shell 执行     │ │   │
+│   │  • 权限数据   │   │  • 权限缓存   │   │  │  沙箱隔离执行           │ │   │
+│   │  • 任务记录   │   │  • Celery    │   │  │  超时控制               │ │   │
+│   │  • LLM用量   │   │    Broker    │   │  └────────────────────────┘ │   │
+│   └──────────────┘   └──────────────┘   └──────────────────────────────┘   │
+│                                                                             │
+│   ┌──────────────┐   ┌──────────────┐   ┌──────────────────────────────┐   │
+│   │    MinIO     │   │  Celery Beat │   │      Telegram Bot            │   │
+│   │  (文件存储)   │   │  (定时任务)   │   │      (通知推送)              │   │
+│   └──────────────┘   └──────────────┘   └──────────────────────────────┘   │
+└─────────────────────────────────────────────────────────────────────────────┘
+
+
+ + +

四、后端模块设计

+ +
+

4.1 项目目录结构

+
+
+agent-system-backend/
+├── app/
+│   ├── __init__.py
+│   ├── main.py                 # FastAPI 应用入口
+│   ├── config.py               # 配置管理 (Pydantic Settings)
+│   │
+│   ├── api/                    # API 路由层
+│   │   ├── __init__.py
+│   │   ├── deps.py             # 依赖注入 (get_db, get_current_user)
+│   │   └── v1/
+│   │       ├── __init__.py
+│   │       ├── auth.py         # 认证接口
+│   │       ├── users.py        # 用户管理
+│   │       ├── roles.py        # 角色管理
+│   │       ├── menus.py        # 菜单管理
+│   │       ├── products.py     # 产品管理
+│   │       ├── skills.py       # Skill管理
+│   │       ├── tasks.py        # 任务管理
+│   │       ├── llm.py          # LLM管理
+│   │       ├── platform_accounts.py  # 平台账号
+│   │       ├── schedules.py    # 定时任务
+│   │       └── logs.py         # 日志
+│   │
+│   ├── core/                   # 核心模块
+│   │   ├── security.py         # JWT/加密工具
+│   │   ├── permission.py       # 权限校验服务
+│   │   ├── database.py         # 数据库连接
+│   │   └── cache.py            # Redis缓存
+│   │
+│   ├── models/                 # SQLAlchemy 模型
+│   │   ├── base.py             # 基础模型 (timestamp, uuid)
+│   │   ├── user.py             # 用户、角色、组织架构
+│   │   ├── menu.py             # 菜单、按钮
+│   │   ├── product.py          # 产品、渠道码
+│   │   ├── skill.py            # Skill、脚本模板
+│   │   ├── task.py             # 任务
+│   │   ├── llm.py              # LLM供应商、模型、路由、用量
+│   │   ├── platform_account.py # 平台账号
+│   │   └── schedule.py         # 定时任务
+│   │
+│   ├── schemas/                # Pydantic 请求/响应模型
+│   │   ├── auth.py             # LoginRequest, Token
+│   │   ├── user.py             # UserCreate, UserUpdate, UserResponse
+│   │   ├── role.py             # RoleCreate, RolePermissionUpdate
+│   │   ├── task.py             # TaskCreate, TaskResponse
+│   │   └── ...
+│   │
+│   ├── services/               # 业务逻辑层
+│   │   ├── auth_service.py     # 登录、Token刷新
+│   │   ├── user_service.py     # 用户CRUD
+│   │   ├── role_service.py     # 角色CRUD
+│   │   ├── permission_service.py # 权限计算核心
+│   │   ├── skill_service.py    # Skill管理
+│   │   ├── task_service.py     # 任务创建/执行
+│   │   ├── llm_service.py      # LLM路由+调用+用量
+│   │   ├── script_executor.py # 脚本执行器
+│   │   └── platform_account_service.py
+│   │
+│   └── utils/                  # 工具函数
+│       ├── encrypt.py         # AES加密 (API Key)
+│       ├── llm_providers.py    # 各Provider SDK封装
+│       └── exceptions.py       # 自定义异常
+│
+│   └── celery_app/             # Celery 配置
+│       ├── celery.py          # Celery实例
+│       └── tasks/
+│           ├── script_tasks.py   # 脚本执行任务
+│           └── schedule_tasks.py # 定时任务
+│
+├── tests/                      # 测试
+├── scripts/
+│   └── init_db.py             # 数据库初始化脚本
+├── alembic/                    # 数据库迁移
+├── Dockerfile                  # API服务镜像
+├── Dockerfile.worker           # Worker镜像
+├── docker-compose.yml
+├── requirements.txt
+└── .env.example
+
+
+
+ +
+

4.2 核心模块详解

+ +

4.2.1 权限服务 (Permission Service)

+

实现 v2.2 的三要素权限模型:菜单/按钮权限 + 产品范围 + Skill权限

+
+class PermissionService:
+    """权限服务 - 实现部门级RBAC三要素模型"""
+    
+    async def get_user_effective_permissions(self, user_id: int) -> EffectivePermissions:
+        """
+        计算用户有效权限 = 所有角色权限并集
+        RB-04: 多角色权限取并集
+        """
+        user = await self.user_repo.get_with_roles(user_id)
+        
+        # 合并所有角色的菜单/按钮权限
+        menu_permissions = set()
+        button_permissions = set()
+        data_scopes = []
+        skill_scopes = []
+        
+        for role in user.roles:
+            # 仅计算同部门角色 (RB-02)
+            if role.org_id != user.org_id:
+                continue
+            menu_permissions.update(role.menus)
+            button_permissions.update(role.buttons)
+            data_scopes.append(role.data_scope)
+            skill_scopes.append(role.skill_scope)
+        
+        return EffectivePermissions(
+            menus=list(menu_permissions),
+            buttons=list(button_permissions),
+            data_scope=self._merge_data_scope(data_scopes),
+            skill_scope=list(set().union(*skill_scopes)),
+            platform_accounts=user.platform_accounts  # 用户直接拥有 (RB-15)
+        )
+    
+    async def verify_task_permission(
+        self, 
+        user_id: int, 
+        skill_id: int, 
+        product_id: int
+    ) -> bool:
+        """
+        任务创建权限校验
+        RB-12: Skill权限 + 产品范围交集双重校验
+        """
+        perms = await self.get_user_effective_permissions(user_id)
+        
+        # 校验1: Skill权限 (RB-07)
+        if skill_id not in perms.skill_scope:
+            raise PermissionDeniedError("无权使用此Skill")
+        
+        # 校验2: 产品范围交集 (RB-12)
+        skill = await self.skill_repo.get(skill_id)
+        allowed_products = (
+            set(perms.data_scope.product_ids) 
+            & set(skill.product_scope.product_ids)
+        )
+        if product_id not in allowed_products:
+            raise PermissionDeniedError("该Skill不可操作此产品")
+        
+        return True
+
+ +

4.2.2 LLM服务 (LLM Service)

+

实现多Provider统一管理和智能路由

+
+class LLMService:
+    """LLM服务 - 多Provider统一管理"""
+    
+    async def route_model(self, scenario: str, product_id: int, model_type: str) -> LLMModel:
+        """
+        模型路由:根据条件匹配最佳模型
+        RB-20: 按优先级匹配,无匹配用默认模型
+        """
+        # 查询匹配规则
+        rules = await self.routing_rule_repo.get_matching_rules(
+            scenario=scenario,
+            product_id=product_id,
+            model_type=model_type
+        )
+        
+        if rules:
+            return await self.model_repo.get(rules[0].target_model_id)
+        
+        # 无匹配规则,使用默认模型
+        default = await self.config_repo.get("default_llm_model")
+        return await self.model_repo.get(default)
+    
+    async def chat(self, user_id: int, params: ChatParams) -> ChatResponse:
+        """
+        LLM调用入口
+        RB-21: 自动记录用量
+        """
+        # 1. 路由匹配
+        model = await self.route_model(params.scenario, params.product_id, params.model_type)
+        
+        # 2. 获取Provider API Key (解密 RB-18)
+        provider = await self.provider_repo.get(model.provider_id)
+        api_key = self.encrypt_service.decrypt(provider.api_keys)
+        
+        # 3. 调用LLM
+        client = self._get_provider_client(provider.provider_type)
+        response = await client.chat(
+            base_url=provider.base_url,
+            api_key=api_key,
+            model=model.model_name,
+            messages=params.messages,
+            **model.default_params
+        )
+        
+        # 4. 记录用量 (RB-21, RB-22)
+        await self.usage_service.record(
+            user_id=user_id,
+            model_id=model.model_id,
+            input_tokens=response.usage.input_tokens,
+            output_tokens=response.usage.output_tokens,
+            latency=response.latency
+        )
+        
+        return response
+
+ +

4.2.3 脚本执行器 (Script Executor)

+

支持Python/Shell多类型脚本,RB-13/RB-14/RB-17

+
+class ScriptExecutorService:
+    """脚本执行器 - 支持Python/Shell多类型"""
+    
+    async def execute(
+        self, 
+        template_id: int, 
+        params: dict,
+        user_env: dict
+    ) -> ExecutionResult:
+        """
+        执行脚本
+        RB-13/14/17: 脚本沙箱隔离执行
+        """
+        # 1. 获取脚本模板
+        template = await self.template_repo.get(template_id)
+        
+        # 2. 渲染脚本 (注入参数和环境变量)
+        script = self._render_script(template, params, user_env)
+        
+        # 3. 提交Celery任务
+        task = execute_script_task.delay(
+            script=script,
+            script_type=template.script_type,
+            timeout=template.default_timeout,
+            work_dir=self.config.script_work_dir
+        )
+        
+        return ExecutionResult(task_id=task.id, status="PENDING")
+
+@celery_app.task(bind=True, max_retries=3)
+def execute_script_task(self, script: str, script_type: str, timeout: int, work_dir: str):
+    """独立进程执行脚本 - 沙箱隔离"""
+    import subprocess
+    
+    result = subprocess.run(
+        ["python" if script_type == "python" else "bash", "-c", script],
+        capture_output=True,
+        text=True,
+        timeout=timeout,  # 超时控制
+        cwd=work_dir,
+        env={**os.environ, **user_env}  # 环境变量隔离
+    )
+    
+    return {
+        "stdout": result.stdout,
+        "stderr": result.stderr,
+        "returncode": result.returncode
+    }
+
+
+ + +

五、前端模块设计

+ +
+

5.1 项目目录结构

+
+
+agent-system-frontend/
+├── src/
+│   ├── api/                    # API 请求层
+│   │   ├── request.ts           # Axios 实例 (拦截器)
+│   │   ├── auth.ts              # 登录、Token
+│   │   ├── user.ts              # 用户管理
+│   │   ├── role.ts             # 角色管理
+│   │   ├── skill.ts             # Skill管理
+│   │   ├── task.ts              # 任务管理
+│   │   ├── llm.ts               # LLM管理
+│   │   └── ...
+│   │
+│   ├── components/             # 组件库
+│   │   ├── Common/             # 通用组件 (PageHeader, EmptyState)
+│   │   ├── Form/               # 表单组件 (DynamicForm, ProductSelector)
+│   │   ├── Table/              # 表格组件 (ProTable)
+│   │   └── Layout/             # 布局组件 (AppLayout)
+│   │
+│   ├── pages/                   # 页面
+│   │   ├── Login/               # 登录页
+│   │   ├── Dashboard/          # 首页仪表盘
+│   │   ├── System/             # 系统管理
+│   │   │   ├── Users/          # 用户管理
+│   │   │   ├── Roles/          # 角色管理
+│   │   │   ├── Menus/          # 菜单管理
+│   │   │   └── Orgs/           # 组织架构
+│   │   ├── Product/             # 产品管理
+│   │   ├── Skill/               # Skill中心
+│   │   ├── Task/               # 任务中心
+│   │   ├── LLM/                # 大模型管理
+│   │   │   ├── Providers/      # 供应商管理
+│   │   │   ├── Models/          # 模型配置
+│   │   │   ├── Routing/        # 路由规则
+│   │   │   └── Usage/          # 用量统计
+│   │   └── Settings/           # 系统设置
+│   │
+│   ├── hooks/                  # React Hooks
+│   │   ├── usePermission.ts    # 权限判断
+│   │   ├── useUser.ts          # 用户状态
+│   │   └── useQueryParams.ts
+│   │
+│   ├── stores/                 # Zustand 状态
+│   │   ├── authStore.ts        # 认证状态
+│   │   ├── permissionStore.ts  # 权限缓存
+│   │   └── settingsStore.ts
+│   │
+│   ├── types/                  # TypeScript类型
+│   │   ├── api.d.ts            # API响应类型
+│   │   └── model.d.ts          # 数据模型类型
+│   │
+│   ├── router/                 # 路由配置
+│   │   └── index.tsx           # React Router + 路由守卫
+│   │
+│   └── App.tsx
+│
+├── package.json
+├── vite.config.ts
+├── tsconfig.json
+└── tailwind.config.js
+
+
+
+ +
+

5.2 权限控制实现

+
+// hooks/usePermission.ts
+export function usePermission() {
+  const { permissions } = useUserStore();
+  
+  const can = (action: string) => {
+    return permissions.buttons.includes(action);
+  };
+  
+  const canAccessMenu = (menuPath: string) => {
+    return permissions.menus.some(m => m.path === menuPath);
+  };
+  
+  const canUseSkill = (skillId: number) => {
+    return permissions.skillScope.includes(skillId);
+  };
+  
+  const canAccessProduct = (productId: number) => {
+    return permissions.dataScope.productIds.includes(productId);
+  };
+  
+  return { can, canAccessMenu, canUseSkill, canAccessProduct };
+}
+
+// 路由守卫
+function PrivateRoute({ children, requiredPermission }: Props) {
+  const { can } = usePermission();
+  const location = useLocation();
+  
+  if (!hasAuth()) {
+    return ;
+  }
+  
+  if (requiredPermission && !can(requiredPermission)) {
+    return ;
+  }
+  
+  return children;
+}
+
+// Skill选择器 - 只显示有权限的Skill
+function SkillSelector() {
+  const { canUseSkill } = usePermission();
+  const { data: skills } = useQuery(['skills'], fetchSkills);
+  
+  // 过滤:只显示用户有权限的Skill
+  const availableSkills = skills?.filter(s => canUseSkill(s.id)) || [];
+  
+  return (
+    
+  );
+}
+
+// 产品选择器 - 只显示在交集范围内的产品
+function ProductSelector({ skillId }: { skillId: number }) {
+  const { canAccessProduct } = usePermission();
+  const { data: products } = useQuery(['products'], fetchProducts);
+  
+  // RB-12: Skill.product_scope ∩ 用户角色.data_scope
+  const availableProducts = products?.filter(p => canAccessProduct(p.id)) || [];
+  
+  return (
+    
+  );
+}
+
+
+ + +

六、部署架构

+ +
+

6.1 Docker Compose 配置

+
+# docker-compose.yml
+
+version: '3.8'
+
+services:
+  # 前端 (Nginx 托管静态资源)
+  frontend:
+    build:
+      context: ./frontend
+      dockerfile: Dockerfile
+    ports:
+      - "80:80"
+      - "443:443"
+    depends_on:
+      - backend
+    networks:
+      - agent-network
+
+  # 后端 API
+  backend:
+    build:
+      context: ./backend
+      dockerfile: Dockerfile
+    ports:
+      - "8000:8000"
+    environment:
+      - DATABASE_URL=postgresql://postgres:${DB_PASSWORD}@db:5432/agent_system
+      - REDIS_URL=redis://redis:6379/0
+      - SECRET_KEY=${SECRET_KEY}
+      - ENCRYPT_KEY=${ENCRYPT_KEY}
+    depends_on:
+      - db
+      - redis
+    networks:
+      - agent-network
+
+  # Celery Worker (脚本执行)
+  celery-worker:
+    build:
+      context: ./backend
+      dockerfile: Dockerfile.worker
+    command: celery -A celery_app worker -l info
+    volumes:
+      - script-data:/app/scripts
+    depends_on:
+      - redis
+      - backend
+    networks:
+      - agent-network
+
+  # Celery Beat (定时任务调度器)
+  celery-beat:
+    build:
+      context: ./backend
+      dockerfile: Dockerfile.worker
+    command: celery -A celery_app beat -l info
+    depends_on:
+      - redis
+    networks:
+      - agent-network
+
+  # PostgreSQL 数据库
+  db:
+    image: postgres:16-alpine
+    environment:
+      POSTGRES_DB: agent_system
+      POSTGRES_PASSWORD: ${DB_PASSWORD}
+    volumes:
+      - postgres-data:/var/lib/postgresql/data
+    ports:
+      - "5432:5432"
+    networks:
+      - agent-network
+
+  # Redis (缓存 + 消息队列)
+  redis:
+    image: redis:7-alpine
+    volumes:
+      - redis-data:/data
+    ports:
+      - "6379:6379"
+    networks:
+      - agent-network
+
+volumes:
+  postgres-data:
+  redis-data:
+  script-data:
+
+networks:
+  agent-network:
+    driver: bridge
+
+
+ +
+

6.2 Nginx 配置

+
+# nginx/nginx.conf
+
+events {
+    worker_connections 1024;
+}
+
+http {
+    include /etc/nginx/mime.types;
+    
+    upstream backend {
+        server backend:8000;
+    }
+    
+    server {
+        listen 443 ssl;
+        server_name your-domain.com;
+        
+        ssl_certificate /etc/nginx/ssl/cert.pem;
+        ssl_certificate_key /etc/nginx/ssl/key.pem;
+        
+        # 静态资源 (前端)
+        location / {
+            root /usr/share/nginx/html;
+            try_files $uri $uri/ /index.html;
+        }
+        
+        # API 代理
+        location /api/ {
+            proxy_pass http://backend;
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+            
+            # SSE 支持
+            proxy_buffering off;
+            proxy_cache off;
+            proxy_read_timeout 86400s;
+        }
+        
+        # WebSocket (实时日志)
+        location /ws/ {
+            proxy_pass http://backend;
+            proxy_http_version 1.1;
+            proxy_set_header Upgrade $http_upgrade;
+            proxy_set_header Connection "upgrade";
+        }
+    }
+}
+
+
+ + +

七、数据流设计

+ +
+

7.1 任务执行流程

+
+用户提交任务 +→ +POST /tasks +→ +Permission Service +→ +校验Skill权限 +→ +校验产品交集 +
+
+任务入库 +→ +获取平台账号 +→ +Celery任务入队 +→ +Worker执行脚本 +
+
+LLM调用(如需要) +→ +记录用量 +→ +SSE推送结果 +→ +Telegram通知 +
+
+ +
+

7.2 LLM调用流程

+
+Skill执行请求 +→ +LLM路由匹配 +→ +获取API Key +→ +调用Provider +
+
+记录Token用量 +→ +计算成本 +→ +返回结果 +
+
+ + +

八、安全设计

+ +
+ + + + + + + + + + +
安全项实现方案对应规则
API Key加密AES-256-GCM加密存储,密钥由环境变量注入RB-18
JWT认证Access Token(7天) + Refresh Token(30天)-
密码存储bcrypt哈希-
权限校验每次请求校验按钮级别权限RB-05
数据范围SQL级别行级过滤(product_scope)RB-06, RB-12
脚本隔离独立进程+超时控制+环境变量隔离RB-17
敏感信息日志脱敏、API响应过滤-
部门隔离角色归属部门,用户只能绑本部门角色RB-01, RB-02
+
+ + +

九、开发里程碑

+ +
+
+
+Phase 1: 项目骨架 + 认证 + 组织架构 2周 +
    +
  • 项目脚手架搭建 (FastAPI + React)
  • +
  • 数据库设计 + Alembic迁移
  • +
  • JWT认证模块
  • +
  • 组织架构管理 (公司→部门→小组)
  • +
  • 基础RBAC (菜单/按钮权限)
  • +
+
+ +
+Phase 2: 产品/渠道管理 + 角色三要素权限 1.5周 +
    +
  • 产品CRUD + 渠道码管理
  • +
  • 角色管理 (部门级 + 三要素)
  • +
  • 用户管理 (部门角色绑定 + 多角色)
  • +
  • 权限服务实现 (多角色并集计算)
  • +
+
+ +
+Phase 3: Skill管理 + 脚本执行器 + 任务模块 2周 +
    +
  • 脚本模板管理
  • +
  • Skill CRUD + 产品范围绑定
  • +
  • Celery Worker部署
  • +
  • 脚本执行器实现
  • +
  • 任务创建/执行/查询
  • +
  • 权限双重校验 (Skill权限 + 产品交集)
  • +
+
+ +
+Phase 4: 平台账号管理 + LLM模块 2周 +
    +
  • 平台账号模板管理
  • +
  • 用户平台账号CRUD
  • +
  • LLM供应商管理 (多Provider)
  • +
  • 模型配置 + 成本设置
  • +
  • LLM路由规则
  • +
  • LLM调用服务 + 用量统计
  • +
+
+ +
+Phase 5: 定时任务 + 日志统计 + 通知推送 1.5周 +
    +
  • Celery Beat定时任务
  • +
  • 操作日志记录
  • +
  • Skill调用统计报表
  • +
  • SSE实时推送
  • +
  • Telegram Bot通知
  • +
+
+ +
+Phase 6: 部署优化 + 集成测试 + 上线 1周 +
    +
  • Docker Compose优化
  • +
  • 环境变量配置
  • +
  • 集成测试
  • +
  • 性能优化
  • +
  • 上线部署
  • +
+
+
+
+ + +
+

📋 架构总结

+ + + + + + + + +
维度选型
前端React 18 + TypeScript + Ant Design + Zustand
后端FastAPI + SQLAlchemy 2.0 + Pydantic v2
数据库PostgreSQL 16 + Redis 7
任务队列Celery + Redis
部署Docker Compose + Nginx
预计工期约9周
+
+ +
+

🤖 Agent任务系统 · 架构设计方案 v1.0

+

生成时间:2026-04-23 | 技术栈:React + FastAPI + PostgreSQL

+
+ +
+ + diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..638f0c7 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,155 @@ +# Loom 生产部署手册 + +目标:把 Loom 全栈部署到 `ai.aidfxn.com`,使用 Docker Compose + Let's Encrypt 自动证书。 + +--- + +## 0. 前置条件(部署前必须就绪) + +| 项 | 要求 | +|---|---| +| 服务器 | Linux(Ubuntu 22.04+ / Debian 12+ / CentOS 9+ 均可),2 vCPU / 4 GB RAM / 30 GB 磁盘起步 | +| 软件 | `docker` 24+、`docker compose v2`、`openssl`、`curl`、`python3` | +| 防火墙 | 开放 80 / 443 到公网(80 是 Let's Encrypt 挑战用) | +| 域名 | `ai.aidfxn.com` 的 A 记录指向服务器公网 IP(TTL ≤ 10 min 便于调试) | +| 邮箱 | Let's Encrypt 续签通知邮箱(`deploy.sh` 里默认 `runzi.lao@gmail.com`,可改) | + +校验 DNS 是否生效: +```bash +dig +short ai.aidfxn.com +# 应返回你的服务器公网 IP +``` + +--- + +## 1. 首次部署步骤 + +### 1.1 把项目推到服务器 + +推荐方式:git 仓库;临时方案:`rsync -avz --exclude='.venv' --exclude='node_modules' --exclude='*.pyc' loom/ user@SERVER:/opt/loom/` + +```bash +ssh user@SERVER +cd /opt/loom +``` + +### 1.2 生成生产 .env + +```bash +cp .env.production.example .env + +# 生成 3 个密钥并手动填进 .env +openssl rand -hex 32 # → APP_SECRET_KEY +openssl rand -base64 24 # → POSTGRES_PASSWORD +python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # → FERNET_KEY + +vi .env # 填入上面 3 个值;其余 LOBSTER_*、OPENAI_* 可先留空(可在 UI 里补) +``` + +> **重要**:`FERNET_KEY` 生成后严禁再改 —— 所有已加密字段(平台账号密码、LLM API Key、龙虾 API Key 等)都依赖它解密。 + +### 1.3 一键部署 + +```bash +bash scripts/deploy.sh +``` + +该脚本会: +1. 校验 `.env` 必填项 + 替换 `DATABASE_URL` 中的占位符 +2. 构建前后端镜像 +3. 首次运行时通过 Let's Encrypt 申请 TLS 证书(HTTP-01 挑战) +4. 起全栈(postgres / redis / backend / frontend / certbot) +5. 轮询 `/api/v1/healthz` 直到后端就绪 + +成功后访问 ****,使用 `admin / admin123` 登录(**首次登录立刻修改密码**)。 + +--- + +## 2. 验证清单 + +| 检查 | 方法 | 通过标志 | +|---|---|---| +| HTTPS 生效 | `curl -I https://ai.aidfxn.com/` | `HTTP/2 200` + `strict-transport-security` 头 | +| HTTP → HTTPS 重定向 | `curl -I http://ai.aidfxn.com/` | `HTTP/1.1 301`,`Location: https://...` | +| 健康检查 | `curl https://ai.aidfxn.com/api/v1/healthz` | `{"code":0,"data":{"db":"ok","redis":"ok",...}}` | +| 登录可用 | 浏览器进入首页登录 | `admin` / `admin123` 能进仪表盘 | +| 证书自动续签 | `docker compose logs certbot` | 12 小时循环 `certbot renew` 输出 | +| 容器 `restart: always` | `docker compose ps` | `STATUS` 均为 `Up` | + +--- + +## 3. 日常运维 + +```bash +# 查容器 +docker compose -f docker-compose.yml -f docker-compose.prod.yml ps + +# 查日志(后端滚动 / nginx 访问) +docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f backend +docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f frontend + +# 进 backend 跑 alembic / 种子 / pytest +docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend alembic upgrade head +docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend python -m app.scripts.seed + +# 备份数据库 +docker compose -f docker-compose.yml -f docker-compose.prod.yml exec -T postgres \ + pg_dump -U loom loom > backup-$(date +%Y%m%d).sql + +# 恢复 +cat backup-YYYYMMDD.sql | docker compose -f docker-compose.yml -f docker-compose.prod.yml exec -T postgres \ + psql -U loom loom +``` + +--- + +## 4. 升级流程(零停机不保证,约 30-60s 抖动) + +```bash +cd /opt/loom +git pull # 或 rsync 覆盖 +docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile full build backend frontend +docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile full up -d + +# 数据库迁移(镜像 entrypoint 会自动跑,但建议显式跑一次查结果) +docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend alembic upgrade head +``` + +如果迁移风险高,先在步骤 2 之前 `pg_dump` 备份。 + +--- + +## 5. 回滚 + +```bash +# 代码回滚 +git reset --hard <上一个已知 good commit> +docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile full up -d --build + +# 迁移回滚(仅支持可逆迁移) +docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend alembic downgrade -1 +``` + +不可逆迁移需从 `pg_dump` 备份恢复。 + +--- + +## 6. 常见问题 + +| 现象 | 排查 | +|---|---| +| `deploy.sh` 卡在证书申请 | 检查 `dig ai.aidfxn.com` 是否指向本机;服务器 80 端口是否被其它服务占用(`sudo lsof -i :80`) | +| 首页 502 | `docker compose logs frontend` + `docker compose logs backend`,多半是 backend 还没就绪或 `.env` 缺字段 | +| 登录 `admin/admin123` 401 | 数据库里还没有 seed;进 backend 容器跑 `python -m app.scripts.seed` | +| 任务执行报 LLM 401 | 先看「系统设置 → 执行器配置 → 龙虾 Agent」有没有配 `api_key`;或 `.env` 的 `OPENAI_API_KEY` / `LOBSTER_API_KEY` 是否真实有效 | +| 证书到期 | `certbot` 容器每 12h 自动续签;手动续签:`docker compose -f ... exec certbot certbot renew` | + +--- + +## 7. 敏感信息清单(切勿提交到代码仓库) + +- `.env`(生产凭据) +- `certbot/conf/` 整个目录(Let's Encrypt 私钥 + 账号凭据) +- 数据库备份 `backup-*.sql`(含加密后的业务凭据) + +建议服务器侧 `chmod 600 .env` + `chown root:root` 严格权限。 diff --git a/docs/二级域名.txt b/docs/二级域名.txt new file mode 100644 index 0000000..746fb97 --- /dev/null +++ b/docs/二级域名.txt @@ -0,0 +1 @@ +ai.aidfxn.com \ No newline at end of file diff --git a/docs/原型agent_task_system_merged.html b/docs/原型agent_task_system_merged.html new file mode 100644 index 0000000..d9f0cd9 --- /dev/null +++ b/docs/原型agent_task_system_merged.html @@ -0,0 +1,176 @@ + + + + + +Agent任务系统 v2.1 - 全模块原型 + + + +
+ +
+ +
+
+ + + + diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..f4d67a9 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.vite +.env.local +.env.*.local +*.log +.DS_Store diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..94cc34a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json ./ +RUN npm install --no-audit --no-fund --legacy-peer-deps +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine +COPY --from=build /app/dist /usr/share/nginx/html +# nginx 配置由 docker-compose 挂载到 /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..982c3b2 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,41 @@ +# Loom Frontend + +React 18 + Vite 5 + Ant Design 5 + Zustand + Tailwind(仅原子工具类)。 + +## 本地开发 + +```bash +pnpm i # 或 npm i / yarn +pnpm dev # http://localhost:5173(/api 代理到 localhost:8000) +``` + +## 构建 + +```bash +pnpm build +# 产物:frontend/dist,由 docker-compose frontend 服务托管 +``` + +## 目录 + +``` +src/ +├── main.tsx 入口,挂载 ConfigProvider + Ant App +├── App.tsx 顶层 RouterProvider +├── router/ 路由表 / 守卫 +├── layouts/ BasicLayout(管理后台)、BlankLayout(登录) +├── pages/ 页面(P2 起按模块补齐 19 页) +├── components/ 公共业务组件 +├── stores/ zustand(auth、menu、app) +├── api/ axios 实例 + 业务 API +├── hooks/ useAuth / usePerm / usePageTable +├── utils/ token / tree / download +├── styles/ 全局 css + AntD theme token +└── constants/ 状态枚举 / 字典 +``` + +## 设计原则 + +- **配色沿用原型**(`docs/原型agent_task_system_merged.html`):主色 `#6366f1`、深色 Sider `#1e293b`、浅灰背景 `#f1f5f9`。所有色值集中在 `src/styles/tokens.ts`。 +- **Tailwind 作为原子工具类**:关闭 preflight、加 `tw-` 前缀;不覆盖 AntD。 +- **权限联动**:`useAuthStore.hasPerm()` + ``;路由守卫 `AuthGuard` 统一处理未登录跳转。 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5c580fc --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Loom · Agent 任务系统 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..fe24b90 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4078 @@ +{ + "name": "loom-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "loom-frontend", + "version": "0.1.0", + "dependencies": { + "@ant-design/icons": "^5.5.1", + "antd": "^5.22.0", + "axios": "^1.7.7", + "dayjs": "^1.11.13", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@types/node": "^22.7.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.0.tgz", + "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.3.tgz", + "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz", + "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz", + "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/antd": { + "version": "5.29.3", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", + "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.1.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.3.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.1", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.9", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.54.0", + "rc-tabs": "~15.7.0", + "rc-textarea": "~1.10.2", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.11.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz", + "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz", + "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz", + "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", + "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.54.0", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz", + "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz", + "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz", + "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz", + "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-virtual-list": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", + "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..96fb9f6 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "loom-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint ." + }, + "dependencies": { + "@ant-design/icons": "^5.5.1", + "antd": "^5.22.0", + "axios": "^1.7.7", + "dayjs": "^1.11.13", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@types/node": "^22.7.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} diff --git a/frontend/postcss.config.cjs b/frontend/postcss.config.cjs new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/frontend/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..1643acb --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,5 @@ +import { AppRouter } from '@/router'; + +export default function App() { + return ; +} diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts new file mode 100644 index 0000000..ddf72c1 --- /dev/null +++ b/frontend/src/api/http.ts @@ -0,0 +1,55 @@ +import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios'; +import { message } from 'antd'; +import { tokenStorage } from '@/utils/token'; +import type { ApiResp } from './types'; + +const BASE = import.meta.env.VITE_API_BASE ?? '/api'; + +export const http: AxiosInstance = axios.create({ + baseURL: BASE, + timeout: 30_000, +}); + +http.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const token = tokenStorage.get(); + if (token) { + config.headers.set('Authorization', `Bearer ${token}`); + } + return config; +}); + +http.interceptors.response.use( + (resp) => { + const body = resp.data as ApiResp; + if (body && typeof body.code === 'number' && body.code !== 0) { + message.error(body.message || '请求失败'); + return Promise.reject(new Error(body.message)); + } + return resp; + }, + (error: AxiosError) => { + const status = error.response?.status; + const body = error.response?.data; + const msg = body?.message || error.message || '网络异常'; + + if (status === 401) { + tokenStorage.clear(); + message.error(msg); + // 登录页本身不必跳转 + if (!location.pathname.startsWith('/login')) { + const redirect = encodeURIComponent(location.pathname + location.search); + location.replace(`/login?redirect=${redirect}`); + } + } else { + message.error(msg); + } + return Promise.reject(error); + }, +); + +export async function request( + config: Parameters[0], +): Promise { + const resp = await http.request>(config); + return resp.data.data; +} diff --git a/frontend/src/api/modules/auth.ts b/frontend/src/api/modules/auth.ts new file mode 100644 index 0000000..e9ee40e --- /dev/null +++ b/frontend/src/api/modules/auth.ts @@ -0,0 +1,30 @@ +import { request } from '@/api/http'; +import type { LoginPayload, MeResponse, TokenPair } from '@/api/types'; + +export const authApi = { + login: (payload: LoginPayload) => + request({ + url: '/v1/auth/login', + method: 'POST', + data: payload, + }), + + logout: () => + request({ + url: '/v1/auth/logout', + method: 'POST', + }), + + me: () => + request({ + url: '/v1/auth/me', + method: 'GET', + }), + + refresh: (refresh_token: string) => + request({ + url: '/v1/auth/refresh', + method: 'POST', + data: { refresh_token }, + }), +}; diff --git a/frontend/src/api/modules/channel-code.ts b/frontend/src/api/modules/channel-code.ts new file mode 100644 index 0000000..35db2c6 --- /dev/null +++ b/frontend/src/api/modules/channel-code.ts @@ -0,0 +1,20 @@ +import { request } from '@/api/http'; +import type { ChannelCode, PageResp } from '@/api/types'; + +export interface ChannelQuery { + page?: number; + size?: number; + keyword?: string; + type?: 'INTERNAL' | 'NATURAL'; +} + +export const channelCodeApi = { + list: (params: ChannelQuery) => + request>({ url: '/v1/channel-codes', method: 'GET', params }), + listAll: () => request({ url: '/v1/channel-codes/all', method: 'GET' }), + create: (data: Partial) => + request({ url: '/v1/channel-codes', method: 'POST', data }), + update: (id: number, data: Partial) => + request({ url: `/v1/channel-codes/${id}`, method: 'PATCH', data }), + remove: (id: number) => request({ url: `/v1/channel-codes/${id}`, method: 'DELETE' }), +}; diff --git a/frontend/src/api/modules/dashboard.ts b/frontend/src/api/modules/dashboard.ts new file mode 100644 index 0000000..575e17c --- /dev/null +++ b/frontend/src/api/modules/dashboard.ts @@ -0,0 +1,29 @@ +import { request } from '@/api/http'; +import type { + DashboardCharts, + DashboardSummary, + OpLog, + PageResp, + ReportData, +} from '@/api/types'; + +export const dashboardApi = { + summary: () => + request({ url: '/v1/dashboard/summary', method: 'GET' }), + charts: (days = 7) => + request({ url: '/v1/dashboard/charts', method: 'GET', params: { days } }), + report: (days = 7) => + request({ url: '/v1/dashboard/report', method: 'GET', params: { days } }), +}; + +export interface OpLogQuery { + page?: number; + size?: number; + module?: string; + operator_id?: number; +} + +export const opLogApi = { + list: (params: OpLogQuery) => + request>({ url: '/v1/op-logs', method: 'GET', params }), +}; diff --git a/frontend/src/api/modules/executor.ts b/frontend/src/api/modules/executor.ts new file mode 100644 index 0000000..57b34d2 --- /dev/null +++ b/frontend/src/api/modules/executor.ts @@ -0,0 +1,7 @@ +import { request } from '@/api/http'; +import type { ExecutorStatus } from '@/api/types'; + +export const executorApi = { + status: () => + request({ url: '/v1/executors/status', method: 'GET' }), +}; diff --git a/frontend/src/api/modules/llm.ts b/frontend/src/api/modules/llm.ts new file mode 100644 index 0000000..8c0e287 --- /dev/null +++ b/frontend/src/api/modules/llm.ts @@ -0,0 +1,85 @@ +import { request } from '@/api/http'; +import type { + ChatResponse, + LLMModel, + LLMProvider, + LLMRoutingRule, + LLMUsage, + LLMUsageSummary, + PageResp, +} from '@/api/types'; + +export interface ProviderPayload { + code?: string; + name?: string; + vendor?: 'openai' | 'azure' | 'anthropic' | 'custom'; + api_base?: string; + /** 留空 / undefined 不改;传空串清空 */ + api_key?: string; + description?: string; + status?: 'ACTIVE' | 'DISABLED'; +} + +export interface ModelPayload { + provider_id?: number; + model?: string; + name?: string; + description?: string; + max_context?: number; + price_input_per_1k?: number; + price_output_per_1k?: number; + status?: 'ACTIVE' | 'DISABLED'; +} + +export interface RulePayload { + name?: string; + priority?: number; + conditions?: Record | null; + model_id?: number; + enabled?: boolean; + description?: string; +} + +export interface ChatPayload { + messages: Array<{ role: string; content: string }>; + model?: string; + conditions?: Record; + temperature?: number; + max_tokens?: number; + source?: string; +} + +export const llmApi = { + listProviders: () => request({ url: '/v1/llm/providers', method: 'GET' }), + createProvider: (data: ProviderPayload) => + request({ url: '/v1/llm/providers', method: 'POST', data }), + updateProvider: (id: number, data: ProviderPayload) => + request({ url: `/v1/llm/providers/${id}`, method: 'PATCH', data }), + removeProvider: (id: number) => + request({ url: `/v1/llm/providers/${id}`, method: 'DELETE' }), + + listModels: () => request({ url: '/v1/llm/models', method: 'GET' }), + createModel: (data: ModelPayload) => + request({ url: '/v1/llm/models', method: 'POST', data }), + updateModel: (id: number, data: ModelPayload) => + request({ url: `/v1/llm/models/${id}`, method: 'PATCH', data }), + removeModel: (id: number) => + request({ url: `/v1/llm/models/${id}`, method: 'DELETE' }), + + listRules: () => request({ url: '/v1/llm/rules', method: 'GET' }), + createRule: (data: RulePayload) => + request({ url: '/v1/llm/rules', method: 'POST', data }), + updateRule: (id: number, data: RulePayload) => + request({ url: `/v1/llm/rules/${id}`, method: 'PATCH', data }), + removeRule: (id: number) => + request({ url: `/v1/llm/rules/${id}`, method: 'DELETE' }), + + chat: (data: ChatPayload) => + request({ url: '/v1/llm/chat', method: 'POST', data }), + + usageSummary: () => + request({ url: '/v1/llm/usage/summary', method: 'GET' }), + + usageList: (params: { page?: number; size?: number; provider_id?: number; source?: string }) => + request>({ url: '/v1/llm/usage', method: 'GET', params }), +}; diff --git a/frontend/src/api/modules/lobster-config.ts b/frontend/src/api/modules/lobster-config.ts new file mode 100644 index 0000000..d11bec2 --- /dev/null +++ b/frontend/src/api/modules/lobster-config.ts @@ -0,0 +1,18 @@ +import { request } from '@/api/http'; +import type { LobsterConfig } from '@/api/types'; + +export interface LobsterConfigPayload { + api_base?: string; + /** 留空 / undefined 不改;传空串清空 DB 回落 .env;填值则加密保存 */ + api_key?: string; + callback_secret?: string; + timeout_seconds?: number; + poll_interval_seconds?: number; + note?: string; +} + +export const lobsterConfigApi = { + get: () => request({ url: '/v1/lobster-config', method: 'GET' }), + update: (data: LobsterConfigPayload) => + request({ url: '/v1/lobster-config', method: 'PUT', data }), +}; diff --git a/frontend/src/api/modules/menu.ts b/frontend/src/api/modules/menu.ts new file mode 100644 index 0000000..fb56d34 --- /dev/null +++ b/frontend/src/api/modules/menu.ts @@ -0,0 +1,12 @@ +import { request } from '@/api/http'; +import type { MenuItem, MenuTreeNode } from '@/api/types'; + +export const menuApi = { + list: () => request({ url: '/v1/menus', method: 'GET' }), + tree: () => request({ url: '/v1/menus/tree', method: 'GET' }), + create: (data: Partial) => + request({ url: '/v1/menus', method: 'POST', data }), + update: (id: number, data: Partial) => + request({ url: `/v1/menus/${id}`, method: 'PATCH', data }), + remove: (id: number) => request({ url: `/v1/menus/${id}`, method: 'DELETE' }), +}; diff --git a/frontend/src/api/modules/organization.ts b/frontend/src/api/modules/organization.ts new file mode 100644 index 0000000..2b1d8e2 --- /dev/null +++ b/frontend/src/api/modules/organization.ts @@ -0,0 +1,13 @@ +import { request } from '@/api/http'; +import type { Organization, OrgTreeNode } from '@/api/types'; + +export const orgApi = { + list: () => request({ url: '/v1/organizations', method: 'GET' }), + tree: () => request({ url: '/v1/organizations/tree', method: 'GET' }), + create: (data: Partial) => + request({ url: '/v1/organizations', method: 'POST', data }), + update: (id: number, data: Partial) => + request({ url: `/v1/organizations/${id}`, method: 'PATCH', data }), + remove: (id: number) => + request({ url: `/v1/organizations/${id}`, method: 'DELETE' }), +}; diff --git a/frontend/src/api/modules/platform-account.ts b/frontend/src/api/modules/platform-account.ts new file mode 100644 index 0000000..9d7bb32 --- /dev/null +++ b/frontend/src/api/modules/platform-account.ts @@ -0,0 +1,55 @@ +import { request } from '@/api/http'; +import type { PageResp, PlatformAccount } from '@/api/types'; + +export interface PlatformAccountQuery { + page?: number; + size?: number; + keyword?: string; + platform_name?: string; + owner_id?: number; +} + +export interface PlatformAccountCreatePayload { + platform_name: string; + product_code?: string; + login: string; + password: string; + google_key?: string; + remark?: string; + status?: 'ACTIVE' | 'DISABLED'; +} + +export interface PlatformAccountUpdatePayload { + platform_name?: string; + product_code?: string; + login?: string; + /** 留空 / undefined 不改;传空字符串 = 清空;传非空 = 更新密文 */ + password?: string; + google_key?: string; + remark?: string; + status?: 'ACTIVE' | 'DISABLED'; +} + +export const platformAccountApi = { + list: (params: PlatformAccountQuery) => + request>({ + url: '/v1/platform-accounts', + method: 'GET', + params, + }), + create: (data: PlatformAccountCreatePayload) => + request({ url: '/v1/platform-accounts', method: 'POST', data }), + update: (id: number, data: PlatformAccountUpdatePayload) => + request({ + url: `/v1/platform-accounts/${id}`, + method: 'PATCH', + data, + }), + remove: (id: number) => + request({ url: `/v1/platform-accounts/${id}`, method: 'DELETE' }), + test: (id: number) => + request({ + url: `/v1/platform-accounts/${id}/test`, + method: 'POST', + }), +}; diff --git a/frontend/src/api/modules/platform.ts b/frontend/src/api/modules/platform.ts new file mode 100644 index 0000000..f29d44b --- /dev/null +++ b/frontend/src/api/modules/platform.ts @@ -0,0 +1,11 @@ +import { request } from '@/api/http'; +import type { Platform } from '@/api/types'; + +export const platformApi = { + list: () => request({ url: '/v1/platforms', method: 'GET' }), + create: (data: Partial) => + request({ url: '/v1/platforms', method: 'POST', data }), + update: (id: number, data: Partial) => + request({ url: `/v1/platforms/${id}`, method: 'PATCH', data }), + remove: (id: number) => request({ url: `/v1/platforms/${id}`, method: 'DELETE' }), +}; diff --git a/frontend/src/api/modules/product.ts b/frontend/src/api/modules/product.ts new file mode 100644 index 0000000..2481a8f --- /dev/null +++ b/frontend/src/api/modules/product.ts @@ -0,0 +1,32 @@ +import { request } from '@/api/http'; +import type { PageResp, Product } from '@/api/types'; + +export interface ProductQuery { + page?: number; + size?: number; + keyword?: string; + platform_id?: number; + dept_id?: number; +} + +export interface ProductPayload { + code?: string; + name?: string; + platform_id?: number | null; + dept_id?: number | null; + owner_id?: number | null; + description?: string; + status?: 'ACTIVE' | 'DISABLED'; + channel_code_ids?: number[]; +} + +export const productApi = { + list: (params: ProductQuery) => + request>({ url: '/v1/products', method: 'GET', params }), + get: (id: number) => request({ url: `/v1/products/${id}`, method: 'GET' }), + create: (data: ProductPayload) => + request({ url: '/v1/products', method: 'POST', data }), + update: (id: number, data: ProductPayload) => + request({ url: `/v1/products/${id}`, method: 'PATCH', data }), + remove: (id: number) => request({ url: `/v1/products/${id}`, method: 'DELETE' }), +}; diff --git a/frontend/src/api/modules/role.ts b/frontend/src/api/modules/role.ts new file mode 100644 index 0000000..4e4467d --- /dev/null +++ b/frontend/src/api/modules/role.ts @@ -0,0 +1,12 @@ +import { request } from '@/api/http'; +import type { Role, RoleDetail } from '@/api/types'; + +export const roleApi = { + list: () => request({ url: '/v1/roles', method: 'GET' }), + get: (id: number) => request({ url: `/v1/roles/${id}`, method: 'GET' }), + create: (data: Partial) => + request({ url: '/v1/roles', method: 'POST', data }), + update: (id: number, data: Partial) => + request({ url: `/v1/roles/${id}`, method: 'PATCH', data }), + remove: (id: number) => request({ url: `/v1/roles/${id}`, method: 'DELETE' }), +}; diff --git a/frontend/src/api/modules/schedule.ts b/frontend/src/api/modules/schedule.ts new file mode 100644 index 0000000..21f90a0 --- /dev/null +++ b/frontend/src/api/modules/schedule.ts @@ -0,0 +1,35 @@ +import { request } from '@/api/http'; +import type { Schedule, ScheduleLog } from '@/api/types'; + +export interface SchedulePayload { + name?: string; + skill_id?: number; + product_id?: number | null; + input_data?: Record; + cron?: string; + enabled?: boolean; +} + +export const scheduleApi = { + list: () => request({ url: '/v1/schedules', method: 'GET' }), + get: (id: number) => request({ url: `/v1/schedules/${id}`, method: 'GET' }), + logs: (id: number, limit = 50) => + request({ + url: `/v1/schedules/${id}/logs`, + method: 'GET', + params: { limit }, + }), + create: (data: SchedulePayload) => + request({ url: '/v1/schedules', method: 'POST', data }), + update: (id: number, data: SchedulePayload) => + request({ url: `/v1/schedules/${id}`, method: 'PATCH', data }), + toggle: (id: number, enabled: boolean) => + request({ + url: `/v1/schedules/${id}/toggle`, + method: 'POST', + params: { enabled }, + }), + trigger: (id: number) => + request({ url: `/v1/schedules/${id}/trigger`, method: 'POST' }), + remove: (id: number) => request({ url: `/v1/schedules/${id}`, method: 'DELETE' }), +}; diff --git a/frontend/src/api/modules/script.ts b/frontend/src/api/modules/script.ts new file mode 100644 index 0000000..ea2fa56 --- /dev/null +++ b/frontend/src/api/modules/script.ts @@ -0,0 +1,20 @@ +import { request } from '@/api/http'; +import type { PageResp, ScriptTemplate } from '@/api/types'; + +export interface ScriptQuery { + page?: number; + size?: number; + keyword?: string; + language?: 'python' | 'node' | 'shell'; +} + +export const scriptApi = { + list: (params: ScriptQuery) => + request>({ url: '/v1/scripts', method: 'GET', params }), + listAll: () => request({ url: '/v1/scripts/all', method: 'GET' }), + create: (data: Partial) => + request({ url: '/v1/scripts', method: 'POST', data }), + update: (id: number, data: Partial) => + request({ url: `/v1/scripts/${id}`, method: 'PATCH', data }), + remove: (id: number) => request({ url: `/v1/scripts/${id}`, method: 'DELETE' }), +}; diff --git a/frontend/src/api/modules/skill.ts b/frontend/src/api/modules/skill.ts new file mode 100644 index 0000000..d1ce5a8 --- /dev/null +++ b/frontend/src/api/modules/skill.ts @@ -0,0 +1,57 @@ +import { request } from '@/api/http'; +import type { PageResp, Skill, SkillCallLog } from '@/api/types'; + +export interface SkillQuery { + page?: number; + size?: number; + keyword?: string; + executor_type?: 'LOBSTER' | 'SCRIPT'; + category?: string; +} + +export interface SkillPayload { + code?: string; + name?: string; + category?: string; + executor_type?: 'LOBSTER' | 'SCRIPT'; + description?: string; + input_schema?: Record | null; + param_mapping?: Record | null; + lobster_template_id?: string | null; + script_template_id?: number | null; + status?: 'ACTIVE' | 'DISABLED'; + doc?: string; + product_scope_ids?: number[]; +} + +export interface SkillTestPayload { + input_data: Record; + product_id?: number | null; +} + +export interface SkillLogQuery { + page?: number; + size?: number; + skill_id?: number; + result?: 'ok' | 'failed'; + source?: string; +} + +export const skillApi = { + list: (params: SkillQuery) => + request>({ url: '/v1/skills', method: 'GET', params }), + get: (id: number) => request({ url: `/v1/skills/${id}`, method: 'GET' }), + create: (data: SkillPayload) => request({ url: '/v1/skills', method: 'POST', data }), + update: (id: number, data: SkillPayload) => + request({ url: `/v1/skills/${id}`, method: 'PATCH', data }), + remove: (id: number) => request({ url: `/v1/skills/${id}`, method: 'DELETE' }), + test: (id: number, payload: SkillTestPayload) => + request({ + url: `/v1/skills/${id}/test`, + method: 'POST', + data: payload, + }), + + listLogs: (params: SkillLogQuery) => + request>({ url: '/v1/skill-logs', method: 'GET', params }), +}; diff --git a/frontend/src/api/modules/task.ts b/frontend/src/api/modules/task.ts new file mode 100644 index 0000000..91c63f4 --- /dev/null +++ b/frontend/src/api/modules/task.ts @@ -0,0 +1,32 @@ +import { request } from '@/api/http'; +import type { PageResp, Task, TaskLog, TaskStatus } from '@/api/types'; + +export interface TaskQuery { + page?: number; + size?: number; + keyword?: string; + status?: TaskStatus; + skill_id?: number; + product_id?: number; +} + +export interface TaskCreatePayload { + name: string; + skill_id: number; + product_id?: number | null; + input_data: Record; + timeout_seconds?: number; +} + +export const taskApi = { + list: (params: TaskQuery) => + request>({ url: '/v1/tasks', method: 'GET', params }), + get: (id: number) => request({ url: `/v1/tasks/${id}`, method: 'GET' }), + logs: (id: number, after_id = 0) => + request({ url: `/v1/tasks/${id}/logs`, method: 'GET', params: { after_id } }), + create: (data: TaskCreatePayload) => + request({ url: '/v1/tasks', method: 'POST', data }), + run: (id: number) => request({ url: `/v1/tasks/${id}/run`, method: 'POST' }), + cancel: (id: number) => request({ url: `/v1/tasks/${id}/cancel`, method: 'POST' }), + retry: (id: number) => request({ url: `/v1/tasks/${id}/retry`, method: 'POST' }), +}; diff --git a/frontend/src/api/modules/user.ts b/frontend/src/api/modules/user.ts new file mode 100644 index 0000000..6a67923 --- /dev/null +++ b/frontend/src/api/modules/user.ts @@ -0,0 +1,43 @@ +import { request } from '@/api/http'; +import type { PageResp, UserItem } from '@/api/types'; + +export interface UserQuery { + page?: number; + size?: number; + keyword?: string; + dept_id?: number; +} + +export interface UserCreatePayload { + username: string; + password: string; + nickname?: string; + email?: string; + dept_id?: number | null; + role_ids?: number[]; + status?: 'ACTIVE' | 'DISABLED'; +} + +export interface UserUpdatePayload { + nickname?: string; + email?: string | null; + dept_id?: number | null; + role_ids?: number[]; + status?: 'ACTIVE' | 'DISABLED'; +} + +export const userApi = { + list: (params: UserQuery) => + request>({ url: '/v1/users', method: 'GET', params }), + create: (data: UserCreatePayload) => + request({ url: '/v1/users', method: 'POST', data }), + update: (id: number, data: UserUpdatePayload) => + request({ url: `/v1/users/${id}`, method: 'PATCH', data }), + resetPassword: (id: number, password: string) => + request({ + url: `/v1/users/${id}/reset-password`, + method: 'POST', + data: { password }, + }), + remove: (id: number) => request({ url: `/v1/users/${id}`, method: 'DELETE' }), +}; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..ebf2bcc --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,500 @@ +export interface ApiResp { + code: number; + message: string; + data: T; +} + +export interface PageResp { + list: T[]; + total: number; + page: number; + size: number; +} + +export interface PageQuery { + page?: number; + size?: number; + keyword?: string; + [k: string]: unknown; +} + +export interface LoginPayload { + username: string; + password: string; + captcha?: string; +} + +export interface TokenPair { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; +} + +export interface RoleBrief { + id: number; + name: string; + code: string; +} + +export interface UserInfo { + id: number; + username: string; + nickname: string | null; + email: string | null; + avatar: string | null; + dept_id: number | null; + dept_name: string | null; + is_superuser: boolean; + roles: RoleBrief[]; + perm_codes: string[]; +} + +export interface MenuTreeNode { + id: number; + parent_id: number | null; + name: string; + type: 'dir' | 'menu' | 'button'; + path: string | null; + component: string | null; + icon: string | null; + perm_code: string | null; + sort: number; + status?: string; + children: MenuTreeNode[]; +} + +export interface MeResponse { + user: UserInfo; + menu_tree: MenuTreeNode[]; +} + +// ---- P3 ---- + +export interface Organization { + id: number; + parent_id: number | null; + name: string; + code: string; + type: 'company' | 'department' | 'group'; + sort: number; + description: string | null; +} + +export interface OrgTreeNode extends Organization { + children: OrgTreeNode[]; +} + +export interface Role { + id: number; + name: string; + code: string; + dept_id: number | null; + description: string | null; + status: 'ACTIVE' | 'DISABLED'; + is_builtin: boolean; +} + +export interface RoleDetail extends Role { + menu_ids: number[]; + product_scope_ids: number[]; + skill_scope_ids: number[]; + account_scope_ids: number[]; + dept_name: string | null; +} + +export interface UserItem { + id: number; + username: string; + nickname: string | null; + email: string | null; + dept_id: number | null; + dept_name: string | null; + status: 'ACTIVE' | 'DISABLED'; + is_superuser: boolean; + role_ids: number[]; + role_names: string[]; +} + +export interface MenuItem { + id: number; + parent_id: number | null; + name: string; + type: 'dir' | 'menu' | 'button'; + path: string | null; + component: string | null; + icon: string | null; + perm_code: string | null; + sort: number; + status: 'ACTIVE' | 'DISABLED'; +} + +export interface PlatformAccount { + id: number; + owner_id: number; + owner_name: string | null; + platform_name: string; + product_code: string | null; + login: string; + password_mask: string; + google_key_mask: string; + has_google_key: boolean; + remark: string | null; + status: 'ACTIVE' | 'DISABLED'; + last_test_result: 'ok' | 'failed' | 'unknown'; + last_test_message: string | null; +} + +// ---- P4 ---- + +export interface Platform { + id: number; + code: string; + name: string; + url: string | null; + description: string | null; + status: 'ACTIVE' | 'DISABLED'; + product_count: number; +} + +export interface ChannelCode { + id: number; + code: string; + name: string; + type: 'INTERNAL' | 'NATURAL'; + description: string | null; + status: 'ACTIVE' | 'DISABLED'; +} + +export interface Product { + id: number; + code: string; + name: string; + platform_id: number | null; + platform_name: string | null; + dept_id: number | null; + dept_name: string | null; + owner_id: number | null; + owner_name: string | null; + description: string | null; + status: 'ACTIVE' | 'DISABLED'; + channel_code_ids: number[]; + channel_code_names: string[]; +} + +export interface ScriptTemplate { + id: number; + code: string; + name: string; + language: 'python' | 'node' | 'shell'; + description: string | null; + content: string; + version: number; + timeout_seconds: number; + status: 'ACTIVE' | 'DISABLED'; + owner_id: number | null; +} + +export interface Skill { + id: number; + code: string; + name: string; + category: string | null; + executor_type: 'LOBSTER' | 'SCRIPT'; + description: string | null; + input_schema: Record | null; + param_mapping: Record | null; + lobster_template_id: string | null; + script_template_id: number | null; + status: 'ACTIVE' | 'DISABLED'; + doc: string | null; + product_scope_ids: number[]; + product_scope_names: string[]; +} + +export interface SkillCallLog { + id: number; + skill_id: number; + skill_code: string; + operator_id: number | null; + operator_name: string | null; + executor_type: 'LOBSTER' | 'SCRIPT'; + product_id: number | null; + input_data: Record | null; + output_data: Record | null; + result: 'ok' | 'failed'; + message: string | null; + cost_ms: number; + source: string; + created_at: string | null; +} + +// ---- P5 ---- + +export type TaskStatus = + | 'PENDING' + | 'QUEUED' + | 'RUNNING' + | 'SUCCEEDED' + | 'FAILED' + | 'TIMEOUT' + | 'CANCELED'; + +export interface Task { + id: number; + task_no: string; + name: string; + skill_id: number; + skill_code: string; + skill_name: string | null; + product_id: number | null; + product_name: string | null; + executor_type: 'LOBSTER' | 'SCRIPT'; + status: TaskStatus; + error_message: string | null; + external_ref: string | null; + source: string; + operator_id: number | null; + operator_name: string | null; + input_data: Record | null; + resolved_input: Record | null; + result_data: Record | null; + started_at: string | null; + finished_at: string | null; + cost_ms: number; + timeout_seconds: number; + created_at: string | null; +} + +export interface TaskLog { + id: number; + task_id: number; + level: string; + message: string; + created_at: string | null; +} + +export interface Schedule { + id: number; + name: string; + skill_id: number; + skill_code: string | null; + skill_name: string | null; + product_id: number | null; + product_name: string | null; + input_data: Record | null; + cron: string; + enabled: boolean; + last_run_at: string | null; + next_run_at: string | null; + last_task_id: number | null; + owner_id: number | null; +} + +export interface ScheduleLog { + id: number; + schedule_id: number; + task_id: number | null; + result: string; + message: string | null; + triggered_at: string | null; + cost_ms: number; +} + +export interface DashboardSummary { + total_users: number; + total_skills: number; + total_tasks: number; + enabled_schedules: number; + + today_total: number; + today_succeeded: number; + today_failed: number; + running: number; + active_users_7d: number; + + by_status: Record; + recent_tasks: Array<{ + id: number; + task_no: string; + name: string; + status: TaskStatus; + skill_code: string; + executor_type: 'LOBSTER' | 'SCRIPT'; + product_id: number | null; + operator_name: string | null; + cost_ms: number; + created_at: string | null; + }>; +} + +export interface DashboardCharts { + days: number; + trend: Array<{ day: string; label: string; total: number; succeeded: number }>; + executor_split: Array<{ key: string; label: string; count: number; percent: number }>; + top_skills: Array<{ skill_code: string; count: number; percent: number }>; + dept_stats: Array<{ + dept_id: number | null; + dept_name: string; + count: number; + percent: number; + }>; +} + +export interface ReportData { + days: number; + daily: Record>; + top_skills: Array<{ skill_code: string; count: number }>; + avg_cost_ms: number; + success_count: number; + failure_count: number; +} + +export interface OpLog { + id: number; + operator_id: number | null; + operator_name: string | null; + module: string; + action: string; + method: string; + path: string; + status_code: number; + ip: string | null; + cost_ms: number; + payload: Record | null; + created_at: string | null; +} + +// ---- P6 LLM ---- + +export interface LLMProvider { + id: number; + code: string; + name: string; + vendor: 'openai' | 'azure' | 'anthropic' | 'custom'; + api_base: string; + api_key_mask: string; + has_api_key: boolean; + description: string | null; + status: 'ACTIVE' | 'DISABLED'; +} + +export interface LLMModel { + id: number; + provider_id: number; + provider_code: string | null; + provider_name: string | null; + model: string; + name: string; + description: string | null; + max_context: number; + price_input_per_1k: number; + price_output_per_1k: number; + status: 'ACTIVE' | 'DISABLED'; +} + +export interface LLMRoutingRule { + id: number; + name: string; + priority: number; + conditions: Record | null; + model_id: number; + model_label: string | null; + enabled: boolean; + description: string | null; +} + +export interface LLMUsage { + id: number; + provider_id: number | null; + provider_code: string | null; + model_id: number | null; + model: string | null; + operator_id: number | null; + operator_name: string | null; + source: string; + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + cost_usd: number; + result: 'ok' | 'failed'; + message: string | null; + cost_ms: number; + created_at: string | null; +} + +export interface LLMUsageSummary { + total_calls: number; + total_cost_usd: number; + by_model: Array<{ + provider_code: string | null; + model: string | null; + calls: number; + prompt_tokens: number; + completion_tokens: number; + cost_usd: number; + }>; +} + +export interface ChatResponse { + provider: string; + model: string; + content: string; + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + cost_usd: number; + cost_ms: number; +} + +export interface ExecutorStatus { + lobster: { + configured: boolean; + mode: 'real' | 'mock'; + api_base: string; + timeout_seconds: number; + poll_interval_seconds: number; + callback_configured: boolean; + task_count: number; + }; + script: { + mode: string; + node: string; + python: string; + node_bin: string | null; + bash_bin: string | null; + task_count: number; + }; + llm: { + providers_active: number; + total_calls: number; + total_cost_usd: number; + }; + meta: { + skill_total: number; + app_env: string; + }; +} + +// 龙虾运行时配置 +export interface LobsterConfig { + api_base: string; + api_base_source: 'db' | 'env' | 'unset'; + + api_key_mask: string; + api_key_source: 'db' | 'env' | 'unset'; + has_api_key: boolean; + + callback_secret_mask: string; + callback_secret_source: 'db' | 'env' | 'unset'; + has_callback_secret: boolean; + + timeout_seconds: number; + timeout_source: 'db' | 'env'; + + poll_interval_seconds: number; + poll_interval_source: 'db' | 'env'; + + mode: 'real' | 'mock'; + note: string | null; +} diff --git a/frontend/src/components/JsonSchemaForm.tsx b/frontend/src/components/JsonSchemaForm.tsx new file mode 100644 index 0000000..378bda5 --- /dev/null +++ b/frontend/src/components/JsonSchemaForm.tsx @@ -0,0 +1,101 @@ +import { DatePicker, Form, Input, InputNumber, Select, Switch } from 'antd'; +import type { FormInstance } from 'antd'; +import dayjs from 'dayjs'; + +/** 精简版 JSON Schema 表单:支持常见标量 + enum + textarea + date-time。*/ +export interface JsonSchema { + type: 'object'; + required?: string[]; + properties: Record; +} + +export interface FieldSchema { + type: 'string' | 'number' | 'integer' | 'boolean'; + title?: string; + description?: string; + format?: 'textarea' | 'date-time' | 'password' | 'email'; + enum?: (string | number)[]; + default?: unknown; + maxLength?: number; + minLength?: number; + minimum?: number; + maximum?: number; +} + +interface Props { + schema: JsonSchema | null | undefined; + form: FormInstance; + disabled?: boolean; +} + +function renderField(name: string, f: FieldSchema, required: boolean) { + const rules: Record[] = []; + if (required) rules.push({ required: true, message: `${f.title || name} 必填` }); + if (f.type === 'string' && f.format === 'email') rules.push({ type: 'email' }); + + let widget: React.ReactNode; + if (f.enum && f.enum.length > 0) { + widget = ( + ; + } + + return ( + (value ? (value as dayjs.Dayjs).toISOString() : null) + : undefined + } + getValueProps={ + f.format === 'date-time' + ? (value) => ({ value: value ? dayjs(value as string) : undefined }) + : undefined + } + > + {widget} + + ); +} + +export default function JsonSchemaForm({ schema, form, disabled }: Props) { + if (!schema || schema.type !== 'object' || !schema.properties) { + return
无参数 schema
; + } + const required = new Set(schema.required || []); + return ( +
+ {Object.entries(schema.properties).map(([name, f]) => + renderField(name, f, required.has(name)), + )} +
+ ); +} diff --git a/frontend/src/components/PermButton.tsx b/frontend/src/components/PermButton.tsx new file mode 100644 index 0000000..418b821 --- /dev/null +++ b/frontend/src/components/PermButton.tsx @@ -0,0 +1,15 @@ +import { Button, ButtonProps } from 'antd'; +import { useAuthStore } from '@/stores/auth'; + +interface Props extends ButtonProps { + /** 所需权限码;为空时始终显示 */ + code?: string; + /** 无权限时:hide 隐藏 / disable 禁用 */ + fallback?: 'hide' | 'disable'; +} + +export default function PermButton({ code, fallback = 'hide', ...rest }: Props) { + const allowed = useAuthStore((s) => (code ? s.hasPerm(code) : true)); + if (!allowed && fallback === 'hide') return null; + return } + /> + ); +} diff --git a/frontend/src/pages/Placeholder.tsx b/frontend/src/pages/Placeholder.tsx new file mode 100644 index 0000000..f8a4ac2 --- /dev/null +++ b/frontend/src/pages/Placeholder.tsx @@ -0,0 +1,37 @@ +import { Card, Empty, Typography, Tag } from 'antd'; +import { useLocation } from 'react-router-dom'; + +const { Title, Paragraph } = Typography; + +interface Props { + title: string; + phase: string; + permCode?: string; +} + +/** + * 业务页占位:P2 仅用于联调动态菜单 + 路由守卫。 + * P3/P4/P5/P6 逐页替换为真实实现。 + */ +export default function Placeholder({ title, phase, permCode }: Props) { + const location = useLocation(); + return ( + + + {title} + + + 路径:{location.pathname} + {permCode ? ( + <> + {' · 权限码:'} + {permCode} + + ) : null} + {' · 所属阶段:'} + {phase} + + + + ); +} diff --git a/frontend/src/pages/auth/menu/index.tsx b/frontend/src/pages/auth/menu/index.tsx new file mode 100644 index 0000000..c3250a2 --- /dev/null +++ b/frontend/src/pages/auth/menu/index.tsx @@ -0,0 +1,316 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Button, + Card, + Col, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Row, + Select, + Space, + Table, + Tag, + Tree, + message, +} from 'antd'; +import type { TableProps } from 'antd'; +import { + DeleteOutlined, + EditOutlined, + PlusOutlined, + ReloadOutlined, +} from '@ant-design/icons'; +import { menuApi } from '@/api/modules/menu'; +import type { MenuItem, MenuTreeNode } from '@/api/types'; + +interface FormValues { + name: string; + type: 'dir' | 'menu' | 'button'; + parent_id?: number | null; + path?: string; + component?: string; + icon?: string; + perm_code?: string; + sort?: number; + status: 'ACTIVE' | 'DISABLED'; +} + +function flattenTree(tree: MenuTreeNode[]): { value: number; label: string }[] { + const res: { value: number; label: string }[] = []; + const walk = (nodes: MenuTreeNode[], prefix: string) => { + for (const n of nodes) { + if (n.type === 'button') continue; + const label = prefix + n.name; + res.push({ value: n.id, label }); + if (n.children?.length) walk(n.children, label + ' / '); + } + }; + walk(tree, ''); + return res; +} + +interface AntNode { + title: React.ReactNode; + key: number; + menu: MenuTreeNode; + children?: AntNode[]; +} + +function toAntTreeData(tree: MenuTreeNode[]): AntNode[] { + return tree.map((n) => ({ + title: ( + + {n.name} + {n.type} + {n.perm_code ? {n.perm_code} : null} + + ), + key: n.id, + menu: n, + children: n.children?.length ? toAntTreeData(n.children) : undefined, + })); +} + +export default function MenuPage() { + const [tree, setTree] = useState([]); + const [children, setChildren] = useState([]); // 右侧列表:选中节点的直接子项(含按钮) + const [flatAll, setFlatAll] = useState([]); + const [selected, setSelected] = useState(null); + const [loading, setLoading] = useState(false); + + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const load = useCallback(async () => { + setLoading(true); + try { + const [t, list] = await Promise.all([menuApi.tree(), menuApi.list()]); + setTree(t); + setFlatAll(list); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + useEffect(() => { + if (!selected) { + setChildren([]); + return; + } + setChildren(flatAll.filter((m) => m.parent_id === selected.id)); + }, [selected, flatAll]); + + const parentOptions = useMemo( + () => [{ value: 0, label: '(顶级)' }, ...flattenTree(tree)], + [tree], + ); + + const openCreate = (parent?: MenuTreeNode) => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ + type: parent ? (parent.type === 'dir' ? 'menu' : 'button') : 'dir', + parent_id: parent?.id ?? null, + sort: 0, + status: 'ACTIVE', + }); + setModalOpen(true); + }; + + const openEdit = (m: MenuItem) => { + setEditing(m); + form.resetFields(); + form.setFieldsValue({ + name: m.name, + type: m.type, + parent_id: m.parent_id, + path: m.path ?? undefined, + component: m.component ?? undefined, + icon: m.icon ?? undefined, + perm_code: m.perm_code ?? undefined, + sort: m.sort, + status: m.status, + }); + setModalOpen(true); + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + const payload = { + ...values, + parent_id: values.parent_id && values.parent_id > 0 ? values.parent_id : null, + }; + if (editing) { + await menuApi.update(editing.id, payload); + message.success('已更新'); + } else { + await menuApi.create(payload); + message.success('已创建'); + } + setModalOpen(false); + await load(); + }; + + const onDelete = async (m: MenuItem) => { + await menuApi.remove(m.id); + message.success('已删除'); + await load(); + }; + + const rightColumns: TableProps['columns'] = [ + { title: '名称', dataIndex: 'name' }, + { + title: '类型', + dataIndex: 'type', + width: 100, + render: (v) => {v}, + }, + { title: 'perm_code', dataIndex: 'perm_code', render: (v) => v ? {v} : null }, + { title: '路径', dataIndex: 'path' }, + { title: '排序', dataIndex: 'sort', width: 70 }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (v) => + v === 'ACTIVE' ? 启用 : 禁用, + }, + { + title: '操作', + key: 'op', + width: 160, + render: (_, m) => ( + + + onDelete(m)}> + + + + ), + }, + ]; + + return ( + <> + + + + + + + } + > + { + const m = (info.node as unknown as { menu: MenuTreeNode }).menu; + setSelected(m); + }} + /> + + + + } onClick={() => openCreate(selected)}> + 新增子项 + + ) : null + } + > + + + + + + setModalOpen(false)} + onOk={onSubmit} + okText="保存" + destroyOnClose + width={600} + > + form={form} layout="vertical"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/pages/auth/role/index.tsx b/frontend/src/pages/auth/role/index.tsx new file mode 100644 index 0000000..ec40f79 --- /dev/null +++ b/frontend/src/pages/auth/role/index.tsx @@ -0,0 +1,430 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, + Button, + Card, + Form, + Input, + Modal, + Popconfirm, + Select, + Space, + Table, + Tabs, + Tag, + Tree, + message, +} from 'antd'; +import type { TableProps } from 'antd'; +import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; +import { roleApi } from '@/api/modules/role'; +import { menuApi } from '@/api/modules/menu'; +import { orgApi } from '@/api/modules/organization'; +import { productApi } from '@/api/modules/product'; +import { skillApi } from '@/api/modules/skill'; +import { platformAccountApi } from '@/api/modules/platform-account'; +import type { + MenuTreeNode, + OrgTreeNode, + PlatformAccount, + Product, + Role, + RoleDetail, + Skill, +} from '@/api/types'; + +interface RoleFormValues { + name: string; + code: string; + dept_id?: number | null; + description?: string; + status: 'ACTIVE' | 'DISABLED'; + menu_ids: number[]; + product_scope_ids: number[]; + skill_scope_ids: number[]; + account_scope_ids: number[]; +} + +function orgOptions(tree: OrgTreeNode[]): { value: number; label: string }[] { + const res: { value: number; label: string }[] = []; + const walk = (nodes: OrgTreeNode[], prefix: string) => { + for (const n of nodes) { + const label = prefix + n.name; + res.push({ value: n.id, label }); + if (n.children?.length) walk(n.children, label + ' / '); + } + }; + walk(tree, ''); + return res; +} + +interface AntTreeNode { + title: React.ReactNode; + key: number; + children?: AntTreeNode[]; +} + +function menuToTree(nodes: MenuTreeNode[]): AntTreeNode[] { + return nodes.map((n) => ({ + title: ( + + {n.name} + {n.perm_code ? {n.perm_code} : null} + + ), + key: n.id, + children: n.children?.length ? menuToTree(n.children) : undefined, + })); +} + +export default function RolePage() { + const [list, setList] = useState([]); + const [loading, setLoading] = useState(false); + const [menuTree, setMenuTree] = useState([]); + const [orgs, setOrgs] = useState([]); + const [products, setProducts] = useState([]); + const [skills, setSkills] = useState([]); + const [accounts, setAccounts] = useState([]); + + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [activeTab, setActiveTab] = useState('basic'); + const [form] = Form.useForm(); + + const load = useCallback(async () => { + setLoading(true); + try { + const [roles, mt, ot, pRes, sRes, aRes] = await Promise.all([ + roleApi.list(), + menuApi.tree(), + orgApi.tree(), + productApi.list({ page: 1, size: 200 }), + skillApi.list({ page: 1, size: 200 }), + platformAccountApi.list({ page: 1, size: 200 }), + ]); + setList(roles); + setMenuTree(mt); + setOrgs(ot); + setProducts(pRes.list); + setSkills(sRes.list); + setAccounts(aRes.list); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const deptOptions = useMemo( + () => [{ value: 0, label: '(全局)' }, ...orgOptions(orgs)], + [orgs], + ); + const productOptions = useMemo( + () => + products.map((p) => ({ + value: p.id, + label: `${p.name} (${p.code})${p.platform_name ? ` · ${p.platform_name}` : ''}`, + })), + [products], + ); + const skillOptions = useMemo( + () => + skills.map((s) => ({ + value: s.id, + label: `${s.name} (${s.code}) · ${s.executor_type}`, + })), + [skills], + ); + const accountOptions = useMemo( + () => + accounts.map((a) => ({ + value: a.id, + label: `${a.platform_name} / ${a.login}${ + a.owner_name ? ` (owner: ${a.owner_name})` : '' + }`, + })), + [accounts], + ); + + const openCreate = () => { + setEditing(null); + setActiveTab('basic'); + form.resetFields(); + form.setFieldsValue({ + status: 'ACTIVE', + menu_ids: [], + product_scope_ids: [], + skill_scope_ids: [], + account_scope_ids: [], + }); + setModalOpen(true); + }; + + const openEdit = async (r: Role) => { + const detail = await roleApi.get(r.id); + setEditing(detail); + setActiveTab('basic'); + form.resetFields(); + form.setFieldsValue({ + name: detail.name, + code: detail.code, + dept_id: detail.dept_id, + description: detail.description ?? undefined, + status: detail.status, + menu_ids: detail.menu_ids, + product_scope_ids: detail.product_scope_ids ?? [], + skill_scope_ids: detail.skill_scope_ids ?? [], + account_scope_ids: detail.account_scope_ids ?? [], + }); + setModalOpen(true); + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + const payload = { + ...values, + dept_id: values.dept_id && values.dept_id > 0 ? values.dept_id : null, + }; + if (editing) { + await roleApi.update(editing.id, payload); + message.success('已更新'); + } else { + await roleApi.create(payload); + message.success('已创建'); + } + setModalOpen(false); + await load(); + }; + + const onDelete = async (r: Role) => { + await roleApi.remove(r.id); + message.success('已删除'); + await load(); + }; + + const columns: TableProps['columns'] = [ + { title: 'ID', dataIndex: 'id', width: 60 }, + { title: '角色名', dataIndex: 'name' }, + { title: 'code', dataIndex: 'code', render: (v) => {v} }, + { + title: '部门', + dataIndex: 'dept_id', + render: (v) => + v ? deptOptions.find((o) => o.value === v)?.label ?? v : 全局, + }, + { + title: '状态', + dataIndex: 'status', + render: (v) => + v === 'ACTIVE' ? 启用 : 禁用, + }, + { + title: '内置', + dataIndex: 'is_builtin', + render: (v) => (v ? 内置 : null), + }, + { + title: '操作', + key: 'op', + fixed: 'right', + width: 200, + render: (_, r) => ( + + + onDelete(r)} + > + + + + ), + }, + ]; + + return ( + <> + + + + + } + > +
+ + + setModalOpen(false)} + onOk={onSubmit} + okText="保存" + width={760} + destroyOnClose + > + form={form} layout="vertical"> + + + + + + + + + + + + + + + ), + }, + { + key: 'menu', + label: '菜单权限', + children: ( + + (Array.isArray(keys) ? keys : (keys?.checked ?? [])) as number[] + } + > + + + ), + }, + { + key: 'product', + label: '产品范围', + children: ( + <> + + + + + + ), + }, + { + key: 'account', + label: '平台账号', + children: ( + <> + + + } + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + onPressEnter={() => setPage(1)} + style={{ width: 240 }} + /> + + + + } + > +
{ + setPage(p); + setSize(s); + }, + }} + /> + + + setModalOpen(false)} + onOk={onSubmit} + okText="保存" + destroyOnClose + > + form={form} layout="vertical"> + + + + {!editing && ( + + + + )} + + + + + + + + + + +
+ + + {/* —— 系统状态 —— */} + + + 系统状态 + + } + > + + + + ); +} diff --git a/frontend/src/pages/log/op/index.tsx b/frontend/src/pages/log/op/index.tsx new file mode 100644 index 0000000..ad71aa8 --- /dev/null +++ b/frontend/src/pages/log/op/index.tsx @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Button, Card, Drawer, Select, Space, Table, Tag } from 'antd'; +import type { TableProps } from 'antd'; +import { ReloadOutlined } from '@ant-design/icons'; +import { opLogApi } from '@/api/modules/dashboard'; +import type { OpLog } from '@/api/types'; + +const ACTION_COLOR: Record = { + create: 'green', + update: 'blue', + delete: 'red', + reset_password: 'volcano', + test: 'geekblue', +}; + +export default function OpLogPage() { + const [list, setList] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [size, setSize] = useState(20); + const [module, setModule] = useState(); + const [loading, setLoading] = useState(false); + + const [detail, setDetail] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await opLogApi.list({ page, size, module }); + setList(res.list); + setTotal(res.total); + } finally { + setLoading(false); + } + }, [page, size, module]); + + useEffect(() => { + load(); + }, [load]); + + const moduleOpts = [ + 'organizations', + 'roles', + 'users', + 'menus', + 'products', + 'channel-codes', + 'platforms', + 'platform-accounts', + 'skills', + 'scripts', + 'tasks', + 'schedules', + ].map((m) => ({ value: m, label: m })); + + const columns: TableProps['columns'] = [ + { title: 'ID', dataIndex: 'id', width: 80 }, + { title: '时间', dataIndex: 'created_at', width: 180 }, + { title: '操作人', dataIndex: 'operator_name', width: 100 }, + { title: '模块', dataIndex: 'module', width: 140, render: (v) => {v} }, + { + title: '动作', + dataIndex: 'action', + width: 120, + render: (v: string) => {v}, + }, + { title: 'HTTP', dataIndex: 'method', width: 80, render: (v) => {v} }, + { title: '路径', dataIndex: 'path', ellipsis: true }, + { + title: '响应码', + dataIndex: 'status_code', + width: 80, + render: (v: number) => ( + {v} + ), + }, + { title: 'IP', dataIndex: 'ip', width: 130 }, + { title: '耗时 (ms)', dataIndex: 'cost_ms', width: 90 }, + { + title: '操作', + key: 'op', + fixed: 'right', + width: 80, + render: (_, r) => ( + + ), + }, + ]; + + return ( + <> + +
{ + setPage(p); + setSize(s); + }, + }} + /> + + + setDetailOpen(false)} + title={detail ? `OpLog #${detail.id}` : ''} + width={640} + destroyOnClose + > + {detail ? ( + <> + + {detail.method} {detail.path} → {detail.status_code} · {detail.cost_ms} ms +
操作人:{detail.operator_name ?? '-'} · IP {detail.ip ?? '-'}
+
+ +
+                {JSON.stringify(detail.payload ?? {}, null, 2)}
+              
+
+ + ) : null} +
+ + ); +} diff --git a/frontend/src/pages/log/report/index.tsx b/frontend/src/pages/log/report/index.tsx new file mode 100644 index 0000000..643ea78 --- /dev/null +++ b/frontend/src/pages/log/report/index.tsx @@ -0,0 +1,165 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Card, + Col, + Empty, + Progress, + Row, + Segmented, + Space, + Statistic, + Table, + Tag, + Typography, +} from 'antd'; +import type { TableProps } from 'antd'; +import { dashboardApi } from '@/api/modules/dashboard'; +import type { ReportData } from '@/api/types'; + +const { Title } = Typography; + +const STATUS_COLOR: Record = { + SUCCEEDED: '#22c55e', + FAILED: '#ef4444', + TIMEOUT: '#f59e0b', + CANCELED: '#94a3b8', + RUNNING: '#3b82f6', + PENDING: '#6b7280', + QUEUED: '#06b6d4', +}; + +export default function ReportPage() { + const [days, setDays] = useState(7); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + (async () => { + setLoading(true); + try { + setData(await dashboardApi.report(days)); + } finally { + setLoading(false); + } + })(); + }, [days]); + + const dailyRows = useMemo(() => { + if (!data) return []; + return Object.entries(data.daily) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, rec]) => ({ date, ...rec })); + }, [data]); + + const total = (data?.success_count ?? 0) + (data?.failure_count ?? 0); + const successRate = total > 0 ? Math.round(((data?.success_count ?? 0) / total) * 100) : 0; + + const dailyCols: TableProps>['columns'] = [ + { title: '日期', dataIndex: 'date', width: 120 }, + ...( + ['SUCCEEDED', 'FAILED', 'TIMEOUT', 'CANCELED', 'RUNNING', 'QUEUED', 'PENDING'] as const + ).map((s) => ({ + title: s, + dataIndex: s, + width: 100, + render: (v: number | undefined) => + v ? ( + + {v} + + ) : ( + - + ), + })), + ]; + + return ( + + setDays(Number(v))} + options={[ + { value: 7, label: '近 7 天' }, + { value: 14, label: '近 14 天' }, + { value: 30, label: '近 30 天' }, + ]} + /> + } + > + + + + + + + + + + + + + + + + + + 成功率 + + + + + + + {dailyRows.length === 0 ? ( + + ) : ( +
[]} + size="small" + pagination={false} + scroll={{ x: 'max-content' }} + /> + )} + + + + {(!data || data.top_skills.length === 0) ? ( + + ) : ( + data.top_skills.map((t) => ( +
+
+ {t.skill_code} + {t.count} +
+ +
+ )) + )} +
+ + ); +} diff --git a/frontend/src/pages/login/index.tsx b/frontend/src/pages/login/index.tsx new file mode 100644 index 0000000..1e6a4df --- /dev/null +++ b/frontend/src/pages/login/index.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import { Alert, Button, Card, Form, Input, Typography, message } from 'antd'; +import { LockOutlined, UserOutlined } from '@ant-design/icons'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { authApi } from '@/api/modules/auth'; +import { useAuthStore } from '@/stores/auth'; +import type { LoginPayload } from '@/api/types'; + +const { Title, Text } = Typography; + +export default function LoginPage() { + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + const [params] = useSearchParams(); + const setTokens = useAuthStore((s) => s.setTokens); + const bootstrap = useAuthStore((s) => s.bootstrap); + + const onFinish = async (values: LoginPayload) => { + setLoading(true); + try { + const result = await authApi.login(values); + setTokens(result.access_token, result.refresh_token); + const user = await bootstrap(); + if (!user) { + message.error('登录成功但加载用户信息失败'); + return; + } + message.success(`欢迎回来,${user.nickname || user.username}`); + const redirect = params.get('redirect') || '/'; + navigate(redirect, { replace: true }); + } catch { + // http 拦截器已弹 Toast + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+ + LOOM + + Agent 任务自动化平台 +
+ + + 超管:admin / admin123(全部菜单) +
+ 业务员:operator / operator123(仅仪表盘 + Skill + 任务) +
+ } + /> + + + layout="vertical" + size="large" + onFinish={onFinish} + autoComplete="off" + initialValues={{ username: 'admin', password: 'admin123' }} + > + + } placeholder="用户名" /> + + + + } placeholder="密码" /> + + + + + + + + + ); +} diff --git a/frontend/src/pages/platform-account/index.tsx b/frontend/src/pages/platform-account/index.tsx new file mode 100644 index 0000000..f753dfb --- /dev/null +++ b/frontend/src/pages/platform-account/index.tsx @@ -0,0 +1,306 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + Alert, + Button, + Card, + Form, + Input, + Modal, + Popconfirm, + Select, + Space, + Table, + Tag, + Tooltip, + message, +} from 'antd'; +import type { TableProps } from 'antd'; +import { + CheckCircleTwoTone, + CloseCircleTwoTone, + DeleteOutlined, + EditOutlined, + PlusOutlined, + QuestionCircleOutlined, + ReloadOutlined, + SearchOutlined, + ThunderboltOutlined, +} from '@ant-design/icons'; +import { platformAccountApi } from '@/api/modules/platform-account'; +import type { + PlatformAccountCreatePayload, + PlatformAccountUpdatePayload, +} from '@/api/modules/platform-account'; +import type { PlatformAccount } from '@/api/types'; + +interface FormValues { + platform_name: string; + product_code?: string; + login: string; + password?: string; + google_key?: string; + remark?: string; + status: 'ACTIVE' | 'DISABLED'; +} + +function TestResultTag({ acc }: { acc: PlatformAccount }) { + if (acc.last_test_result === 'ok') + return ( + + } color="success"> + 连通 + + + ); + if (acc.last_test_result === 'failed') + return ( + + } color="error"> + 失败 + + + ); + return ( + } color="default"> + 未测试 + + ); +} + +export default function PlatformAccountPage() { + const [list, setList] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [size, setSize] = useState(20); + const [keyword, setKeyword] = useState(''); + const [loading, setLoading] = useState(false); + + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await platformAccountApi.list({ + page, + size, + keyword: keyword || undefined, + }); + setList(res.list); + setTotal(res.total); + } finally { + setLoading(false); + } + }, [page, size, keyword]); + + useEffect(() => { + load(); + }, [load]); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ status: 'ACTIVE' }); + setModalOpen(true); + }; + + const openEdit = (acc: PlatformAccount) => { + setEditing(acc); + form.resetFields(); + form.setFieldsValue({ + platform_name: acc.platform_name, + product_code: acc.product_code ?? undefined, + login: acc.login, + remark: acc.remark ?? undefined, + status: acc.status, + }); + setModalOpen(true); + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + if (editing) { + const payload: PlatformAccountUpdatePayload = { + platform_name: values.platform_name, + product_code: values.product_code, + login: values.login, + remark: values.remark, + status: values.status, + }; + if (values.password) payload.password = values.password; + if (values.google_key !== undefined) payload.google_key = values.google_key; + await platformAccountApi.update(editing.id, payload); + message.success('已更新'); + } else { + if (!values.password) { + message.error('新账号必须填写密码'); + return; + } + await platformAccountApi.create(values as PlatformAccountCreatePayload); + message.success('已创建'); + } + setModalOpen(false); + await load(); + }; + + const onDelete = async (acc: PlatformAccount) => { + await platformAccountApi.remove(acc.id); + message.success('已删除'); + await load(); + }; + + const onTest = async (acc: PlatformAccount) => { + const result = await platformAccountApi.test(acc.id); + if (result.last_test_result === 'ok') { + message.success('连通测试通过'); + } else { + message.error(`连通失败:${result.last_test_message ?? '未知'}`); + } + await load(); + }; + + const columns: TableProps['columns'] = [ + { title: 'ID', dataIndex: 'id', width: 60 }, + { title: '平台', dataIndex: 'platform_name', render: (v) => {v} }, + { title: '产品代号', dataIndex: 'product_code' }, + { title: '登录账号', dataIndex: 'login' }, + { title: '密码', dataIndex: 'password_mask' }, + { + title: 'GoogleKey', + dataIndex: 'has_google_key', + render: (has: boolean, r) => + has ? r.google_key_mask : 无, + }, + { title: '拥有者', dataIndex: 'owner_name' }, + { + title: '连通', + dataIndex: 'last_test_result', + render: (_v, r) => , + }, + { + title: '状态', + dataIndex: 'status', + render: (v) => + v === 'ACTIVE' ? 启用 : 禁用, + }, + { + title: '操作', + key: 'op', + fixed: 'right', + width: 260, + render: (_, r) => ( + + + + onDelete(r)}> + + + + ), + }, + ]; + + return ( + <> + + } + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + onPressEnter={() => setPage(1)} + style={{ width: 220 }} + /> + + + + } + > + +
{ + setPage(p); + setSize(s); + }, + }} + /> + + + setModalOpen(false)} + onOk={onSubmit} + okText="保存" + destroyOnClose + > + form={form} layout="vertical"> + + + + + + + + + + + + + + + + + + + +
{ + setPage(p); + setSize(s); + }, + }} + /> + + setModalOpen(false)} + onOk={onSubmit} + okText="保存" + destroyOnClose + > + form={form} layout="vertical"> + + + + + + + + + + + + + ); +} + +export default function ChannelCodePage() { + const [tab, setTab] = useState<'INTERNAL' | 'NATURAL'>('INTERNAL'); + const [keyword, setKeyword] = useState(''); + + return ( + } + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + style={{ width: 240 }} + /> + } + > + setTab(k as 'INTERNAL' | 'NATURAL')} + items={[ + { + key: 'INTERNAL', + label: '内导', + children: , + }, + { + key: 'NATURAL', + label: '自然', + children: , + }, + ]} + /> + + ); +} diff --git a/frontend/src/pages/product/list/index.tsx b/frontend/src/pages/product/list/index.tsx new file mode 100644 index 0000000..c9a8470 --- /dev/null +++ b/frontend/src/pages/product/list/index.tsx @@ -0,0 +1,297 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Button, + Card, + Form, + Input, + Modal, + Popconfirm, + Select, + Space, + Table, + Tag, + message, +} from 'antd'; +import type { TableProps } from 'antd'; +import { + DeleteOutlined, + EditOutlined, + PlusOutlined, + ReloadOutlined, + SearchOutlined, +} from '@ant-design/icons'; +import { productApi } from '@/api/modules/product'; +import type { ProductPayload } from '@/api/modules/product'; +import { platformApi } from '@/api/modules/platform'; +import { channelCodeApi } from '@/api/modules/channel-code'; +import { orgApi } from '@/api/modules/organization'; +import type { ChannelCode, OrgTreeNode, Platform, Product } from '@/api/types'; + +interface FormValues { + code: string; + name: string; + platform_id?: number | null; + dept_id?: number | null; + description?: string; + status: 'ACTIVE' | 'DISABLED'; + channel_code_ids: number[]; +} + +function orgOptions(tree: OrgTreeNode[]) { + const res: { value: number; label: string }[] = []; + const walk = (nodes: OrgTreeNode[], prefix: string) => { + for (const n of nodes) { + const l = prefix + n.name; + res.push({ value: n.id, label: l }); + if (n.children?.length) walk(n.children, l + ' / '); + } + }; + walk(tree, ''); + return res; +} + +export default function ProductPage() { + const [list, setList] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [size, setSize] = useState(20); + const [keyword, setKeyword] = useState(''); + const [platformId, setPlatformId] = useState(); + const [loading, setLoading] = useState(false); + + const [platforms, setPlatforms] = useState([]); + const [channels, setChannels] = useState([]); + const [orgs, setOrgs] = useState([]); + + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const load = useCallback(async () => { + setLoading(true); + try { + const [res, ps, cs, ot] = await Promise.all([ + productApi.list({ + page, + size, + keyword: keyword || undefined, + platform_id: platformId, + }), + platformApi.list(), + channelCodeApi.listAll(), + orgApi.tree(), + ]); + setList(res.list); + setTotal(res.total); + setPlatforms(ps); + setChannels(cs); + setOrgs(ot); + } finally { + setLoading(false); + } + }, [page, size, keyword, platformId]); + + useEffect(() => { + load(); + }, [load]); + + const platformOpts = useMemo( + () => platforms.map((p) => ({ value: p.id, label: p.name })), + [platforms], + ); + const channelOpts = useMemo( + () => channels.map((c) => ({ value: c.id, label: `${c.name} (${c.type})` })), + [channels], + ); + const deptOpts = useMemo(() => orgOptions(orgs), [orgs]); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ status: 'ACTIVE', channel_code_ids: [] }); + setModalOpen(true); + }; + + const openEdit = (p: Product) => { + setEditing(p); + form.resetFields(); + form.setFieldsValue({ + code: p.code, + name: p.name, + platform_id: p.platform_id ?? undefined, + dept_id: p.dept_id ?? undefined, + description: p.description ?? undefined, + status: p.status, + channel_code_ids: p.channel_code_ids, + }); + setModalOpen(true); + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + const payload: ProductPayload = { + ...values, + platform_id: values.platform_id ?? null, + dept_id: values.dept_id ?? null, + }; + if (editing) { + delete (payload as { code?: string }).code; + await productApi.update(editing.id, payload); + message.success('已更新'); + } else { + await productApi.create(payload); + message.success('已创建'); + } + setModalOpen(false); + await load(); + }; + + const columns: TableProps['columns'] = [ + { title: 'ID', dataIndex: 'id', width: 60 }, + { title: 'code', dataIndex: 'code', render: (v) => {v} }, + { title: '产品名', dataIndex: 'name' }, + { title: '平台', dataIndex: 'platform_name', render: (v) => v || '-' }, + { title: '部门', dataIndex: 'dept_name' }, + { + title: '渠道码', + dataIndex: 'channel_code_names', + render: (names: string[]) => names.map((n) => {n}), + }, + { + title: '状态', + dataIndex: 'status', + render: (v) => + v === 'ACTIVE' ? 启用 : 禁用, + }, + { + title: '操作', + key: 'op', + fixed: 'right', + width: 180, + render: (_, p) => ( + + + { + await productApi.remove(p.id); + message.success('已删除'); + await load(); + }} + > + + + + ), + }, + ]; + + return ( + <> + + } + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + onPressEnter={() => setPage(1)} + style={{ width: 220 }} + /> +
{ + setPage(p); + setSize(s); + }, + }} + /> + + + setModalOpen(false)} + onOk={onSubmit} + okText="保存" + destroyOnClose + width={600} + > + form={form} layout="vertical"> + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/pages/product/suite/index.tsx b/frontend/src/pages/product/suite/index.tsx new file mode 100644 index 0000000..f1fecf5 --- /dev/null +++ b/frontend/src/pages/product/suite/index.tsx @@ -0,0 +1,191 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + Button, + Card, + Col, + Empty, + Form, + Input, + Modal, + Popconfirm, + Row, + Select, + Space, + Tag, + Typography, + message, +} from 'antd'; +import { + DeleteOutlined, + EditOutlined, + LinkOutlined, + PlusOutlined, + ReloadOutlined, +} from '@ant-design/icons'; +import { platformApi } from '@/api/modules/platform'; +import type { Platform } from '@/api/types'; + +const { Text } = Typography; + +interface FormValues { + code: string; + name: string; + url?: string; + description?: string; + status: 'ACTIVE' | 'DISABLED'; +} + +export default function PlatformSuitePage() { + const [list, setList] = useState([]); + const [loading, setLoading] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const load = useCallback(async () => { + setLoading(true); + try { + setList(await platformApi.list()); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ status: 'ACTIVE' }); + setModalOpen(true); + }; + const openEdit = (p: Platform) => { + setEditing(p); + form.resetFields(); + form.setFieldsValue({ + code: p.code, + name: p.name, + url: p.url ?? undefined, + description: p.description ?? undefined, + status: p.status, + }); + setModalOpen(true); + }; + const onSubmit = async () => { + const values = await form.validateFields(); + if (editing) { + await platformApi.update(editing.id, values); + } else { + await platformApi.create(values); + } + message.success('已保存'); + setModalOpen(false); + await load(); + }; + + return ( + <> + + + + + } + > + {list.length === 0 && !loading ? ( + + ) : ( + + {list.map((p) => ( + + + {p.name} + {p.code} + {p.status === 'DISABLED' ? 已禁用 : null} + + } + extra={ + + + { + await platformApi.remove(p.id); + message.success('已删除'); + await load(); + }} + > + + + + } + > +
{ + setPage(p); + setSize(s); + }, + }} + /> + + + {/* 编辑弹窗 */} + setModalOpen(false)} + onOk={onSubmit} + okText="保存" + destroyOnClose + width={780} + > + form={form} layout="vertical"> + + + + + + + + + + + + + + ) : ( + + + + + ), + }, + { + key: 'schema', + label: '参数 Schema', + children: ( + <> + + { + const r = parseJsonSafe(v); + return r.ok ? Promise.resolve() : Promise.reject(new Error(r.message)); + }, + }, + ]} + > + + + { + const r = parseJsonSafe(v); + return r.ok ? Promise.resolve() : Promise.reject(new Error(r.message)); + }, + }, + ]} + > + + + + ), + }, + { + key: 'scope', + label: '产品范围', + children: ( + + } + value={skillCode} + onChange={(e) => setSkillCode(e.target.value)} + style={{ width: 200 }} + /> + setSource(v)} + style={{ width: 110 }} + /> + + + } + > +
{ + setPage(p); + setSize(s); + }, + }} + /> + + + setDetailOpen(false)} + title={detail ? `调用详情 #${detail.id} · ${detail.skill_code}` : ''} + width={640} + destroyOnClose + > + {detail ? ( + <> + +
+ 结果: + {detail.result === 'ok' ? ( + 成功 + ) : ( + 失败 + )} + {detail.cost_ms} ms + {detail.source} +
+
+ 操作人: + {detail.operator_name ?? '-'} +
+
+ 消息: + {detail.message ?? '-'} +
+
+ +
+                {JSON.stringify(detail.input_data ?? {}, null, 2)}
+              
+
+ +
+                {JSON.stringify(detail.output_data ?? {}, null, 2)}
+              
+
+ + ) : null} +
+ + ); +} diff --git a/frontend/src/pages/system/cron/index.tsx b/frontend/src/pages/system/cron/index.tsx new file mode 100644 index 0000000..cb5b739 --- /dev/null +++ b/frontend/src/pages/system/cron/index.tsx @@ -0,0 +1,314 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + Button, + Card, + Drawer, + Form, + Input, + Modal, + Popconfirm, + Select, + Space, + Switch, + Table, + Tag, + message, +} from 'antd'; +import type { TableProps } from 'antd'; +import { + DeleteOutlined, + EditOutlined, + PlusOutlined, + ReloadOutlined, + ThunderboltOutlined, +} from '@ant-design/icons'; +import { scheduleApi } from '@/api/modules/schedule'; +import { skillApi } from '@/api/modules/skill'; +import { productApi } from '@/api/modules/product'; +import type { Product, Schedule, ScheduleLog, Skill } from '@/api/types'; + +interface FormValues { + name: string; + skill_id: number; + product_id?: number | null; + cron: string; + enabled: boolean; + input_data?: string; +} + +function parseJson(s: string | undefined): Record { + if (!s || !s.trim()) return {}; + try { + return JSON.parse(s); + } catch { + throw new Error('JSON 解析失败'); + } +} + +export default function CronPage() { + const [list, setList] = useState([]); + const [loading, setLoading] = useState(false); + + const [skills, setSkills] = useState([]); + const [products, setProducts] = useState([]); + + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const [logsOpen, setLogsOpen] = useState(false); + const [logs, setLogs] = useState([]); + const [logsSchedule, setLogsSchedule] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const [l, s, p] = await Promise.all([ + scheduleApi.list(), + skillApi.list({ page: 1, size: 200 }), + productApi.list({ page: 1, size: 200 }), + ]); + setList(l); + setSkills(s.list); + setProducts(p.list); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ cron: '0 * * * *', enabled: true }); + setModalOpen(true); + }; + + const openEdit = (s: Schedule) => { + setEditing(s); + form.resetFields(); + form.setFieldsValue({ + name: s.name, + skill_id: s.skill_id, + product_id: s.product_id ?? undefined, + cron: s.cron, + enabled: s.enabled, + input_data: s.input_data ? JSON.stringify(s.input_data, null, 2) : '', + }); + setModalOpen(true); + }; + + const onSubmit = async () => { + const values = await form.validateFields(); + let input_data: Record; + try { + input_data = parseJson(values.input_data); + } catch (e) { + message.error((e as Error).message); + return; + } + const payload = { + name: values.name, + skill_id: values.skill_id, + product_id: values.product_id ?? null, + cron: values.cron, + enabled: values.enabled, + input_data, + }; + if (editing) { + await scheduleApi.update(editing.id, payload); + } else { + await scheduleApi.create(payload); + } + message.success('已保存'); + setModalOpen(false); + await load(); + }; + + const openLogs = async (s: Schedule) => { + setLogsSchedule(s); + setLogs(await scheduleApi.logs(s.id)); + setLogsOpen(true); + }; + + const columns: TableProps['columns'] = [ + { title: 'ID', dataIndex: 'id', width: 60 }, + { title: '名称', dataIndex: 'name' }, + { title: 'Skill', dataIndex: 'skill_code', render: (v) => {v} }, + { title: '产品', dataIndex: 'product_name' }, + { title: 'cron', dataIndex: 'cron', render: (v) => {v} }, + { + title: '启用', + dataIndex: 'enabled', + width: 80, + render: (v: boolean, r) => ( + { + await scheduleApi.toggle(r.id, on); + message.success(on ? '已启用' : '已关闭'); + load(); + }} + /> + ), + }, + { title: '上次触发', dataIndex: 'last_run_at', width: 180 }, + { title: '下次触发', dataIndex: 'next_run_at', width: 180 }, + { + title: '操作', + key: 'op', + fixed: 'right', + width: 280, + render: (_, r) => ( + + + + + { + await scheduleApi.remove(r.id); + message.success('已删除'); + load(); + }} + > + + + + } + > +
+ + + setModalOpen(false)} + onOk={onSubmit} + okText="保存" + destroyOnClose + width={640} + > + form={form} layout="vertical"> + + + + + ({ + value: p.id, + label: `${p.name} (${p.code})`, + }))} + optionFilterProp="label" + /> + + + + + + + + + + + + + + setLogsOpen(false)} + title={logsSchedule ? `执行记录 - ${logsSchedule.name}` : ''} + width={560} + destroyOnClose + > +
+ v === 'ok' ? ( + ok + ) : v === 'skipped' ? ( + skipped + ) : ( + failed + ), + }, + { title: 'task', dataIndex: 'task_id', width: 80 }, + { title: '消息', dataIndex: 'message', ellipsis: true }, + ]} + /> + + + ); +} diff --git a/frontend/src/pages/system/executor/index.tsx b/frontend/src/pages/system/executor/index.tsx new file mode 100644 index 0000000..a96ab70 --- /dev/null +++ b/frontend/src/pages/system/executor/index.tsx @@ -0,0 +1,1158 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + Alert, + Badge, + Button, + Card, + Col, + Drawer, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Row, + Select, + Space, + Switch, + Table, + Tabs, + Tag, + Typography, + message, +} from 'antd'; +import type { TableProps } from 'antd'; +import { + ApiOutlined, + DeleteOutlined, + EditOutlined, + MessageOutlined, + PlusOutlined, + ReloadOutlined, + SaveOutlined, + SendOutlined, +} from '@ant-design/icons'; +import { executorApi } from '@/api/modules/executor'; +import { llmApi } from '@/api/modules/llm'; +import { lobsterConfigApi } from '@/api/modules/lobster-config'; +import type { + ChatResponse, + ExecutorStatus, + LLMModel, + LLMProvider, + LLMRoutingRule, + LLMUsageSummary, + LobsterConfig, +} from '@/api/types'; + +const { Text } = Typography; + +function Health() { + const [status, setStatus] = useState(null); + const [usage, setUsage] = useState(null); + const [loading, setLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const [s, u] = await Promise.all([executorApi.status(), llmApi.usageSummary()]); + setStatus(s); + setUsage(u); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + if (!status) return null; + + return ( + <> + + + + + + + + 龙虾 Agent + + } + extra={ + + {status.lobster.mode === 'real' ? '已接入' : 'mock 模式'} + + } + > +
+ API Base: + {status.lobster.api_base} +
+
+ 回调签名: + {status.lobster.callback_configured ? ( + 已配置 + ) : ( + 未配置 + )} +
+
+ HTTP 超时: + {status.lobster.timeout_seconds}s ·{' '} + 兜底轮询: + {status.lobster.poll_interval_seconds}s +
+
+ 累计任务: + {status.lobster.task_count} +
+ {status.lobster.mode === 'mock' ? ( + + ) : null} +
+ + + + + + 脚本引擎 + + } + extra={{status.script.mode}} + > +
+ 节点: + {status.script.node} +
+
+ python: + {status.script.python} +
+
+ node: + {status.script.node_bin ? ( + {status.script.node_bin} + ) : ( + 未安装 + )} +
+
+ bash: + {status.script.bash_bin ? {status.script.bash_bin} : 未安装} +
+
+ 累计任务: + {status.script.task_count} +
+
+ + + + + 0 ? 'success' : 'default'} /> + LLM 用量汇总 + + } + > + + + 可用 Provider +
+ {status.llm.providers_active} +
+ + + 总调用次数 +
+ {usage?.total_calls ?? status.llm.total_calls} +
+ + + 总 token(粗估) +
+ {usage + ? usage.by_model.reduce( + (a, b) => a + b.prompt_tokens + b.completion_tokens, + 0, + ) + : '-'} +
+ + + 累计成本 (USD) +
+ {(usage?.total_cost_usd ?? status.llm.total_cost_usd).toFixed(4)} +
+ + + + + + + ); +} + +function ProvidersTab() { + const [list, setList] = useState([]); + const [loading, setLoading] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const load = useCallback(async () => { + setLoading(true); + try { + setList(await llmApi.listProviders()); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ + vendor: 'openai', + api_base: 'https://api.openai.com/v1', + status: 'ACTIVE', + }); + setModalOpen(true); + }; + + const openEdit = (p: LLMProvider) => { + setEditing(p); + form.resetFields(); + form.setFieldsValue({ + code: p.code, + name: p.name, + vendor: p.vendor, + api_base: p.api_base, + description: p.description, + status: p.status, + }); + setModalOpen(true); + }; + + const onSubmit = async () => { + const v = await form.validateFields(); + if (editing) { + delete v.code; + await llmApi.updateProvider(editing.id, v); + } else { + await llmApi.createProvider(v); + } + message.success('已保存'); + setModalOpen(false); + load(); + }; + + const columns: TableProps['columns'] = [ + { title: 'ID', dataIndex: 'id', width: 60 }, + { title: 'code', dataIndex: 'code', render: (v) => {v} }, + { title: '名称', dataIndex: 'name' }, + { title: 'vendor', dataIndex: 'vendor' }, + { title: 'API base', dataIndex: 'api_base', ellipsis: true }, + { + title: 'API Key', + dataIndex: 'api_key_mask', + render: (v: string, r) => + r.has_api_key ? v : 未配置 (mock), + }, + { + title: '状态', + dataIndex: 'status', + render: (v) => + v === 'ACTIVE' ? 启用 : 禁用, + }, + { + title: '操作', + key: 'op', + width: 180, + render: (_, r) => ( + + + { + await llmApi.removeProvider(r.id); + message.success('已删除'); + load(); + }} + > + + + +
+ setModalOpen(false)} + onOk={onSubmit} + title={editing ? `编辑 - ${editing.name}` : '新建 Provider'} + okText="保存" + destroyOnClose + > +
+ + + + + + + + + + + + + + + + +
+ setModalOpen(false)} + onOk={onSubmit} + title={editing ? `编辑 - ${editing.name}` : '新建模型'} + okText="保存" + destroyOnClose + width={520} + > + + + + + + + + + + + + + + + + + + + + + + +
+ setModalOpen(false)} + onOk={onSubmit} + title={editing ? `编辑规则 - ${editing.name}` : '新建路由规则'} + okText="保存" + destroyOnClose + > + + + + + + + + + + + + + + HTTP 超时 + {sourceTag(cfg.timeout_source)} + + } + rules={[{ required: true }]} + > + + + + + + 兜底轮询 + {sourceTag(cfg.poll_interval_source)} + + } + rules={[{ required: true }]} + > + + + + + + + + + API Key(Bearer Token) + {cfg.has_api_key ? 已配置 : 未配置} + + } + extra="留空保持不变;填空字符串清除 DB 值;填新值则加密后存库" + > + + + + + + 回调签名密钥 + {cfg.has_callback_secret ? ( + 已配置 + ) : ( + 未配置 + )} + + } + extra="龙虾回调 loom 时 HMAC-SHA256 签名用;留空保持不变" + > + + + + + + + + + + + + + {cfg.has_api_key && cfg.api_key_source === 'db' ? ( + + + + ) : null} + {cfg.has_callback_secret && cfg.callback_secret_source === 'db' ? ( + + + + ) : null} + + + + ); +} + +function Playground() { + const [system, setSystem] = useState('你是 Loom 平台的调试助手,请简洁回答。'); + const [user, setUser] = useState('用一句话解释 Loom 是什么。'); + const [model, setModel] = useState(''); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(false); + const [resp, setResp] = useState(null); + + useEffect(() => { + llmApi.listModels().then((ms) => setModels(ms.filter((m) => m.status === 'ACTIVE'))); + }, []); + + const run = async () => { + setLoading(true); + try { + const r = await llmApi.chat({ + messages: [ + ...(system.trim() ? [{ role: 'system', content: system }] : []), + { role: 'user', content: user }, + ], + model: model || undefined, + source: 'playground', + }); + setResp(r); + } finally { + setLoading(false); + } + }; + + return ( + <> + + + + +
+ + setSystem(e.target.value)} + /> + + + setUser(e.target.value)} + /> + + + } + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + onPressEnter={() => setPage(1)} + style={{ width: 200 }} + /> +
{ + setPage(p); + setSize(s); + }, + }} + /> + + + setModalOpen(false)} + onOk={onSubmit} + okText="保存" + destroyOnClose + width={820} + > + form={form} layout="vertical"> + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/pages/task/create/index.tsx b/frontend/src/pages/task/create/index.tsx new file mode 100644 index 0000000..7a7796c --- /dev/null +++ b/frontend/src/pages/task/create/index.tsx @@ -0,0 +1,324 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Button, + Card, + Descriptions, + Form, + Input, + InputNumber, + Result, + Select, + Space, + Steps, + Tag, + Typography, + message, +} from 'antd'; +import { ArrowLeftOutlined, ArrowRightOutlined, ThunderboltOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import { skillApi } from '@/api/modules/skill'; +import { productApi } from '@/api/modules/product'; +import { taskApi } from '@/api/modules/task'; +import JsonSchemaForm from '@/components/JsonSchemaForm'; +import type { JsonSchema } from '@/components/JsonSchemaForm'; +import type { Product, Skill } from '@/api/types'; + +const { Title, Text } = Typography; + +export default function TaskCreatePage() { + const navigate = useNavigate(); + const [step, setStep] = useState(0); + + const [skills, setSkills] = useState([]); + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(false); + + const [skillId, setSkillId] = useState(); + const [productId, setProductId] = useState(); + const [taskName, setTaskName] = useState(''); + const [timeout, setTimeoutValue] = useState(60); + const [inputForm] = Form.useForm>(); + const [inputValues, setInputValues] = useState>({}); + const [created, setCreated] = useState(null); + + useEffect(() => { + (async () => { + setLoading(true); + try { + const [s, p] = await Promise.all([ + skillApi.list({ page: 1, size: 200 }), + productApi.list({ page: 1, size: 200 }), + ]); + setSkills(s.list.filter((x) => x.status === 'ACTIVE')); + setProducts(p.list.filter((x) => x.status === 'ACTIVE')); + } finally { + setLoading(false); + } + })(); + }, []); + + const selectedSkill = useMemo( + () => skills.find((s) => s.id === skillId) ?? null, + [skills, skillId], + ); + + const allowedProducts = useMemo(() => { + if (!selectedSkill) return [] as Product[]; + if (selectedSkill.product_scope_ids.length === 0) return products; + return products.filter((p) => selectedSkill.product_scope_ids.includes(p.id)); + }, [selectedSkill, products]); + + const canNext = (s: number) => { + if (s === 0) return !!selectedSkill; + if (s === 1) { + if (!selectedSkill) return false; + if (selectedSkill.product_scope_ids.length > 0 && !productId) return false; + return true; + } + if (s === 2) return !!taskName.trim(); + return true; + }; + + const goNext = async () => { + if (step === 2) { + if (selectedSkill?.input_schema) { + try { + const v = await inputForm.validateFields(); + setInputValues(v); + } catch { + return; + } + } + } + if (step < 3) setStep(step + 1); + }; + + const submit = async () => { + if (!selectedSkill) return; + setLoading(true); + try { + const task = await taskApi.create({ + name: taskName, + skill_id: selectedSkill.id, + product_id: productId ?? null, + input_data: inputValues, + timeout_seconds: timeout, + }); + await taskApi.run(task.id); + setCreated(task.id); + message.success(`任务 ${task.task_no} 已创建并触发执行`); + } finally { + setLoading(false); + } + }; + + if (created !== null) { + return ( + navigate(`/task/list?id=${created}`)} + > + 查看任务详情 + , + , + ]} + /> + ); + } + + return ( + + + + {step === 0 && ( + <> + Skill + ({ + value: p.id, + label: `${p.name} (${p.code})`, + }))} + value={productId} + onChange={setProductId} + /> + + )} + + {step === 2 && selectedSkill && ( + <> +
+ + setTaskName(e.target.value)} /> + + + setTimeoutValue((v as number) ?? 60)} + /> + + + + Skill 参数 + +
+ +
+ + )} + + {step === 3 && selectedSkill && ( + + {taskName} + + {selectedSkill.name} {selectedSkill.code} + {selectedSkill.executor_type === 'LOBSTER' ? ( + 龙虾 + ) : ( + 脚本 + )} + + + {productId ? ( + allowedProducts.find((p) => p.id === productId)?.name + ) : ( + 未选择 + )} + + {timeout} 秒 + +
+              {JSON.stringify(inputValues, null, 2)}
+            
+
+
+ )} + +
+ + {step > 0 && ( + + )} + {step < 3 ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/frontend/src/pages/task/list/index.tsx b/frontend/src/pages/task/list/index.tsx new file mode 100644 index 0000000..5e268ad --- /dev/null +++ b/frontend/src/pages/task/list/index.tsx @@ -0,0 +1,397 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Button, + Card, + Descriptions, + Drawer, + Input, + Popconfirm, + Select, + Space, + Table, + Tag, + Typography, + message, +} from 'antd'; +import type { TableProps } from 'antd'; +import { + CloseCircleOutlined, + ReloadOutlined, + SearchOutlined, + SyncOutlined, +} from '@ant-design/icons'; +import { useSearchParams } from 'react-router-dom'; +import { taskApi } from '@/api/modules/task'; +import type { Task, TaskLog, TaskStatus } from '@/api/types'; + +const { Text } = Typography; + +const STATUS_COLOR: Record = { + PENDING: 'default', + QUEUED: 'cyan', + RUNNING: 'blue', + SUCCEEDED: 'success', + FAILED: 'error', + TIMEOUT: 'warning', + CANCELED: 'default', +}; + +const TERMINAL: Set = new Set(['SUCCEEDED', 'FAILED', 'TIMEOUT', 'CANCELED']); + +export default function TaskListPage() { + const [params, setParams] = useSearchParams(); + + const [list, setList] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [size, setSize] = useState(20); + const [keyword, setKeyword] = useState(''); + const [status, setStatus] = useState(); + const [loading, setLoading] = useState(false); + + // 详情 + 日志轮询 + const [detail, setDetail] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const [logs, setLogs] = useState([]); + const lastLogId = useRef(0); + const pollingRef = useRef | null>(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await taskApi.list({ + page, + size, + keyword: keyword || undefined, + status, + }); + setList(res.list); + setTotal(res.total); + } finally { + setLoading(false); + } + }, [page, size, keyword, status]); + + useEffect(() => { + load(); + }, [load]); + + // 打开详情 → 启动轮询 + const openDetail = useCallback(async (id: number) => { + const t = await taskApi.get(id); + const ls = await taskApi.logs(id, 0); + setDetail(t); + setLogs(ls); + lastLogId.current = ls[ls.length - 1]?.id ?? 0; + setDetailOpen(true); + }, []); + + // URL 带 ?id= 时自动打开 + useEffect(() => { + const id = params.get('id'); + if (id && !detailOpen) { + openDetail(Number(id)); + } + }, [params, detailOpen, openDetail]); + + // 轮询:任务非终态时每 2 秒拉一次状态 + 增量日志 + useEffect(() => { + const clear = () => { + if (pollingRef.current) clearTimeout(pollingRef.current); + pollingRef.current = null; + }; + if (!detailOpen || !detail) return clear; + if (TERMINAL.has(detail.status)) return clear; + + const tick = async () => { + try { + const [fresh, more] = await Promise.all([ + taskApi.get(detail.id), + taskApi.logs(detail.id, lastLogId.current), + ]); + if (more.length) { + setLogs((prev) => [...prev, ...more]); + lastLogId.current = more[more.length - 1].id; + } + setDetail(fresh); + if (!TERMINAL.has(fresh.status)) { + pollingRef.current = setTimeout(tick, 2000); + } else { + load(); + } + } catch { + pollingRef.current = setTimeout(tick, 5000); + } + }; + pollingRef.current = setTimeout(tick, 2000); + return clear; + }, [detailOpen, detail, load]); + + const closeDetail = () => { + if (pollingRef.current) clearTimeout(pollingRef.current); + pollingRef.current = null; + setDetailOpen(false); + setDetail(null); + setLogs([]); + lastLogId.current = 0; + if (params.get('id')) { + params.delete('id'); + setParams(params); + } + }; + + const onCancel = async (t: Task) => { + await taskApi.cancel(t.id); + message.success('已取消'); + if (detail?.id === t.id) { + const fresh = await taskApi.get(t.id); + setDetail(fresh); + } + load(); + }; + + const onRetry = async (t: Task) => { + const newTask = await taskApi.retry(t.id); + message.success(`重试新任务:${newTask.task_no}`); + load(); + }; + + const columns: TableProps['columns'] = [ + { + title: '任务号', + dataIndex: 'task_no', + width: 170, + render: (v: string, t) => ( + + ), + }, + { title: '名称', dataIndex: 'name', ellipsis: true }, + { title: 'Skill', dataIndex: 'skill_code', render: (v) => {v} }, + { + title: '执行器', + dataIndex: 'executor_type', + width: 90, + render: (v: string) => + v === 'LOBSTER' ? 龙虾 : 脚本, + }, + { title: '产品', dataIndex: 'product_name' }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: (v: TaskStatus) => ( + : undefined} color={STATUS_COLOR[v]}> + {v} + + ), + }, + { title: '操作人', dataIndex: 'operator_name', width: 100 }, + { + title: '耗时 (ms)', + dataIndex: 'cost_ms', + width: 100, + }, + { title: '创建时间', dataIndex: 'created_at', width: 170 }, + { + title: '操作', + key: 'op', + fixed: 'right', + width: 200, + render: (_, t) => ( + + + {!TERMINAL.has(t.status) ? ( + onCancel(t)}> + + + ) : ( + + )} + + ), + }, + ]; + + return ( + <> + + } + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + onPressEnter={() => setPage(1)} + style={{ width: 200 }} + /> +
{ + setPage(p); + setSize(s); + }, + }} + /> + + + onCancel(detail)} + > + + + ) : null + } + > + {detail ? ( + <> + + + : undefined} + color={STATUS_COLOR[detail.status]} + > + {detail.status} + + + + {detail.cost_ms} ms + + {detail.skill_name} + + {detail.executor_type === 'LOBSTER' ? ( + 龙虾 + ) : ( + 脚本 + )} + + {detail.product_name ?? '-'} + {detail.operator_name ?? '-'} + + {detail.external_ref || -} + + {detail.started_at ?? '-'} + {detail.finished_at ?? '-'} + {detail.error_message ? ( + + {detail.error_message} + + ) : null} + + + +
+ {logs.length === 0 ? ( +
[无日志]
+ ) : ( + logs.map((l) => ( +
+ + [{(l.created_at || '').slice(11, 19)}] + {' '} + + {l.level} + {' '} + {l.message} +
+ )) + )} +
+
+ + + + +
+                    {JSON.stringify(detail.input_data ?? {}, null, 2)}
+                  
+
+ +
+                    {JSON.stringify(detail.resolved_input ?? {}, null, 2)}
+                  
+
+ +
+                    {JSON.stringify(detail.result_data ?? {}, null, 2)}
+                  
+
+
+
+ + ) : null} +
+ + ); +} diff --git a/frontend/src/router/guards.tsx b/frontend/src/router/guards.tsx new file mode 100644 index 0000000..1c8ed35 --- /dev/null +++ b/frontend/src/router/guards.tsx @@ -0,0 +1,66 @@ +import { ReactNode, useEffect, useState } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { Spin } from 'antd'; +import { useAuthStore } from '@/stores/auth'; +import { tokenStorage } from '@/utils/token'; + +interface AuthGuardProps { + children: ReactNode; +} + +/** + * 登录守卫: + * - 没有 token → /login + * - 有 token 但 store 未 bootstrap → 调 /auth/me 拉用户信息 + 菜单;失败再跳 /login + */ +export function AuthGuard({ children }: AuthGuardProps) { + const location = useLocation(); + const user = useAuthStore((s) => s.user); + const bootstrapped = useAuthStore((s) => s.bootstrapped); + const bootstrap = useAuthStore((s) => s.bootstrap); + + const [loading, setLoading] = useState(!user); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!tokenStorage.get()) { + setLoading(false); + return; + } + if (bootstrapped && user) { + setLoading(false); + return; + } + bootstrap().then((u) => { + if (!u) setFailed(true); + setLoading(false); + }); + }, [bootstrap, bootstrapped, user]); + + if (!tokenStorage.get() || failed) { + const redirect = encodeURIComponent(location.pathname + location.search); + return ; + } + + if (loading) { + return ( +
+ +
+ ); + } + + return <>{children}; +} + +interface PermGuardProps { + code: string; + fallback?: ReactNode; + children: ReactNode; +} + +export function PermGuard({ code, fallback, children }: PermGuardProps) { + const hasPerm = useAuthStore((s) => s.hasPerm(code)); + if (!hasPerm) return <>{fallback ?? null}; + return <>{children}; +} diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx new file mode 100644 index 0000000..0b050de --- /dev/null +++ b/frontend/src/router/index.tsx @@ -0,0 +1,71 @@ +import { Suspense, lazy } from 'react'; +import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom'; +import { Spin } from 'antd'; +import { AuthGuard, PermGuard } from './guards'; +import BlankLayout from '@/layouts/BlankLayout'; +import BasicLayout from '@/layouts/BasicLayout'; +import { bizRoutes } from './routes'; + +const LoginPage = lazy(() => import('@/pages/login')); +const ForbiddenPage = lazy(() => import('@/pages/403')); + +function PageFallback() { + return ( +
+ +
+ ); +} + +const router = createBrowserRouter([ + { + element: , + children: [ + { + path: '/login', + element: ( + }> + + + ), + }, + ], + }, + { + element: ( + + + + ), + children: [ + { path: '/', element: }, + { + path: '/403', + element: ( + }> + + + ), + }, + ...bizRoutes.map((r) => ({ + path: r.path, + element: ( + }> + {r.meta.permKey ? ( + }> + + + ) : ( + + )} + + ), + })), + ], + }, + { path: '*', element: }, +]); + +export function AppRouter() { + return ; +} diff --git a/frontend/src/router/routes.ts b/frontend/src/router/routes.ts new file mode 100644 index 0000000..8ea2856 --- /dev/null +++ b/frontend/src/router/routes.ts @@ -0,0 +1,67 @@ +import { lazy, type LazyExoticComponent, type ComponentType } from 'react'; + +export interface RouteMeta { + title: string; + permKey?: string; + requiresAuth: boolean; +} + +export interface BizRoute { + path: string; + Component: LazyExoticComponent; + meta: RouteMeta; +} + +/** 鉴权后的业务路由(统一挂 BasicLayout)。 */ +export const bizRoutes: BizRoute[] = [ + { path: '/dashboard', Component: lazy(() => import('@/pages/dashboard')), + meta: { title: '仪表盘', permKey: 'dashboard:view', requiresAuth: true } }, + + // 权限管理 + { path: '/auth/org', Component: lazy(() => import('@/pages/auth/org')), + meta: { title: '组织架构', permKey: 'auth:org:view', requiresAuth: true } }, + { path: '/auth/role', Component: lazy(() => import('@/pages/auth/role')), + meta: { title: '角色管理', permKey: 'auth:role:view', requiresAuth: true } }, + { path: '/auth/user', Component: lazy(() => import('@/pages/auth/user')), + meta: { title: '用户管理', permKey: 'auth:user:view', requiresAuth: true } }, + { path: '/auth/menu', Component: lazy(() => import('@/pages/auth/menu')), + meta: { title: '菜单管理', permKey: 'auth:menu:view', requiresAuth: true } }, + + // 产品 + { path: '/product/list', Component: lazy(() => import('@/pages/product/list')), + meta: { title: '产品管理', permKey: 'product:view', requiresAuth: true } }, + { path: '/product/channel', Component: lazy(() => import('@/pages/product/channel')), + meta: { title: '渠道码管理', permKey: 'product:channel:view', requiresAuth: true } }, + { path: '/product/suite', Component: lazy(() => import('@/pages/product/suite')), + meta: { title: '平台套件', permKey: 'product:suite:view', requiresAuth: true } }, + + // 平台账号 + { path: '/platform-account', Component: lazy(() => import('@/pages/platform-account')), + meta: { title: '平台账号', permKey: 'platform-account:view', requiresAuth: true } }, + + // Skill + { path: '/skill/list', Component: lazy(() => import('@/pages/skill/list')), + meta: { title: 'Skill 管理', permKey: 'skill:view', requiresAuth: true } }, + { path: '/skill/log', Component: lazy(() => import('@/pages/skill/log')), + meta: { title: 'Skill 调用日志', permKey: 'skill:log:view', requiresAuth: true } }, + + // 任务 + { path: '/task/create', Component: lazy(() => import('@/pages/task/create')), + meta: { title: '创建任务', permKey: 'task:create', requiresAuth: true } }, + { path: '/task/list', Component: lazy(() => import('@/pages/task/list')), + meta: { title: '任务列表', permKey: 'task:list', requiresAuth: true } }, + + // 日志 + { path: '/log/op', Component: lazy(() => import('@/pages/log/op')), + meta: { title: '操作日志', permKey: 'log:op:view', requiresAuth: true } }, + { path: '/log/report', Component: lazy(() => import('@/pages/log/report')), + meta: { title: '统计报表', permKey: 'log:report:view', requiresAuth: true } }, + + // 系统设置 + { path: '/system/executor', Component: lazy(() => import('@/pages/system/executor')), + meta: { title: '执行器配置', permKey: 'system:executor:view', requiresAuth: true } }, + { path: '/system/script', Component: lazy(() => import('@/pages/system/script')), + meta: { title: '脚本模板管理', permKey: 'system:script:view', requiresAuth: true } }, + { path: '/system/cron', Component: lazy(() => import('@/pages/system/cron')), + meta: { title: '定时任务', permKey: 'system:cron:view', requiresAuth: true } }, +]; diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..265150b --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,76 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { MenuTreeNode, UserInfo } from '@/api/types'; +import { tokenStorage } from '@/utils/token'; +import { authApi } from '@/api/modules/auth'; + +interface AuthState { + user: UserInfo | null; + permCodes: string[]; + menuTree: MenuTreeNode[]; + /** /auth/me 是否已成功拉过 */ + bootstrapped: boolean; + + setTokens: (access: string, refresh?: string) => void; + /** 登录后 / 刷新页后拉当前用户 + 菜单 */ + bootstrap: () => Promise; + logout: () => Promise; + hasPerm: (code: string) => boolean; +} + +export const useAuthStore = create()( + persist( + (set, get) => ({ + user: null, + permCodes: [], + menuTree: [], + bootstrapped: false, + + setTokens: (access, refresh) => { + tokenStorage.set(access); + if (refresh) tokenStorage.setRefresh(refresh); + }, + + bootstrap: async () => { + if (!tokenStorage.get()) return null; + try { + const { user, menu_tree } = await authApi.me(); + set({ + user, + permCodes: user.perm_codes, + menuTree: menu_tree, + bootstrapped: true, + }); + return user; + } catch { + tokenStorage.clear(); + set({ user: null, permCodes: [], menuTree: [], bootstrapped: false }); + return null; + } + }, + + logout: async () => { + try { + await authApi.logout(); + } catch { + // 吞掉:服务端无状态 token,本地清掉即可 + } + tokenStorage.clear(); + set({ user: null, permCodes: [], menuTree: [], bootstrapped: false }); + }, + + hasPerm: (code) => { + const codes = get().permCodes; + return codes.includes('*') || codes.includes(code); + }, + }), + { + name: 'loom.auth', + partialize: (s) => ({ + user: s.user, + permCodes: s.permCodes, + menuTree: s.menuTree, + }), + }, + ), +); diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css new file mode 100644 index 0000000..8297399 --- /dev/null +++ b/frontend/src/styles/index.css @@ -0,0 +1,19 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + margin: 0; + padding: 0; + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', + Arial, sans-serif; + background: #f1f5f9; + color: #1e293b; +} + +* { + box-sizing: border-box; +} diff --git a/frontend/src/styles/tokens.ts b/frontend/src/styles/tokens.ts new file mode 100644 index 0000000..bd5a5ef --- /dev/null +++ b/frontend/src/styles/tokens.ts @@ -0,0 +1,35 @@ +import type { ThemeConfig } from 'antd'; + +/** + * Loom 设计令牌:沿用原型中的靛蓝主色 + 深色侧栏。 + * 原型参考:docs/原型agent_task_system_merged.html + */ +export const loomTheme: ThemeConfig = { + token: { + colorPrimary: '#6366f1', + colorInfo: '#6366f1', + colorBgLayout: '#f1f5f9', + colorBorder: '#e2e8f0', + colorText: '#1e293b', + colorTextSecondary: '#64748b', + borderRadius: 8, + fontFamily: + '-apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif', + }, + components: { + Layout: { + siderBg: '#1e293b', + headerBg: '#ffffff', + headerHeight: 56, + bodyBg: '#f1f5f9', + }, + Menu: { + darkItemBg: '#1e293b', + darkItemSelectedBg: '#6366f1', + darkItemHoverBg: '#334155', + darkSubMenuItemBg: '#0f172a', + }, + Button: { primaryShadow: 'none' }, + Table: { headerBg: '#f8fafc' }, + }, +}; diff --git a/frontend/src/utils/token.ts b/frontend/src/utils/token.ts new file mode 100644 index 0000000..1790a93 --- /dev/null +++ b/frontend/src/utils/token.ts @@ -0,0 +1,13 @@ +const TOKEN_KEY = 'loom.access_token'; +const REFRESH_KEY = 'loom.refresh_token'; + +export const tokenStorage = { + get: () => localStorage.getItem(TOKEN_KEY), + set: (t: string) => localStorage.setItem(TOKEN_KEY, t), + clear: () => { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_KEY); + }, + getRefresh: () => localStorage.getItem(REFRESH_KEY), + setRefresh: (t: string) => localStorage.setItem(REFRESH_KEY, t), +}; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..03e8021 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..c897a29 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,20 @@ +import type { Config } from 'tailwindcss'; + +const config: Config = { + content: ['./index.html', './src/**/*.{ts,tsx,js,jsx}'], + // 关闭 preflight,避免全局 reset 覆盖 AntD 的默认样式 + corePlugins: { preflight: false }, + // 所有 Tailwind 类加 tw- 前缀,避免和 AntD class 名冲突 + prefix: 'tw-', + theme: { + extend: { + colors: { + primary: '#6366f1', + sider: '#1e293b', + }, + }, + }, + plugins: [], +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..3f5f6e8 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..1a555ac --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..bddfabd --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,22 @@ +import path from 'node:path'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +}); diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..2e43748 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Loom 生产部署脚本(在目标服务器上执行) +# 用法:bash scripts/deploy.sh +# +# 前置条件: +# - 已装 docker + docker compose v2 +# - 域名 ai.aidfxn.com 已解析到本机公网 IP +# - 服务器 80 / 443 已对公网开放 +# - 已 `cp .env.production.example .env` 并填完所有 ***= + +set -euo pipefail + +DOMAIN="ai.aidfxn.com" +EMAIL="${DEPLOY_EMAIL:-runzi.lao@gmail.com}" + +echo "[1/6] 检查 .env 是否就绪..." +if [ ! -f .env ]; then + echo " 缺少 .env,请先 cp .env.production.example .env 并填值" >&2 + exit 1 +fi +for k in APP_SECRET_KEY POSTGRES_PASSWORD FERNET_KEY; do + if ! grep -qE "^${k}=.+" .env; then + echo " .env 中 ${k} 为空,必须填写" >&2 + exit 1 + fi +done + +# DATABASE_URL 占位替换(让脚本更省事) +if grep -q "__POSTGRES_PASSWORD__" .env; then + PWD_VAL=$(grep -E "^POSTGRES_PASSWORD=" .env | cut -d= -f2-) + # 用 awk 做安全替换(避免 sed 对特殊字符的问题) + awk -v pw="$PWD_VAL" '{gsub(/__POSTGRES_PASSWORD__/, pw); print}' .env > .env.tmp && mv .env.tmp .env + echo " 已替换 DATABASE_URL 中的 __POSTGRES_PASSWORD__" +fi + +echo "[2/6] 准备 certbot 目录..." +mkdir -p certbot/conf certbot/www + +echo "[3/6] 先拉 / 构建镜像..." +docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile full pull postgres redis certbot || true +docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile full build backend frontend + +# 检查证书是否已存在 +if [ ! -d "certbot/conf/live/${DOMAIN}" ]; then + echo "[4/6] 首次签发 Let's Encrypt 证书..." + # 启一个临时 nginx(只暴露 80,用 HTTP-01 验证) + docker run --rm -d --name loom-certbot-bootstrap \ + -p 80:80 \ + -v "$(pwd)/certbot/www:/usr/share/nginx/html:ro" \ + nginx:1.27-alpine + sleep 2 + + docker run --rm \ + -v "$(pwd)/certbot/conf:/etc/letsencrypt" \ + -v "$(pwd)/certbot/www:/var/www/certbot" \ + certbot/certbot certonly --webroot \ + -w /var/www/certbot \ + --email "${EMAIL}" --agree-tos --no-eff-email \ + -d "${DOMAIN}" + + docker stop loom-certbot-bootstrap || true +else + echo "[4/6] 证书已存在,跳过申请" +fi + +echo "[5/6] 启动全栈..." +docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile full up -d + +echo "[6/6] 等待 backend 就绪..." +for i in $(seq 1 30); do + if curl -sf "https://${DOMAIN}/api/v1/healthz" -k >/dev/null 2>&1; then + echo " ✅ backend ready" + break + fi + sleep 2 + if [ "$i" = "30" ]; then + echo " ⚠️ 60s 内 /api/v1/healthz 未响应,请查看 docker logs" + docker compose ps + exit 1 + fi +done + +echo +echo "============================================================" +echo "部署完成!访问:https://${DOMAIN}/" +echo " 超管:admin / admin123(首次登录请立刻修改密码)" +echo +echo "常用运维命令:" +echo " docker compose -f docker-compose.yml -f docker-compose.prod.yml ps" +echo " docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f backend" +echo " docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend alembic upgrade head" +echo "============================================================"