feat: Agent任务系统 v2.1 完整实现

- 后端: FastAPI + SQLAlchemy 2.0 + Pydantic v2 + Celery (19个API模块)
- 前端: React 18 + TypeScript + Ant Design 5 + Zustand (19个页面组件)
- 权限: RBAC四维权限体系 (RB-01~RB-13)
- 任务: 四步向导创建 + 双执行器(龙虾/脚本) + 定时调度
- 部署: Docker Compose 6服务 + Nginx反代 + Alembic迁移

统计: 88个文件 | 8779行代码 | 默认账号: admin/admin123
This commit is contained in:
Dev Agent
2026-04-24 09:10:47 +00:00
commit 883a925db5
88 changed files with 13880 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
# Agent任务系统 v2.1
基于 FastAPI + React + Ant Design 的Agent任务管理系统。
## 技术栈
| 层 | 技术 |
|-----------|----------------------------------------|
| 前端 | React 18 + TypeScript + Ant Design 5 + Zustand + Vite 5 |
| 后端 | Python FastAPI + SQLAlchemy 2.0 + Pydantic v2 |
| 数据库 | PostgreSQL 16 + Redis 7 |
| 任务队列 | Celery + Redis |
| 部署 | Docker Compose + Nginx |
## 快速启动
```bash
# 一键启动所有服务
docker compose up -d --build
# 查看日志
docker compose logs -f backend
# 访问
# 前端: http://localhost
# API: http://localhost:8000/docs
# 默认账号: admin / admin123
```
## 项目结构
```
├── backend/
│ ├── app/
│ │ ├── api/v1/ # 路由层
│ │ ├── core/ # 数据库/安全/缓存/权限
│ │ ├── models/ # ORM模型 (t_前缀)
│ │ ├── schemas/ # Pydantic Schema
│ │ ├── celery_app/ # Celery任务
│ │ ├── scripts/ # 种子脚本
│ │ └── main.py
│ ├── alembic/ # 数据库迁移
│ ├── Dockerfile
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── api/ # Axios封装
│ │ ├── components/ # Layout等公共组件
│ │ ├── pages/ # 19个功能页面
│ │ ├── router/ # 路由配置
│ │ ├── stores/ # Zustand状态
│ │ └── types/ # TypeScript类型
│ ├── Dockerfile
│ └── nginx.conf
├── docker-compose.yml
└── README.md
```
## 功能模块 (19个)
1. **登录** — JWT双Token + 谷歌验证
2. **仪表盘** — 6卡片指标 + 趋势 + 排行
3. **组织架构** — 公司→部门→小组三级树
4. **角色管理** — 部门级角色 + 四维权限(菜单/产品/Skill/账号)
5. **用户管理** — 多角色绑定 + 部门切换清角色(RB-08)
6. **菜单管理** — 目录/菜单/按钮三级 + 拖拽排序
7. **产品管理** — 渠道码必填(RB-09) + CSV导入
8. **渠道码管理** — 内导/自然Tab + 产品绑定
9. **平台套件** — 卡片式平台展示
10. **平台产品账号** — 密码AES加密 + 掩码/查看
11. **Skill列表** — 龙虾🦞/脚本📝双执行器 + RB-13互斥
12. **Skill调用日志** — 状态筛选 + 执行详情
13. **任务创建** — 四步向导(选Skill→选产品→填参数→确认)
14. **任务列表** — 5种状态 + 自动轮询 + 重试
15. **操作日志** — 模块筛选 + 变更前后对比
16. **统计报表** — 任务趋势 + Skill排行TOP10
17. **执行器配置** — 并发/负载率环形图
18. **脚本模板管理** — 代码编辑 + 环境变量
19. **定时任务** — Cron预设 + 执行记录
## 权限体系
- **RB-01** 超级管理员跳过所有校验
- **RB-02** 多角色权限并集
- **RB-07** Skill权限绑定角色
- **RB-08** 切换部门自动清除角色
- **RB-09** 产品渠道码必填
- **RB-12** 产品范围交集校验
- **RB-13** 执行器类型互斥
+984
View File
@@ -0,0 +1,984 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Agent任务系统 · 架构设计方案</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--pri:#6366f1;--pri-dark:#4f46e5;
--bg:#f8fafc;--bg-card:#fff;--txt:#1e293b;--sub:#64748b;
--bdr:#e2e8f0;--ok:#22c55e;--warn:#f59e0b;--err:#ef4444;
--blue:#3b82f6;--purple:#8b5cf6;--cyan:#06b6d4
}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--txt);line-height:1.7}
.wrap{max-width:1200px;margin:0 auto;padding:40px 24px 80px}
h1{font-size:28px;margin-bottom:8px;color:var(--pri)}
h2{font-size:20px;margin:48px 0 16px;padding-bottom:8px;border-bottom:2px solid var(--pri);color:var(--pri)}
h3{font-size:16px;margin:24px 0 12px;color:var(--txt)}
h4{font-size:14px;margin:20px 0 10px;color:var(--sub);border-left:3px solid var(--pri);padding-left:10px}
p,li{font-size:14px;margin-bottom:8px}
ul,ol{padding-left:24px}
table{width:100%;border-collapse:collapse;font-size:13px;margin:12px 0}
th,td{padding:10px 12px;text-align:left;border:1px solid var(--bdr)}
th{background:#f1f5f9;font-weight:600;color:var(--sub);font-size:12px}
tr:nth-child(even) td{background:#fafbfc}
.card{background:var(--bg-card);border-radius:12px;padding:24px;margin:16px 0;box-shadow:0 1px 3px rgba(0,0,0,.06);border:1px solid var(--bdr)}
.tag{display:inline-block;padding:2px 10px;border-radius:99px;font-size:11px;font-weight:600;margin-left:6px}
.tag-blue{background:#dbeafe;color:#1e40af}
.tag-green{background:#dcfce7;color:#166534}
.tag-purple{background:#f3e8ff;color:#6b21a8}
.tag-orange{background:#fef3c7;color:#92400e}
.tag-cyan{background:#cffafe;color:#0e7490}
.highlight{background:#fef3c7;padding:2px 4px;border-radius:3px}
code{background:#f1f5f9;padding:2px 6px;border-radius:4px;font-size:12px;color:#e11d48;font-family:Consolas,monospace}
pre{background:#1e293b;color:#e2e8f0;padding:16px;border-radius:8px;overflow-x:auto;font-size:13px;line-height:1.5}
.architecture-box{background:#1e293b;color:#e2e8f0;border-radius:12px;padding:20px;margin:16px 0;font-family:Consolas,monospace;font-size:12px;line-height:1.6;overflow-x:auto}
.architecture-box .comment{color:#64748b}
.architecture-box .keyword{color:#f472b6}
.architecture-box .string{color:#a5f3fc}
.architecture-box .function{color:#fbbf24}
.layer{background:linear-gradient(135deg,var(--pri),var(--purple));color:#fff;padding:14px 20px;border-radius:10px;margin:20px 0 12px;font-weight:600}
.component{display:inline-block;background:#fff;border:2px solid var(--bdr);border-radius:8px;padding:8px 14px;margin:4px;font-size:13px;font-weight:500;transition:all .2s}
.component:hover{border-color:var(--pri);box-shadow:0 2px 8px rgba(99,102,241,.2)}
.timeline{position:relative;padding-left:30px}
.timeline::before{content:'';position:absolute;left:8px;top:0;bottom:0;width:2px;background:var(--bdr)}
.timeline-item{position:relative;margin-bottom:20px}
.timeline-item::before{content:'';position:absolute;left:-26px;top:6px;width:12px;height:12px;border-radius:50%;background:var(--pri);border:3px solid #fff;box-shadow:0 0 0 2px var(--pri)}
.phase{background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;padding:16px 24px;border-radius:12px;margin:32px 0 16px}
.phase h3{color:#fff;margin:0;font-size:18px}
.phase p{color:rgba(255,255,255,.85);margin:4px 0 0;font-size:14px}
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:16px}
@media(max-width:768px){.two-col{grid-template-columns:1fr}}
.badge{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500}
.badge-primary{background:#eef2ff;color:#4338ca}
.badge-success{background:#ecfdf5;color:#059669}
.badge-warning{background:#fffbeb;color:#d97706}
.arrow{color:var(--pri);font-weight:700}
.flow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0;font-size:13px}
.flow-item{background:#f1f5f9;border:1px solid var(--bdr);border-radius:8px;padding:8px 14px;font-weight:500}
.summary-table{background:#1e293b;color:#e2e8f0;border-radius:12px;padding:20px;margin:16px 0}
.summary-table table{color:#e2e8f0}
.summary-table th{background:#334155;color:#94a3b8}
.summary-table td{border-color:#475569}
.toc{background:#f8fafc;border:1px solid var(--bdr);border-radius:10px;padding:20px;margin:16px 0}
.toc h4{margin-top:0;border:none;padding:0}
.toc a{color:var(--pri);text-decoration:none;font-size:14px}
.toc a:hover{text-decoration:underline}
.toc li{margin:8px 0}
@media print{.wrap{padding:20px}.card{box-shadow:none}}
</style>
</head>
<body>
<div class="wrap">
<h1>🤖 Agent任务系统 · 架构设计方案</h1>
<div style="color:var(--sub);font-size:14px;margin-bottom:24px">
版本 v1.0 | 2026-04-23 | 前后端分离架构<br>
技术栈:React + FastAPI + PostgreSQL + Redis + Celery
</div>
<!-- 目录 -->
<div class="toc">
<h4>📑 目录</h4>
<ol>
<li><a href="#section1">一、架构选型分析</a></li>
<li><a href="#section2">二、技术栈选型</a></li>
<li><a href="#section3">三、系统架构图</a></li>
<li><a href="#section4">四、后端模块设计</a></li>
<li><a href="#section5">五、前端模块设计</a></li>
<li><a href="#section6">六、部署架构</a></li>
<li><a href="#section7">七、数据流设计</a></li>
<li><a href="#section8">八、安全设计</a></li>
<li><a href="#section9">九、开发里程碑</a></li>
</ol>
</div>
<!-- 一、架构选型分析 -->
<h2 id="section1">一、架构选型分析</h2>
<div class="card">
<h3>1.1 需求特点</h3>
<p>从功能清单来看,这是一个<strong>中等复杂度</strong>的企业级SaaS应用:</p>
<table>
<tr><th>需求维度</th><th>特点描述</th></tr>
<tr><td>权限系统</td><td>部门级RBAC、多角色权限并集、产品/Skill双重范围校验</td></tr>
<tr><td>任务执行</td><td>脚本执行器异步执行任务,支持Python/Shell</td></tr>
<tr><td>LLM集成</td><td>多Provider路由、Token统计、成本计算</td></tr>
<tr><td>实时通知</td><td>SSE/Telegram推送</td></tr>
<tr><td>部署要求</td><td>前后端分离式独立部署</td></tr>
</table>
</div>
<div class="card">
<h3>1.2 推荐架构</h3>
<p><strong>前后端分离 + 分层后端 + 模块化设计</strong></p>
<table>
<tr><th>架构特点</th><th>说明</th></tr>
<tr><td>前后端分离</td><td>React SPA + FastAPI REST API,独立部署</td></tr>
<tr><td>后端分层</td><td>API层 → Service层 → Repository层</td></tr>
<tr><td>异步任务</td><td>Celery + Redis 实现脚本异步执行</td></tr>
<tr><td>缓存层</td><td>Redis 缓存权限数据、Session</td></tr>
<tr><td>容器化</td><td>Docker Compose 一键部署</td></tr>
</table>
</div>
<!-- 二、技术栈选型 -->
<h2 id="section2">二、技术栈选型</h2>
<div class="card">
<h3>2.1 前端技术栈 <span class="tag tag-blue">React</span></h3>
<table>
<tr><th>组件</th><th>技术选型</th><th>说明</th></tr>
<tr><td><strong>框架</strong></td><td>React 18 + TypeScript</td><td>类型安全,生态成熟</td></tr>
<tr><td><strong>构建工具</strong></td><td>Vite 5</td><td>快速启动,热更新快</td></tr>
<tr><td><strong>UI组件库</strong></td><td>Ant Design 5</td><td>企业级组件,RBAC友好</td></tr>
<tr><td><strong>状态管理</strong></td><td>Zustand</td><td>轻量,比Redux简洁</td></tr>
<tr><td><strong>路由</strong></td><td>React Router 6</td><td>路由守卫+权限控制</td></tr>
<tr><td><strong>表单</strong></td><td>react-jsonschema-form</td><td>动态表单渲染</td></tr>
<tr><td><strong>HTTP</strong></td><td>Axios + React Query</td><td>请求拦截、缓存</td></tr>
<tr><td><strong>图表</strong></td><td>@ant-design/charts</td><td>用量统计图表</td></tr>
<tr><td><strong>CSS</strong></td><td>Tailwind CSS</td><td>工具类,快速开发</td></tr>
</table>
</div>
<div class="card">
<h3>2.2 后端技术栈 <span class="tag tag-green">Python FastAPI</span></h3>
<table>
<tr><th>组件</th><th>技术选型</th><th>说明</th></tr>
<tr><td><strong>Web框架</strong></td><td>FastAPI</td><td>异步、高性能、自动API文档</td></tr>
<tr><td><strong>ORM</strong></td><td>SQLAlchemy 2.0 + asyncpg</td><td>异步数据库操作</td></tr>
<tr><td><strong>认证</strong></td><td>Python-Jose + Passlib</td><td>JWT签发/验证</td></tr>
<tr><td><strong>验证</strong></td><td>Pydantic v2</td><td>数据校验</td></tr>
<tr><td><strong>任务队列</strong></td><td>Celery + Redis</td><td>异步脚本执行</td></tr>
<tr><td><strong>定时任务</strong></td><td>Celery Beat</td><td>Cron任务调度</td></tr>
<tr><td><strong>缓存</strong></td><td>Redis</td><td>会话、权限缓存</td></tr>
<tr><td><strong>加密</strong></td><td>Cryptography</td><td>AES-256 API Key加密</td></tr>
<tr><td><strong>LLM SDK</strong></td><td>OpenAI SDK + 各Provider SDK</td><td>统一接口封装</td></tr>
</table>
</div>
<div class="card">
<h3>2.3 基础设施</h3>
<table>
<tr><th>组件</th><th>技术选型</th><th>说明</th></tr>
<tr><td><strong>数据库</strong></td><td>PostgreSQL 16</td><td>JSON支持,复杂查询</td></tr>
<tr><td><strong>缓存/队列</strong></td><td>Redis 7</td><td>多用途</td></tr>
<tr><td><strong>对象存储</strong></td><td>MinIO / S3</td><td>脚本模板存储</td></tr>
<tr><td><strong>反向代理</strong></td><td>Nginx</td><td>静态资源、负载均衡</td></tr>
<tr><td><strong>容器化</strong></td><td>Docker + Docker Compose</td><td>开发/生产环境</td></tr>
<tr><td><strong>CI/CD</strong></td><td>GitHub Actions</td><td>自动构建部署</td></tr>
</table>
</div>
<!-- 三、系统架构图 -->
<h2 id="section3">三、系统架构图</h2>
<div class="architecture-box">
<pre>
┌─────────────────────────────────────────────────────────────────────────────┐
│ 客户端层 │
│ ┌─────────────────────────┐ ┌─────────────────────────────┐ │
│ │ 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 │ │
│ │ (文件存储) │ │ (定时任务) │ │ (通知推送) │ │
│ └──────────────┘ └──────────────┘ └──────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
</pre>
</div>
<!-- 四、后端模块设计 -->
<h2 id="section4">四、后端模块设计</h2>
<div class="card">
<h3>4.1 项目目录结构</h3>
<div class="architecture-box">
<pre>
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
</pre>
</div>
</div>
<div class="card">
<h3>4.2 核心模块详解</h3>
<h4>4.2.1 权限服务 (Permission Service)</h4>
<p>实现 v2.2 的三要素权限模型:菜单/按钮权限 + 产品范围 + Skill权限</p>
<pre>
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
</pre>
<h4>4.2.2 LLM服务 (LLM Service)</h4>
<p>实现多Provider统一管理和智能路由</p>
<pre>
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
</pre>
<h4>4.2.3 脚本执行器 (Script Executor)</h4>
<p>支持Python/Shell多类型脚本,RB-13/RB-14/RB-17</p>
<pre>
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
}
</pre>
</div>
<!-- 五、前端模块设计 -->
<h2 id="section5">五、前端模块设计</h2>
<div class="card">
<h3>5.1 项目目录结构</h3>
<div class="architecture-box">
<pre>
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
</pre>
</div>
</div>
<div class="card">
<h3>5.2 权限控制实现</h3>
<pre>
// 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 <Navigate to="/login" state={{ from: location }} />;
}
if (requiredPermission && !can(requiredPermission)) {
return <Navigate to="/403" />;
}
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 (
<Select placeholder="选择Skill">
{availableSkills.map(skill => (
<Option key={skill.id} value={skill.id}>
{skill.name} - {skill.description}
</Option>
))}
</Select>
);
}
// 产品选择器 - 只显示在交集范围内的产品
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 (
<Select placeholder="选择产品">
{availableProducts.map(product => (
<Option key={product.id} value={product.id}>
{product.name} ({product.platform.name})
</Option>
))}
</Select>
);
}
</pre>
</div>
<!-- 六、部署架构 -->
<h2 id="section6">六、部署架构</h2>
<div class="card">
<h3>6.1 Docker Compose 配置</h3>
<pre>
# 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
</pre>
</div>
<div class="card">
<h3>6.2 Nginx 配置</h3>
<pre>
# 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";
}
}
}
</pre>
</div>
<!-- 七、数据流设计 -->
<h2 id="section7">七、数据流设计</h2>
<div class="card">
<h3>7.1 任务执行流程</h3>
<div class="flow">
<span class="flow-item">用户提交任务</span>
<span class="arrow">→</span>
<span class="flow-item">POST /tasks</span>
<span class="arrow">→</span>
<span class="flow-item">Permission Service</span>
<span class="arrow">→</span>
<span class="flow-item">校验Skill权限</span>
<span class="arrow">→</span>
<span class="flow-item">校验产品交集</span>
</div>
<div class="flow">
<span class="flow-item">任务入库</span>
<span class="arrow">→</span>
<span class="flow-item">获取平台账号</span>
<span class="arrow">→</span>
<span class="flow-item">Celery任务入队</span>
<span class="arrow">→</span>
<span class="flow-item">Worker执行脚本</span>
</div>
<div class="flow">
<span class="flow-item">LLM调用(如需要)</span>
<span class="arrow">→</span>
<span class="flow-item">记录用量</span>
<span class="arrow">→</span>
<span class="flow-item">SSE推送结果</span>
<span class="arrow">→</span>
<span class="flow-item">Telegram通知</span>
</div>
</div>
<div class="card">
<h3>7.2 LLM调用流程</h3>
<div class="flow">
<span class="flow-item">Skill执行请求</span>
<span class="arrow">→</span>
<span class="flow-item">LLM路由匹配</span>
<span class="arrow">→</span>
<span class="flow-item">获取API Key</span>
<span class="arrow">→</span>
<span class="flow-item">调用Provider</span>
</div>
<div class="flow">
<span class="flow-item">记录Token用量</span>
<span class="arrow">→</span>
<span class="flow-item">计算成本</span>
<span class="arrow">→</span>
<span class="flow-item">返回结果</span>
</div>
</div>
<!-- 八、安全设计 -->
<h2 id="section8">八、安全设计</h2>
<div class="card">
<table>
<tr><th>安全项</th><th>实现方案</th><th>对应规则</th></tr>
<tr><td><strong>API Key加密</strong></td><td>AES-256-GCM加密存储,密钥由环境变量注入</td><td>RB-18</td></tr>
<tr><td><strong>JWT认证</strong></td><td>Access Token(7天) + Refresh Token(30天)</td><td>-</td></tr>
<tr><td><strong>密码存储</strong></td><td>bcrypt哈希</td><td>-</td></tr>
<tr><td><strong>权限校验</strong></td><td>每次请求校验按钮级别权限</td><td>RB-05</td></tr>
<tr><td><strong>数据范围</strong></td><td>SQL级别行级过滤(product_scope)</td><td>RB-06, RB-12</td></tr>
<tr><td><strong>脚本隔离</strong></td><td>独立进程+超时控制+环境变量隔离</td><td>RB-17</td></tr>
<tr><td><strong>敏感信息</strong></td><td>日志脱敏、API响应过滤</td><td>-</td></tr>
<tr><td><strong>部门隔离</strong></td><td>角色归属部门,用户只能绑本部门角色</td><td>RB-01, RB-02</td></tr>
</table>
</div>
<!-- 九、开发里程碑 -->
<h2 id="section9">九、开发里程碑</h2>
<div class="card">
<div class="timeline">
<div class="timeline-item">
<strong>Phase 1: 项目骨架 + 认证 + 组织架构 <span class="tag tag-blue">2周</span></strong>
<ul>
<li>项目脚手架搭建 (FastAPI + React)</li>
<li>数据库设计 + Alembic迁移</li>
<li>JWT认证模块</li>
<li>组织架构管理 (公司→部门→小组)</li>
<li>基础RBAC (菜单/按钮权限)</li>
</ul>
</div>
<div class="timeline-item">
<strong>Phase 2: 产品/渠道管理 + 角色三要素权限 <span class="tag tag-purple">1.5周</span></strong>
<ul>
<li>产品CRUD + 渠道码管理</li>
<li>角色管理 (部门级 + 三要素)</li>
<li>用户管理 (部门角色绑定 + 多角色)</li>
<li>权限服务实现 (多角色并集计算)</li>
</ul>
</div>
<div class="timeline-item">
<strong>Phase 3: Skill管理 + 脚本执行器 + 任务模块 <span class="tag tag-green">2周</span></strong>
<ul>
<li>脚本模板管理</li>
<li>Skill CRUD + 产品范围绑定</li>
<li>Celery Worker部署</li>
<li>脚本执行器实现</li>
<li>任务创建/执行/查询</li>
<li>权限双重校验 (Skill权限 + 产品交集)</li>
</ul>
</div>
<div class="timeline-item">
<strong>Phase 4: 平台账号管理 + LLM模块 <span class="tag tag-orange">2周</span></strong>
<ul>
<li>平台账号模板管理</li>
<li>用户平台账号CRUD</li>
<li>LLM供应商管理 (多Provider)</li>
<li>模型配置 + 成本设置</li>
<li>LLM路由规则</li>
<li>LLM调用服务 + 用量统计</li>
</ul>
</div>
<div class="timeline-item">
<strong>Phase 5: 定时任务 + 日志统计 + 通知推送 <span class="tag tag-cyan">1.5周</span></strong>
<ul>
<li>Celery Beat定时任务</li>
<li>操作日志记录</li>
<li>Skill调用统计报表</li>
<li>SSE实时推送</li>
<li>Telegram Bot通知</li>
</ul>
</div>
<div class="timeline-item">
<strong>Phase 6: 部署优化 + 集成测试 + 上线 <span class="tag tag-blue">1周</span></strong>
<ul>
<li>Docker Compose优化</li>
<li>环境变量配置</li>
<li>集成测试</li>
<li>性能优化</li>
<li>上线部署</li>
</ul>
</div>
</div>
</div>
<!-- 总结 -->
<div class="card" style="background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;margin-top:32px">
<h3 style="color:#fff;margin-top:0">📋 架构总结</h3>
<table style="color:#e2e8f0">
<tr><th>维度</th><th>选型</th></tr>
<tr><td>前端</td><td>React 18 + TypeScript + Ant Design + Zustand</td></tr>
<tr><td>后端</td><td>FastAPI + SQLAlchemy 2.0 + Pydantic v2</td></tr>
<tr><td>数据库</td><td>PostgreSQL 16 + Redis 7</td></tr>
<tr><td>任务队列</td><td>Celery + Redis</td></tr>
<tr><td>部署</td><td>Docker Compose + Nginx</td></tr>
<tr><td>预计工期</td><td><strong>约9周</strong></td></tr>
</table>
</div>
<div style="text-align:center;color:var(--sub);font-size:13px;padding:40px 0;border-top:1px solid var(--bdr);margin-top:40px">
<p>🤖 Agent任务系统 · 架构设计方案 v1.0</p>
<p>生成时间:2026-04-23 | 技术栈:React + FastAPI + PostgreSQL</p>
</div>
</div>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
# Agent Task System Backend
DATABASE_URL=postgresql+asyncpg://postgres:agent_system_2026@localhost:5432/agent_system
DATABASE_URL_SYNC=postgresql://postgres:agent_system_2026@localhost:5432/agent_system
REDIS_URL=redis://localhost:6379/0
SECRET_KEY=agent-task-system-secret-key-change-in-production-2026
ENCRYPT_KEY=0123456789abcdef0123456789abcdef
# JWT
ACCESS_TOKEN_EXPIRE_MINUTES=420
REFRESH_TOKEN_EXPIRE_DAYS=30
# CORS
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
# Celery
CELERY_BROKER_URL=redis://localhost:6379/1
CELERY_RESULT_BACKEND=redis://localhost:6379/2
# App
APP_NAME=Agent任务系统
APP_VERSION=2.1.0
DEBUG=true
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+37
View File
@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
sqlalchemy.url = postgresql+asyncpg://postgres:agent_system_2026@localhost:5432/agent_system
prepend_sys_path = .
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[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
+55
View File
@@ -0,0 +1,55 @@
"""Alembic env.py — async migration support"""
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# import all models so Alembic can detect them
from app.models import Base # noqa
from app.config import settings
config = context.config
config.set_main_option("sqlalchemy.url", str(settings.DATABASE_URL))
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=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()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+1
View File
@@ -0,0 +1 @@
# app package
+63
View File
@@ -0,0 +1,63 @@
"""
API 依赖注入 — get_db, get_current_user, require_permission
"""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.security import decode_token
from app.core.permission import PermissionService
from app.models.user import User
from jose import JWTError
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
"""从JWT Token获取当前用户"""
token = credentials.credentials
try:
payload = decode_token(token)
if payload.get("type") != "access":
raise HTTPException(status_code=401, detail="Invalid token type")
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid token")
except JWTError:
raise HTTPException(status_code=401, detail="Token expired or invalid")
stmt = select(User).where(User.id == int(user_id), User.deleted_at == None)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found")
if user.status.value == "disabled":
raise HTTPException(status_code=403, detail="User is disabled")
return user
def require_permission(permission_code: str):
"""按钮级权限装饰器"""
async def _check(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
perm_service = PermissionService(db)
perms = await perm_service.get_user_effective_permissions(current_user.id)
# 超级管理员跳过
is_super = await perm_service._is_super_admin(current_user.id)
if is_super:
return current_user
if permission_code not in perms.button_codes:
raise HTTPException(
status_code=403,
detail=f"Permission denied: {permission_code}"
)
return current_user
return _check
+125
View File
@@ -0,0 +1,125 @@
"""
认证 API — 登录 / 登出 / Token刷新 / 获取用户信息
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from datetime import datetime, timezone
from app.core.database import get_db
from app.core.security import verify_password, create_access_token, create_refresh_token, decode_token
from app.core.permission import PermissionService
from app.api.deps import get_current_user
from app.models.user import User, Role
from app.models.log import OperationLog
from app.schemas.auth import LoginRequest, TokenResponse, RefreshRequest, UserInfo
from app.schemas.common import ResponseModel
from app.config import settings
import uuid
from jose import JWTError
router = APIRouter(prefix="/auth", tags=["认证"])
@router.post("/login", response_model=ResponseModel[TokenResponse])
async def login(body: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
"""登录"""
stmt = select(User).where(User.username == body.username, User.deleted_at == None)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误")
if user.status.value == "disabled":
raise HTTPException(status_code=403, detail="账号已禁用")
# 签发Token
access_token = create_access_token({"sub": str(user.id), "username": user.username})
refresh_token = create_refresh_token({"sub": str(user.id)})
# 更新最后登录时间
user.last_login = datetime.now(timezone.utc)
# 记录操作日志
log = OperationLog(
log_id=f"OPLOG-{uuid.uuid4().hex[:8].upper()}",
user_id=user.id,
username=user.username,
module="登录",
action="login",
description=f"用户 {user.username} 登录成功",
ip_address=request.client.host if request.client else None,
)
db.add(log)
return ResponseModel(data=TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
))
@router.post("/refresh", response_model=ResponseModel[TokenResponse])
async def refresh_token(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
"""刷新Token"""
try:
payload = decode_token(body.refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid refresh token")
user_id = payload.get("sub")
except JWTError:
raise HTTPException(status_code=401, detail="Refresh token expired")
stmt = select(User).where(User.id == int(user_id), User.deleted_at == None)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found")
access_token = create_access_token({"sub": str(user.id), "username": user.username})
new_refresh = create_refresh_token({"sub": str(user.id)})
return ResponseModel(data=TokenResponse(
access_token=access_token,
refresh_token=new_refresh,
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
))
@router.get("/userinfo", response_model=ResponseModel[UserInfo])
async def get_userinfo(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取当前用户信息 + 权限"""
perm_service = PermissionService(db)
perms = await perm_service.get_user_effective_permissions(current_user.id)
menu_tree = await perm_service.get_menu_tree(current_user.id)
# 加载角色
stmt = (
select(User)
.options(selectinload(User.roles), selectinload(User.organization))
.where(User.id == current_user.id)
)
result = await db.execute(stmt)
user = result.scalar_one()
return ResponseModel(data=UserInfo(
id=user.id,
username=user.username,
email=user.email,
phone=user.phone,
employee_no=user.employee_no,
org_id=user.org_id,
org_name=user.organization.name if user.organization else None,
status=user.status.value,
roles=[{"id": r.id, "name": r.name, "code": r.code} for r in user.roles],
menus=menu_tree,
buttons=list(perms.button_codes),
data_scope=list(perms.product_ids),
skill_scope=list(perms.skill_ids),
))
+300
View File
@@ -0,0 +1,300 @@
"""
操作日志 + 统计报表 + 执行器配置 + 定时任务 API
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text
from datetime import datetime, timezone
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.models.log import OperationLog
from app.models.executor import ExecutorConfig
from app.models.task import Task, ScheduleTask, ScheduleRecord
from app.models.skill import Skill, SkillCallLog
from app.schemas.log import OperationLogResponse, ExecutorConfigResponse, ExecutorConfigUpdate
from app.schemas.task import ScheduleTaskCreate, ScheduleTaskUpdate, ScheduleTaskResponse, ScheduleRecordResponse
from app.schemas.common import ResponseModel, PageResponse
# ============ 操作日志 ============
log_router = APIRouter(prefix="/operation-logs", tags=["操作日志"])
@log_router.get("", response_model=PageResponse[OperationLogResponse])
async def list_logs(
page: int = Query(1), page_size: int = Query(20),
module: str = Query(None), action: str = Query(None),
user_id: int = Query(None),
start_date: str = Query(None), end_date: str = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(OperationLog)
if module:
stmt = stmt.where(OperationLog.module == module)
if action:
stmt = stmt.where(OperationLog.action == action)
if user_id:
stmt = stmt.where(OperationLog.user_id == user_id)
if start_date:
stmt = stmt.where(OperationLog.created_at >= start_date)
if end_date:
stmt = stmt.where(OperationLog.created_at <= end_date)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
logs = (await db.execute(
stmt.order_by(OperationLog.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
data = [
OperationLogResponse(
id=l.id, log_id=l.log_id, user_id=l.user_id, username=l.username,
module=l.module, action=l.action, description=l.description,
request_params=l.request_params,
before_data=l.before_data, after_data=l.after_data,
ip_address=l.ip_address, created_at=l.created_at,
) for l in logs
]
return PageResponse(data=data, total=total, page=page, page_size=page_size)
# ============ 统计报表 ============
stats_router = APIRouter(prefix="/stats", tags=["统计报表"])
@stats_router.get("/dashboard", response_model=ResponseModel)
async def get_dashboard(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""仪表盘总览数据"""
task_total = (await db.execute(select(func.count(Task.id)))).scalar() or 0
task_success = (await db.execute(
select(func.count(Task.id)).where(Task.status == "SUCCEEDED")
)).scalar() or 0
task_failed = (await db.execute(
select(func.count(Task.id)).where(Task.status == "FAILED")
)).scalar() or 0
task_running = (await db.execute(
select(func.count(Task.id)).where(Task.status == "RUNNING")
)).scalar() or 0
task_pending = (await db.execute(
select(func.count(Task.id)).where(Task.status == "PENDING")
)).scalar() or 0
skill_total = (await db.execute(
select(func.count(Skill.id)).where(Skill.deleted_at == None)
)).scalar() or 0
call_total = (await db.execute(select(func.count(SkillCallLog.id)))).scalar() or 0
call_success = (await db.execute(
select(func.count(SkillCallLog.id)).where(SkillCallLog.status == "SUCCESS")
)).scalar() or 0
# 今日任务数
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
today_tasks = (await db.execute(
select(func.count(Task.id)).where(Task.created_at >= today_start)
)).scalar() or 0
return ResponseModel(data={
"task_total": task_total,
"task_success": task_success,
"task_failed": task_failed,
"task_running": task_running,
"task_pending": task_pending,
"task_success_rate": round(task_success / task_total * 100, 1) if task_total > 0 else 0,
"skill_total": skill_total,
"call_total": call_total,
"call_success_rate": round(call_success / call_total * 100, 1) if call_total > 0 else 0,
"today_tasks": today_tasks,
})
@stats_router.get("/task-trend", response_model=ResponseModel)
async def get_task_trend(
days: int = Query(7, ge=1, le=90),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""任务趋势(按天)"""
sql = text("""
SELECT DATE(created_at) as day, status, COUNT(*) as cnt
FROM t_task
WHERE created_at >= NOW() - INTERVAL :days DAY
GROUP BY day, status
ORDER BY day
""")
result = await db.execute(sql, {"days": f"{days} days"})
rows = result.all()
trend = {}
for day, status, cnt in rows:
d = str(day)
if d not in trend:
trend[d] = {"date": d, "total": 0, "success": 0, "failed": 0}
trend[d]["total"] += cnt
if status == "SUCCEEDED":
trend[d]["success"] += cnt
elif status == "FAILED":
trend[d]["failed"] += cnt
return ResponseModel(data=list(trend.values()))
@stats_router.get("/skill-ranking", response_model=ResponseModel)
async def get_skill_ranking(
limit: int = Query(10),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Skill调用排行"""
stmt = (
select(SkillCallLog.skill_id, func.count(SkillCallLog.id).label("cnt"))
.group_by(SkillCallLog.skill_id)
.order_by(func.count(SkillCallLog.id).desc())
.limit(limit)
)
rows = (await db.execute(stmt)).all()
data = []
for skill_id, cnt in rows:
s = (await db.execute(select(Skill.name).where(Skill.id == skill_id))).scalar()
data.append({"skill_id": skill_id, "skill_name": s or "未知", "call_count": cnt})
return ResponseModel(data=data)
# ============ 执行器配置 ============
executor_router = APIRouter(prefix="/executor-configs", tags=["执行器配置"])
@executor_router.get("", response_model=ResponseModel[list])
async def list_executors(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
executors = (await db.execute(select(ExecutorConfig).order_by(ExecutorConfig.id))).scalars().all()
data = [
ExecutorConfigResponse(
id=e.id, executor_type=e.executor_type, name=e.name,
config_data=e.config_data, max_concurrency=e.max_concurrency,
current_load=e.current_load, status=e.status,
health_data=e.health_data, updated_at=e.updated_at,
) for e in executors
]
return ResponseModel(data=data)
@executor_router.put("/{eid}", response_model=ResponseModel)
async def update_executor(
eid: int, body: ExecutorConfigUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
e = (await db.execute(select(ExecutorConfig).where(ExecutorConfig.id == eid))).scalar_one_or_none()
if not e:
raise HTTPException(404, "执行器不存在")
if body.config_data is not None:
e.config_data = body.config_data
if body.max_concurrency is not None:
e.max_concurrency = body.max_concurrency
if body.status is not None:
e.status = body.status
return ResponseModel(message="✅ 配置已保存")
# ============ 定时任务 ============
schedule_router = APIRouter(prefix="/schedule-tasks", tags=["定时任务"])
@schedule_router.get("", response_model=PageResponse[ScheduleTaskResponse])
async def list_schedule_tasks(
page: int = Query(1), page_size: int = Query(10),
status: str = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(ScheduleTask).where(ScheduleTask.deleted_at == None)
if status:
stmt = stmt.where(ScheduleTask.status == status)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
tasks = (await db.execute(
stmt.order_by(ScheduleTask.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
# 获取关联名
skill_ids = list({t.skill_id for t in tasks})
skill_map = {}
if skill_ids:
ss = (await db.execute(select(Skill).where(Skill.id.in_(skill_ids)))).scalars().all()
skill_map = {s.id: s.name for s in ss}
data = [
ScheduleTaskResponse(
id=t.id, name=t.name, skill_id=t.skill_id,
skill_name=skill_map.get(t.skill_id),
product_id=t.product_id, cron_expression=t.cron_expression,
exec_params=t.exec_params, retry_policy=t.retry_policy,
timeout=t.timeout, status=t.status,
last_run_at=t.last_run_at, next_run_at=t.next_run_at,
created_at=t.created_at,
) for t in tasks
]
return PageResponse(data=data, total=total, page=page, page_size=page_size)
@schedule_router.post("", response_model=ResponseModel)
async def create_schedule(body: ScheduleTaskCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
t = ScheduleTask(**body.model_dump(), creator_id=current_user.id)
db.add(t)
await db.flush()
return ResponseModel(message="✅ 定时任务已创建", data={"id": t.id})
@schedule_router.put("/{sid}", response_model=ResponseModel)
async def update_schedule(sid: int, body: ScheduleTaskUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
t = (await db.execute(select(ScheduleTask).where(ScheduleTask.id == sid, ScheduleTask.deleted_at == None))).scalar_one_or_none()
if not t:
raise HTTPException(404, "定时任务不存在")
for k, v in body.model_dump().items():
setattr(t, k, v)
return ResponseModel(message="✅ 定时任务已保存")
@schedule_router.delete("/{sid}", response_model=ResponseModel)
async def delete_schedule(sid: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
t = (await db.execute(select(ScheduleTask).where(ScheduleTask.id == sid, ScheduleTask.deleted_at == None))).scalar_one_or_none()
if not t:
raise HTTPException(404, "定时任务不存在")
t.deleted_at = datetime.now(timezone.utc)
return ResponseModel(message="✅ 已删除")
@schedule_router.post("/{sid}/toggle", response_model=ResponseModel)
async def toggle_schedule(sid: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
"""启停定时任务"""
t = (await db.execute(select(ScheduleTask).where(ScheduleTask.id == sid, ScheduleTask.deleted_at == None))).scalar_one_or_none()
if not t:
raise HTTPException(404, "定时任务不存在")
t.status = "paused" if t.status == "active" else "active"
return ResponseModel(message=f"✅ 已{'暂停' if t.status == 'paused' else '启用'}")
@schedule_router.get("/{sid}/records", response_model=PageResponse[ScheduleRecordResponse])
async def list_schedule_records(
sid: int, page: int = Query(1), page_size: int = Query(20),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(ScheduleRecord).where(ScheduleRecord.schedule_id == sid)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
records = (await db.execute(
stmt.order_by(ScheduleRecord.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
data = [
ScheduleRecordResponse(
id=r.id, schedule_id=r.schedule_id, status=r.status,
duration_ms=r.duration_ms, result_text=r.result_text,
executed_at=r.executed_at,
) for r in records
]
return PageResponse(data=data, total=total, page=page, page_size=page_size)
+139
View File
@@ -0,0 +1,139 @@
"""
菜单管理 API — 菜单树 + 按钮权限
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from datetime import datetime, timezone
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.models.menu import Menu, MenuButton
from app.schemas.menu import MenuCreate, MenuUpdate, MenuButtonCreate, MenuButtonUpdate, MenuResponse, MenuTreeNode
from app.schemas.common import ResponseModel
router = APIRouter(prefix="/menus", tags=["菜单管理"])
@router.get("/tree", response_model=ResponseModel[list])
async def get_menu_tree(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""完整菜单树(管理用)"""
stmt = (
select(Menu)
.options(selectinload(Menu.buttons))
.where(Menu.deleted_at == None)
.order_by(Menu.sort_order)
)
result = await db.execute(stmt)
menus = result.scalars().all()
def build(parent_id):
nodes = []
for m in menus:
if m.parent_id == parent_id:
nodes.append({
"id": m.id, "name": m.name, "menu_type": m.menu_type,
"path": m.path, "icon": m.icon, "component": m.component,
"sort_order": m.sort_order, "visible": m.visible, "status": m.status,
"buttons": [
{"id": b.id, "name": b.name, "code": b.permission_code, "type": b.button_type, "status": b.status}
for b in (m.buttons or [])
],
"children": build(m.id),
})
return nodes
return ResponseModel(data=build(None))
@router.post("", response_model=ResponseModel)
async def create_menu(
body: MenuCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
menu = Menu(**body.model_dump())
db.add(menu)
await db.flush()
return ResponseModel(message="✅ 菜单已保存", data={"id": menu.id})
@router.put("/{menu_id}", response_model=ResponseModel)
async def update_menu(
menu_id: int, body: MenuUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(Menu).where(Menu.id == menu_id, Menu.deleted_at == None)
menu = (await db.execute(stmt)).scalar_one_or_none()
if not menu:
raise HTTPException(status_code=404, detail="菜单不存在")
for k, v in body.model_dump().items():
setattr(menu, k, v)
return ResponseModel(message="✅ 菜单已保存")
@router.delete("/{menu_id}", response_model=ResponseModel)
async def delete_menu(
menu_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# 检查是否有子菜单
children = await db.execute(select(Menu).where(Menu.parent_id == menu_id, Menu.deleted_at == None))
if children.scalars().first():
raise HTTPException(status_code=400, detail="请先删除子菜单")
menu = (await db.execute(select(Menu).where(Menu.id == menu_id))).scalar_one_or_none()
if not menu:
raise HTTPException(status_code=404, detail="菜单不存在")
menu.deleted_at = datetime.now(timezone.utc)
return ResponseModel(message="✅ 已删除")
# ---- 按钮 ----
@router.post("/buttons", response_model=ResponseModel)
async def create_button(
body: MenuButtonCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# 权限标识唯一
exists = await db.execute(select(MenuButton).where(MenuButton.permission_code == body.permission_code))
if exists.scalar_one_or_none():
raise HTTPException(status_code=400, detail="权限标识已存在")
btn = MenuButton(**body.model_dump())
db.add(btn)
return ResponseModel(message="✅ 按钮已保存")
@router.put("/buttons/{btn_id}", response_model=ResponseModel)
async def update_button(
btn_id: int, body: MenuButtonUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
btn = (await db.execute(select(MenuButton).where(MenuButton.id == btn_id))).scalar_one_or_none()
if not btn:
raise HTTPException(status_code=404, detail="按钮不存在")
for k, v in body.model_dump().items():
setattr(btn, k, v)
return ResponseModel(message="✅ 按钮已保存")
@router.delete("/buttons/{btn_id}", response_model=ResponseModel)
async def delete_button(
btn_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
btn = (await db.execute(select(MenuButton).where(MenuButton.id == btn_id))).scalar_one_or_none()
if not btn:
raise HTTPException(status_code=404, detail="按钮不存在")
await db.delete(btn)
return ResponseModel(message="✅ 已删除")
+167
View File
@@ -0,0 +1,167 @@
"""
组织架构 API — CRUD + 树形结构
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import Organization, User
from app.schemas.user import OrgCreate, OrgUpdate, OrgResponse, OrgTreeNode
from app.schemas.common import ResponseModel, PageResponse
router = APIRouter(prefix="/organizations", tags=["组织架构"])
@router.get("/tree", response_model=ResponseModel[list])
async def get_org_tree(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取组织架构树"""
stmt = (
select(Organization)
.where(Organization.deleted_at == None)
.order_by(Organization.sort_order, Organization.id)
)
result = await db.execute(stmt)
orgs = result.scalars().all()
# 统计每个org的成员数
member_counts = {}
count_stmt = select(User.org_id, func.count(User.id)).where(User.deleted_at == None).group_by(User.org_id)
count_result = await db.execute(count_stmt)
for org_id, cnt in count_result.all():
member_counts[org_id] = cnt
def build_tree(parent_id):
nodes = []
for org in orgs:
if org.parent_id == parent_id:
children = build_tree(org.id)
nodes.append({
"id": org.id,
"name": org.name,
"org_type": org.org_type.value if hasattr(org.org_type, 'value') else org.org_type,
"parent_id": org.parent_id,
"sort_order": org.sort_order,
"status": org.status,
"member_count": member_counts.get(org.id, 0),
"children": children,
})
return nodes
tree = build_tree(None)
return ResponseModel(data=tree)
@router.get("/{org_id}", response_model=ResponseModel[OrgResponse])
async def get_org(
org_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取组织详情"""
stmt = select(Organization).where(Organization.id == org_id, Organization.deleted_at == None)
result = await db.execute(stmt)
org = result.scalar_one_or_none()
if not org:
raise HTTPException(status_code=404, detail="组织不存在")
# 统计成员数和子组数
member_stmt = select(func.count(User.id)).where(User.org_id == org_id, User.deleted_at == None)
member_count = (await db.execute(member_stmt)).scalar() or 0
child_stmt = select(func.count(Organization.id)).where(
Organization.parent_id == org_id, Organization.deleted_at == None
)
child_count = (await db.execute(child_stmt)).scalar() or 0
resp = OrgResponse(
id=org.id, name=org.name, org_type=org.org_type.value,
parent_id=org.parent_id, leader_id=org.leader_id,
sort_order=org.sort_order, status=org.status, remark=org.remark,
created_at=org.created_at, member_count=member_count, child_count=child_count,
)
return ResponseModel(data=resp)
@router.post("", response_model=ResponseModel[OrgResponse])
async def create_org(
body: OrgCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""新增组织"""
org = Organization(
name=body.name, org_type=body.org_type, parent_id=body.parent_id,
leader_id=body.leader_id, sort_order=body.sort_order,
status=body.status, remark=body.remark,
)
db.add(org)
await db.flush()
resp = OrgResponse(
id=org.id, name=org.name, org_type=org.org_type.value if hasattr(org.org_type, 'value') else org.org_type,
parent_id=org.parent_id, leader_id=org.leader_id,
sort_order=org.sort_order, status=org.status, remark=org.remark,
created_at=org.created_at,
)
return ResponseModel(data=resp, message="保存成功")
@router.put("/{org_id}", response_model=ResponseModel[OrgResponse])
async def update_org(
org_id: int, body: OrgUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""编辑组织"""
stmt = select(Organization).where(Organization.id == org_id, Organization.deleted_at == None)
result = await db.execute(stmt)
org = result.scalar_one_or_none()
if not org:
raise HTTPException(status_code=404, detail="组织不存在")
for field in ["name", "org_type", "parent_id", "leader_id", "sort_order", "status", "remark"]:
setattr(org, field, getattr(body, field))
await db.flush()
resp = OrgResponse(
id=org.id, name=org.name, org_type=org.org_type.value if hasattr(org.org_type, 'value') else org.org_type,
parent_id=org.parent_id, leader_id=org.leader_id,
sort_order=org.sort_order, status=org.status, remark=org.remark,
created_at=org.created_at,
)
return ResponseModel(data=resp, message="保存成功")
@router.delete("/{org_id}", response_model=ResponseModel)
async def delete_org(
org_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""删除组织 — 软删除,子部门和人员移至上级"""
from datetime import datetime, timezone
stmt = select(Organization).where(Organization.id == org_id, Organization.deleted_at == None)
result = await db.execute(stmt)
org = result.scalar_one_or_none()
if not org:
raise HTTPException(status_code=404, detail="组织不存在")
# 子组织移到上级
children_stmt = select(Organization).where(Organization.parent_id == org_id, Organization.deleted_at == None)
children_result = await db.execute(children_stmt)
for child in children_result.scalars().all():
child.parent_id = org.parent_id
# 成员移到上级
members_stmt = select(User).where(User.org_id == org_id, User.deleted_at == None)
members_result = await db.execute(members_stmt)
for user in members_result.scalars().all():
user.org_id = org.parent_id
org.deleted_at = datetime.now(timezone.utc)
return ResponseModel(message="已删除")
+172
View File
@@ -0,0 +1,172 @@
"""
平台产品账号 API — 密码加密存储 + 掩码查看
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from datetime import datetime, timezone
from app.core.database import get_db
from app.core.security import aes_encrypt, aes_decrypt
from app.api.deps import get_current_user
from app.models.user import User
from app.models.platform_account import PlatformAccount
from app.models.product import Platform, Product
from app.schemas.platform_account import PlatformAccountCreate, PlatformAccountUpdate, PlatformAccountResponse
from app.schemas.common import ResponseModel, PageResponse
router = APIRouter(prefix="/platform-accounts", tags=["平台产品账号"])
@router.get("", response_model=PageResponse[PlatformAccountResponse])
async def list_accounts(
page: int = Query(1), page_size: int = Query(10),
search: str = Query(""), platform_id: int = Query(None),
product_id: int = Query(None), owner_id: int = Query(None),
status: str = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(PlatformAccount).where(PlatformAccount.deleted_at == None)
if search:
stmt = stmt.where(PlatformAccount.login_account.ilike(f"%{search}%"))
if platform_id:
stmt = stmt.where(PlatformAccount.platform_id == platform_id)
if product_id:
stmt = stmt.where(PlatformAccount.product_id == product_id)
if owner_id:
stmt = stmt.where(PlatformAccount.owner_id == owner_id)
if status:
stmt = stmt.where(PlatformAccount.status == status)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
accounts = (await db.execute(
stmt.order_by(PlatformAccount.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
# 批量获取关联
plat_ids = list({a.platform_id for a in accounts})
prod_ids = list({a.product_id for a in accounts})
owner_ids = list({a.owner_id for a in accounts})
plat_map, prod_map, owner_map = {}, {}, {}
if plat_ids:
ps = (await db.execute(select(Platform).where(Platform.id.in_(plat_ids)))).scalars().all()
plat_map = {p.id: (p.name, p.address) for p in ps}
if prod_ids:
ps = (await db.execute(select(Product).where(Product.id.in_(prod_ids)))).scalars().all()
prod_map = {p.id: (p.code, p.name) for p in ps}
if owner_ids:
us = (await db.execute(select(User).where(User.id.in_(owner_ids)))).scalars().all()
owner_map = {u.id: u.username for u in us}
data = []
for a in accounts:
pn = plat_map.get(a.platform_id, ("", ""))
pr = prod_map.get(a.product_id, ("", ""))
# 谷歌密钥掩码:只显示后4位
gk_masked = None
if a.google_key_enc:
try:
gk = aes_decrypt(a.google_key_enc)
gk_masked = f"••••{gk[-4:]}" if len(gk) > 4 else "••••"
except Exception:
gk_masked = "••••"
data.append(PlatformAccountResponse(
id=a.id, platform_id=a.platform_id,
platform_name=pn[0], platform_address=pn[1],
product_id=a.product_id, product_code=pr[0], product_name=pr[1],
owner_id=a.owner_id, owner_name=owner_map.get(a.owner_id),
login_account=a.login_account,
login_password_masked="••••••••",
google_key_masked=gk_masked,
status=a.status, created_at=a.created_at,
))
return PageResponse(data=data, total=total, page=page, page_size=page_size)
@router.post("", response_model=ResponseModel)
async def create_account(
body: PlatformAccountCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
account = PlatformAccount(
platform_id=body.platform_id,
product_id=body.product_id,
owner_id=current_user.id,
login_account=body.login_account,
login_password_enc=aes_encrypt(body.login_password),
google_key_enc=aes_encrypt(body.google_key) if body.google_key else None,
status=body.status,
)
db.add(account)
await db.flush()
return ResponseModel(message="✅ 账号已保存", data={"id": account.id})
@router.put("/{aid}", response_model=ResponseModel)
async def update_account(
aid: int, body: PlatformAccountUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
a = (await db.execute(
select(PlatformAccount).where(PlatformAccount.id == aid, PlatformAccount.deleted_at == None)
)).scalar_one_or_none()
if not a:
raise HTTPException(404, "账号不存在")
if body.platform_id is not None:
a.platform_id = body.platform_id
if body.product_id is not None:
a.product_id = body.product_id
if body.login_account is not None:
a.login_account = body.login_account
if body.login_password:
a.login_password_enc = aes_encrypt(body.login_password)
if body.google_key:
a.google_key_enc = aes_encrypt(body.google_key)
if body.status is not None:
a.status = body.status
return ResponseModel(message="✅ 账号已保存")
@router.delete("/{aid}", response_model=ResponseModel)
async def delete_account(
aid: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
a = (await db.execute(
select(PlatformAccount).where(PlatformAccount.id == aid, PlatformAccount.deleted_at == None)
)).scalar_one_or_none()
if not a:
raise HTTPException(404, "账号不存在")
a.deleted_at = datetime.now(timezone.utc)
return ResponseModel(message="✅ 已删除")
@router.get("/{aid}/reveal-password", response_model=ResponseModel)
async def reveal_password(
aid: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""查看真实密码(需权限)"""
a = (await db.execute(
select(PlatformAccount).where(PlatformAccount.id == aid, PlatformAccount.deleted_at == None)
)).scalar_one_or_none()
if not a:
raise HTTPException(404, "账号不存在")
password = aes_decrypt(a.login_password_enc)
google_key = aes_decrypt(a.google_key_enc) if a.google_key_enc else None
return ResponseModel(data={
"login_password": password,
"google_key": google_key,
})
+263
View File
@@ -0,0 +1,263 @@
"""
产品管理 + 渠道码管理 + 平台套件 API
"""
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from datetime import datetime, timezone
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.models.product import Platform, Product, ChannelCode, channel_product_table
from app.schemas.product import (
PlatformCreate, PlatformUpdate, PlatformResponse,
ProductCreate, ProductUpdate, ProductResponse,
ChannelCodeCreate, ChannelCodeUpdate, ChannelCodeResponse, ChannelBindRequest,
)
from app.schemas.common import ResponseModel, PageResponse
router = APIRouter(tags=["产品管理"])
# ============ 平台套件 ============
platform_router = APIRouter(prefix="/platforms", tags=["平台套件"])
@platform_router.get("", response_model=ResponseModel[list])
async def list_platforms(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(Platform).where(Platform.deleted_at == None).order_by(Platform.id)
result = await db.execute(stmt)
platforms = result.scalars().all()
data = []
for p in platforms:
# 统计产品数和账号数
prod_count = (await db.execute(
select(func.count(Product.id)).where(Product.platform_id == p.id, Product.deleted_at == None)
)).scalar() or 0
data.append(PlatformResponse(
id=p.id, name=p.name, address=p.address, icon=p.icon,
remark=p.remark, status=p.status, product_count=prod_count,
account_count=0, created_at=p.created_at,
))
return ResponseModel(data=data)
@platform_router.post("", response_model=ResponseModel)
async def create_platform(body: PlatformCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
p = Platform(**body.model_dump())
db.add(p)
await db.flush()
return ResponseModel(message="✅ 平台已保存", data={"id": p.id})
@platform_router.put("/{pid}", response_model=ResponseModel)
async def update_platform(pid: int, body: PlatformUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
p = (await db.execute(select(Platform).where(Platform.id == pid, Platform.deleted_at == None))).scalar_one_or_none()
if not p:
raise HTTPException(404, "平台不存在")
for k, v in body.model_dump().items():
setattr(p, k, v)
return ResponseModel(message="✅ 平台已保存")
@platform_router.delete("/{pid}", response_model=ResponseModel)
async def delete_platform(pid: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
p = (await db.execute(select(Platform).where(Platform.id == pid, Platform.deleted_at == None))).scalar_one_or_none()
if not p:
raise HTTPException(404, "平台不存在")
p.deleted_at = datetime.now(timezone.utc)
return ResponseModel(message="✅ 已删除")
# ============ 产品 ============
product_router = APIRouter(prefix="/products", tags=["产品管理"])
@product_router.get("", response_model=PageResponse[ProductResponse])
async def list_products(
page: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100),
search: str = Query(""), platform_id: int = Query(None),
org_id: int = Query(None), status: str = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = (
select(Product)
.options(selectinload(Product.platform), selectinload(Product.organization))
.where(Product.deleted_at == None)
)
if search:
stmt = stmt.where(
(Product.name.ilike(f"%{search}%")) |
(Product.code.ilike(f"%{search}%")) |
(Product.internal_channel_code.ilike(f"%{search}%"))
)
if platform_id:
stmt = stmt.where(Product.platform_id == platform_id)
if org_id:
stmt = stmt.where(Product.org_id == org_id)
if status:
stmt = stmt.where(Product.status == status)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
stmt = stmt.order_by(Product.id.desc()).offset((page - 1) * page_size).limit(page_size)
products = (await db.execute(stmt)).scalars().all()
data = [
ProductResponse(
id=p.id, name=p.name, code=p.code, platform_id=p.platform_id,
platform_name=p.platform.name if p.platform else None,
org_id=p.org_id, org_name=p.organization.name if p.organization else None,
team_id=p.team_id, leader_id=p.leader_id,
internal_channel_code=p.internal_channel_code,
natural_channel_code=p.natural_channel_code,
status=p.status, created_at=p.created_at,
) for p in products
]
return PageResponse(data=data, total=total, page=page, page_size=page_size)
@product_router.post("", response_model=ResponseModel)
async def create_product(body: ProductCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
# RB-09: 渠道码必填
if not body.internal_channel_code:
raise HTTPException(400, "内导渠道码必填(RB-09)")
if not body.natural_channel_code:
raise HTTPException(400, "自然渠道码必填(RB-09)")
# 代号唯一
exists = (await db.execute(select(Product).where(Product.code == body.code, Product.deleted_at == None))).scalar_one_or_none()
if exists:
raise HTTPException(400, "产品代号已存在")
p = Product(**body.model_dump())
db.add(p)
await db.flush()
return ResponseModel(message="✅ 保存成功", data={"id": p.id})
@product_router.put("/{pid}", response_model=ResponseModel)
async def update_product(pid: int, body: ProductUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
p = (await db.execute(select(Product).where(Product.id == pid, Product.deleted_at == None))).scalar_one_or_none()
if not p:
raise HTTPException(404, "产品不存在")
for k, v in body.model_dump().items():
setattr(p, k, v)
return ResponseModel(message="✅ 保存成功")
@product_router.delete("/{pid}", response_model=ResponseModel)
async def delete_product(pid: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
p = (await db.execute(select(Product).where(Product.id == pid, Product.deleted_at == None))).scalar_one_or_none()
if not p:
raise HTTPException(404, "产品不存在")
p.deleted_at = datetime.now(timezone.utc)
return ResponseModel(message="✅ 已删除")
@product_router.post("/import", response_model=ResponseModel)
async def import_products(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""CSV批量导入产品"""
import csv, io
content = await file.read()
reader = csv.DictReader(io.StringIO(content.decode("utf-8-sig")))
created, updated = 0, 0
for row in reader:
code = row.get("code", "").strip()
if not code:
continue
exists = (await db.execute(select(Product).where(Product.code == code))).scalar_one_or_none()
if exists:
exists.name = row.get("name", exists.name)
updated += 1
else:
db.add(Product(
name=row.get("name", ""), code=code,
platform_id=int(row.get("platform_id", 1)),
internal_channel_code=row.get("internal_channel_code", code),
natural_channel_code=row.get("natural_channel_code", code),
))
created += 1
return ResponseModel(message=f"✅ 成功导入 {created} 条,覆盖 {updated} 条")
# ============ 渠道码 ============
channel_router = APIRouter(prefix="/channel-codes", tags=["渠道码管理"])
@channel_router.get("", response_model=PageResponse[ChannelCodeResponse])
async def list_channel_codes(
channel_type: str = Query(None, description="internal/natural"),
search: str = Query(""), status: str = Query(None),
page: int = Query(1), page_size: int = Query(20),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(ChannelCode).options(selectinload(ChannelCode.products)).where(ChannelCode.deleted_at == None)
if channel_type:
stmt = stmt.where(ChannelCode.channel_type == channel_type)
if search:
stmt = stmt.where((ChannelCode.code.ilike(f"%{search}%")) | (ChannelCode.name.ilike(f"%{search}%")))
if status:
stmt = stmt.where(ChannelCode.status == status)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
codes = (await db.execute(stmt.order_by(ChannelCode.id).offset((page-1)*page_size).limit(page_size))).scalars().all()
data = [
ChannelCodeResponse(
id=c.id, code=c.code, name=c.name, channel_type=c.channel_type,
status=c.status, remark=c.remark, product_count=len(c.products),
created_at=c.created_at,
) for c in codes
]
return PageResponse(data=data, total=total, page=page, page_size=page_size)
@channel_router.post("", response_model=ResponseModel)
async def create_channel_code(body: ChannelCodeCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
exists = (await db.execute(select(ChannelCode).where(ChannelCode.code == body.code))).scalar_one_or_none()
if exists:
raise HTTPException(400, "渠道码已存在")
c = ChannelCode(**body.model_dump())
db.add(c)
return ResponseModel(message="✅ 保存成功")
@channel_router.put("/{cid}", response_model=ResponseModel)
async def update_channel_code(cid: int, body: ChannelCodeUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
c = (await db.execute(select(ChannelCode).where(ChannelCode.id == cid, ChannelCode.deleted_at == None))).scalar_one_or_none()
if not c:
raise HTTPException(404, "渠道码不存在")
for k, v in body.model_dump().items():
setattr(c, k, v)
return ResponseModel(message="✅ 保存成功")
@channel_router.delete("/{cid}", response_model=ResponseModel)
async def delete_channel_code(cid: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
c = (await db.execute(select(ChannelCode).where(ChannelCode.id == cid, ChannelCode.deleted_at == None))).scalar_one_or_none()
if not c:
raise HTTPException(404, "渠道码不存在")
c.deleted_at = datetime.now(timezone.utc)
return ResponseModel(message="✅ 已删除")
@channel_router.post("/{cid}/bind", response_model=ResponseModel)
async def bind_products(cid: int, body: ChannelBindRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
"""渠道码绑定产品"""
c = (await db.execute(
select(ChannelCode).options(selectinload(ChannelCode.products)).where(ChannelCode.id == cid)
)).scalar_one_or_none()
if not c:
raise HTTPException(404, "渠道码不存在")
products = (await db.execute(select(Product).where(Product.id.in_(body.product_ids)))).scalars().all()
c.products = list(products)
return ResponseModel(message="✅ 绑定成功")
+170
View File
@@ -0,0 +1,170 @@
"""
角色管理 API — 四维权限配置
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from datetime import datetime, timezone
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import Role, User, Organization, user_role_table
from app.models.menu import Menu
from app.models.product import Product
from app.models.skill import Skill
from app.schemas.user import RoleCreate, RoleUpdate, RoleResponse
from app.schemas.common import ResponseModel, PageResponse
router = APIRouter(prefix="/roles", tags=["角色管理"])
@router.get("", response_model=PageResponse[RoleResponse])
async def list_roles(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
search: str = Query(""),
org_id: int = Query(None),
status: str = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = (
select(Role)
.options(
selectinload(Role.organization),
selectinload(Role.menus),
selectinload(Role.products),
selectinload(Role.skills),
selectinload(Role.users),
)
.where(Role.deleted_at == None)
)
if search:
stmt = stmt.where((Role.name.ilike(f"%{search}%")) | (Role.code.ilike(f"%{search}%")))
if org_id:
stmt = stmt.where(Role.org_id == org_id)
if status:
stmt = stmt.where(Role.status == status)
count_stmt = select(func.count()).select_from(stmt.subquery())
total = (await db.execute(count_stmt)).scalar() or 0
stmt = stmt.order_by(Role.id).offset((page - 1) * page_size).limit(page_size)
result = await db.execute(stmt)
roles = result.scalars().all()
data = []
for r in roles:
data.append(RoleResponse(
id=r.id, name=r.name, code=r.code,
org_id=r.org_id,
org_name=r.organization.name if r.organization else "全局",
is_global=r.is_global,
description=r.description, status=r.status,
menu_count=len(r.menus),
product_count=len(r.products),
skill_count=len(r.skills),
member_count=len(r.users),
created_at=r.created_at,
))
return PageResponse(data=data, total=total, page=page, page_size=page_size)
@router.post("", response_model=ResponseModel[RoleResponse])
async def create_role(
body: RoleCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# 编码唯一性
exists = await db.execute(select(Role).where(Role.code == body.code, Role.deleted_at == None))
if exists.scalar_one_or_none():
raise HTTPException(status_code=400, detail="角色编码已存在")
role = Role(
name=body.name, code=body.code, org_id=body.org_id,
is_global=body.is_global, description=body.description,
status=body.status,
)
db.add(role)
await db.flush()
# 绑定菜单
if body.menu_ids:
menus = (await db.execute(select(Menu).where(Menu.id.in_(body.menu_ids)))).scalars().all()
role.menus = list(menus)
# 绑定产品范围
if body.product_ids:
products = (await db.execute(select(Product).where(Product.id.in_(body.product_ids)))).scalars().all()
role.products = list(products)
# 绑定Skill
if body.skill_ids:
skills = (await db.execute(select(Skill).where(Skill.id.in_(body.skill_ids)))).scalars().all()
role.skills = list(skills)
await db.flush()
return ResponseModel(
data=RoleResponse(
id=role.id, name=role.name, code=role.code,
org_id=role.org_id, is_global=role.is_global,
description=role.description, status=role.status,
created_at=role.created_at,
),
message="保存成功",
)
@router.put("/{role_id}", response_model=ResponseModel)
async def update_role(
role_id: int, body: RoleUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = (
select(Role)
.options(selectinload(Role.menus), selectinload(Role.products), selectinload(Role.skills))
.where(Role.id == role_id, Role.deleted_at == None)
)
result = await db.execute(stmt)
role = result.scalar_one_or_none()
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
role.name = body.name
role.org_id = body.org_id
role.is_global = body.is_global
role.description = body.description
role.status = body.status
# 更新四维权限
if body.menu_ids is not None:
menus = (await db.execute(select(Menu).where(Menu.id.in_(body.menu_ids)))).scalars().all()
role.menus = list(menus)
if body.product_ids is not None:
products = (await db.execute(select(Product).where(Product.id.in_(body.product_ids)))).scalars().all()
role.products = list(products)
if body.skill_ids is not None:
skills = (await db.execute(select(Skill).where(Skill.id.in_(body.skill_ids)))).scalars().all()
role.skills = list(skills)
return ResponseModel(message="保存成功")
@router.delete("/{role_id}", response_model=ResponseModel)
async def delete_role(
role_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(Role).where(Role.id == role_id, Role.deleted_at == None)
result = await db.execute(stmt)
role = result.scalar_one_or_none()
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
if role.code == "super_admin":
raise HTTPException(status_code=400, detail="超级管理员角色不可删除")
role.deleted_at = datetime.now(timezone.utc)
return ResponseModel(message="已删除")
+275
View File
@@ -0,0 +1,275 @@
"""
Skill管理 + 脚本模板 API
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from datetime import datetime, timezone
import uuid
from app.core.database import get_db
from app.core.security import aes_encrypt
from app.api.deps import get_current_user
from app.models.user import User
from app.models.skill import Skill, ScriptTemplate, SkillCallLog
from app.models.product import Product
from app.schemas.skill import (
SkillCreate, SkillUpdate, SkillResponse, SkillTestRequest,
ScriptTemplateCreate, ScriptTemplateUpdate, ScriptTemplateResponse,
SkillCallLogResponse,
)
from app.schemas.common import ResponseModel, PageResponse
router = APIRouter(tags=["Skill管理"])
# ============ 脚本模板 ============
tpl_router = APIRouter(prefix="/script-templates", tags=["脚本模板"])
@tpl_router.get("", response_model=PageResponse[ScriptTemplateResponse])
async def list_templates(
page: int = Query(1), page_size: int = Query(10),
search: str = Query(""), script_type: str = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(ScriptTemplate).where(ScriptTemplate.deleted_at == None)
if search:
stmt = stmt.where(ScriptTemplate.name.ilike(f"%{search}%"))
if script_type:
stmt = stmt.where(ScriptTemplate.script_type == script_type)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
tpls = (await db.execute(
stmt.order_by(ScriptTemplate.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
data = [
ScriptTemplateResponse(
id=t.id, name=t.name, script_type=t.script_type,
description=t.description, code=t.code, env_vars=t.env_vars,
status=t.status, skill_count=0,
created_at=t.created_at, updated_at=t.updated_at,
) for t in tpls
]
return PageResponse(data=data, total=total, page=page, page_size=page_size)
@tpl_router.post("", response_model=ResponseModel)
async def create_template(body: ScriptTemplateCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
t = ScriptTemplate(**body.model_dump())
db.add(t)
await db.flush()
return ResponseModel(message="✅ 模板已保存", data={"id": t.id})
@tpl_router.put("/{tid}", response_model=ResponseModel)
async def update_template(tid: int, body: ScriptTemplateUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
t = (await db.execute(select(ScriptTemplate).where(ScriptTemplate.id == tid, ScriptTemplate.deleted_at == None))).scalar_one_or_none()
if not t:
raise HTTPException(404, "模板不存在")
for k, v in body.model_dump().items():
setattr(t, k, v)
return ResponseModel(message="✅ 模板已保存")
@tpl_router.delete("/{tid}", response_model=ResponseModel)
async def delete_template(tid: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
t = (await db.execute(select(ScriptTemplate).where(ScriptTemplate.id == tid))).scalar_one_or_none()
if not t:
raise HTTPException(404, "模板不存在")
# 检查是否被Skill引用
ref = (await db.execute(select(Skill).where(Skill.script_template_id == tid, Skill.deleted_at == None))).scalars().first()
if ref:
raise HTTPException(400, f"模板被Skill「{ref.name}」引用,不可删除")
t.deleted_at = datetime.now(timezone.utc)
return ResponseModel(message="✅ 已删除")
# ============ Skill ============
skill_router = APIRouter(prefix="/skills", tags=["Skill管理"])
@skill_router.get("", response_model=PageResponse[SkillResponse])
async def list_skills(
page: int = Query(1), page_size: int = Query(10),
search: str = Query(""), category: str = Query(None),
executor_type: str = Query(None), status: str = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(Skill).options(selectinload(Skill.products)).where(Skill.deleted_at == None)
if search:
stmt = stmt.where(Skill.name.ilike(f"%{search}%"))
if category:
stmt = stmt.where(Skill.category == category)
if executor_type:
stmt = stmt.where(Skill.executor_type == executor_type)
if status:
stmt = stmt.where(Skill.status == status)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
skills = (await db.execute(
stmt.order_by(Skill.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
data = []
for s in skills:
# 统计调用次数和成功率
call_total = (await db.execute(
select(func.count(SkillCallLog.id)).where(SkillCallLog.skill_id == s.id)
)).scalar() or 0
call_success = (await db.execute(
select(func.count(SkillCallLog.id)).where(SkillCallLog.skill_id == s.id, SkillCallLog.status == "SUCCESS")
)).scalar() or 0
data.append(SkillResponse(
id=s.id, name=s.name, category=s.category,
executor_type=s.executor_type, description=s.description,
lobster_api_url=s.lobster_api_url, lobster_agent_id=s.lobster_agent_id,
script_template_id=s.script_template_id, script_timeout=s.script_timeout,
input_schema=s.input_schema, param_mapping=s.param_mapping,
product_count=len(s.products), call_count=call_total,
success_rate=round(call_success / call_total * 100, 1) if call_total > 0 else 0.0,
status=s.status, created_at=s.created_at,
))
return PageResponse(data=data, total=total, page=page, page_size=page_size)
@skill_router.get("/{skill_id}", response_model=ResponseModel[SkillResponse])
async def get_skill(skill_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
s = (await db.execute(
select(Skill).options(selectinload(Skill.products)).where(Skill.id == skill_id, Skill.deleted_at == None)
)).scalar_one_or_none()
if not s:
raise HTTPException(404, "Skill不存在")
return ResponseModel(data=SkillResponse(
id=s.id, name=s.name, category=s.category,
executor_type=s.executor_type, description=s.description,
lobster_api_url=s.lobster_api_url, lobster_agent_id=s.lobster_agent_id,
script_template_id=s.script_template_id, script_timeout=s.script_timeout,
input_schema=s.input_schema, param_mapping=s.param_mapping,
product_count=len(s.products), status=s.status, created_at=s.created_at,
))
@skill_router.post("", response_model=ResponseModel)
async def create_skill(body: SkillCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
# RB-13: 执行器类型互斥校验
if body.executor_type == "lobster":
if not body.lobster_api_url or not body.lobster_api_key or not body.lobster_agent_id:
raise HTTPException(400, "龙虾执行器需填写API URL、API Key、Agent ID")
if body.script_template_id:
raise HTTPException(400, "龙虾类型不可关联脚本模板 (RB-13互斥)")
elif body.executor_type == "script":
if not body.script_template_id:
raise HTTPException(400, "脚本执行器需选择脚本模板")
if body.lobster_api_key:
raise HTTPException(400, "脚本类型不可填写龙虾API Key (RB-13互斥)")
skill = Skill(
name=body.name, category=body.category,
executor_type=body.executor_type, description=body.description,
lobster_api_url=body.lobster_api_url,
lobster_api_key_enc=aes_encrypt(body.lobster_api_key) if body.lobster_api_key else None,
lobster_agent_id=body.lobster_agent_id,
script_template_id=body.script_template_id,
script_env_vars=body.script_env_vars,
script_timeout=body.script_timeout,
input_schema=body.input_schema,
param_mapping=body.param_mapping,
status=body.status,
)
db.add(skill)
await db.flush()
# 绑定产品范围
if body.product_ids:
products = (await db.execute(select(Product).where(Product.id.in_(body.product_ids)))).scalars().all()
skill.products = list(products)
return ResponseModel(message="✅ Skill已保存", data={"id": skill.id})
@skill_router.put("/{skill_id}", response_model=ResponseModel)
async def update_skill(skill_id: int, body: SkillUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
s = (await db.execute(
select(Skill).options(selectinload(Skill.products)).where(Skill.id == skill_id, Skill.deleted_at == None)
)).scalar_one_or_none()
if not s:
raise HTTPException(404, "Skill不存在")
s.name = body.name
s.category = body.category
s.executor_type = body.executor_type
s.description = body.description
s.lobster_api_url = body.lobster_api_url
if body.lobster_api_key:
s.lobster_api_key_enc = aes_encrypt(body.lobster_api_key)
s.lobster_agent_id = body.lobster_agent_id
s.script_template_id = body.script_template_id
s.script_env_vars = body.script_env_vars
s.script_timeout = body.script_timeout
s.input_schema = body.input_schema
s.param_mapping = body.param_mapping
s.status = body.status
if body.product_ids is not None:
products = (await db.execute(select(Product).where(Product.id.in_(body.product_ids)))).scalars().all()
s.products = list(products)
return ResponseModel(message="✅ Skill已保存")
@skill_router.delete("/{skill_id}", response_model=ResponseModel)
async def delete_skill(skill_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
s = (await db.execute(select(Skill).where(Skill.id == skill_id, Skill.deleted_at == None))).scalar_one_or_none()
if not s:
raise HTTPException(404, "Skill不存在")
s.deleted_at = datetime.now(timezone.utc)
return ResponseModel(message="✅ 已删除")
# ============ Skill 调用日志 ============
call_log_router = APIRouter(prefix="/skill-call-logs", tags=["Skill调用日志"])
@call_log_router.get("", response_model=PageResponse[SkillCallLogResponse])
async def list_call_logs(
page: int = Query(1), page_size: int = Query(20),
skill_id: int = Query(None), user_id: int = Query(None),
status: str = Query(None), start_date: str = Query(None), end_date: str = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(SkillCallLog)
if skill_id:
stmt = stmt.where(SkillCallLog.skill_id == skill_id)
if user_id:
stmt = stmt.where(SkillCallLog.user_id == user_id)
if status:
stmt = stmt.where(SkillCallLog.status == status)
if start_date:
stmt = stmt.where(SkillCallLog.created_at >= start_date)
if end_date:
stmt = stmt.where(SkillCallLog.created_at <= end_date)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
logs = (await db.execute(
stmt.order_by(SkillCallLog.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
data = [
SkillCallLogResponse(
id=l.id, call_id=l.call_id, skill_id=l.skill_id,
user_id=l.user_id, product_id=l.product_id,
executor_type=l.executor_type,
request_params=l.request_params,
response_data=l.response_data,
execution_log=l.execution_log,
status=l.status, duration_ms=l.duration_ms,
created_at=l.created_at,
) for l in logs
]
return PageResponse(data=data, total=total, page=page, page_size=page_size)
+188
View File
@@ -0,0 +1,188 @@
"""
任务中心 API — 创建 + 列表 + 详情
四步向导:选Skill → 选产品 → 填参数 → 确认提交
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from datetime import datetime, timezone
import uuid
from app.core.database import get_db
from app.core.permission import PermissionService
from app.api.deps import get_current_user
from app.models.user import User
from app.models.task import Task
from app.models.skill import Skill, SkillCallLog
from app.models.product import Product
from app.schemas.task import TaskCreate, TaskResponse
from app.schemas.common import ResponseModel, PageResponse
router = APIRouter(prefix="/tasks", tags=["任务中心"])
@router.get("", response_model=PageResponse[TaskResponse])
async def list_tasks(
page: int = Query(1), page_size: int = Query(10),
search: str = Query(""), status: str = Query(None),
skill_id: int = Query(None), product_id: int = Query(None),
creator_id: int = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(Task)
if search:
stmt = stmt.where(Task.task_no.ilike(f"%{search}%"))
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)
if creator_id:
stmt = stmt.where(Task.creator_id == creator_id)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
tasks = (await db.execute(
stmt.order_by(Task.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
# 批量获取关联名称
skill_ids = list({t.skill_id for t in tasks})
product_ids = list({t.product_id for t in tasks})
creator_ids = list({t.creator_id for t in tasks})
skill_map, product_map, user_map = {}, {}, {}
if skill_ids:
ss = (await db.execute(select(Skill).where(Skill.id.in_(skill_ids)))).scalars().all()
skill_map = {s.id: s.name for s in ss}
if product_ids:
ps = (await db.execute(select(Product).where(Product.id.in_(product_ids)))).scalars().all()
product_map = {p.id: p.name for p in ps}
if creator_ids:
us = (await db.execute(select(User).where(User.id.in_(creator_ids)))).scalars().all()
user_map = {u.id: u.username for u in us}
data = [
TaskResponse(
id=t.id, task_no=t.task_no, skill_id=t.skill_id,
skill_name=skill_map.get(t.skill_id),
product_id=t.product_id,
product_name=product_map.get(t.product_id),
creator_id=t.creator_id,
creator_name=user_map.get(t.creator_id),
executor_type=t.executor_type,
input_params=t.input_params,
output_data=t.output_data,
execution_log=t.execution_log,
status=t.status,
duration_ms=t.duration_ms,
error_message=t.error_message,
created_at=t.created_at,
started_at=t.started_at,
completed_at=t.completed_at,
) for t in tasks
]
return PageResponse(data=data, total=total, page=page, page_size=page_size)
@router.post("", response_model=ResponseModel[TaskResponse])
async def create_task(
body: TaskCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
创建任务 — 四步向导最终提交
权限校验: RB-07(Skill) + RB-12(产品交集)
"""
# 权限校验
perm_service = PermissionService(db)
await perm_service.verify_task_permission(current_user.id, body.skill_id, body.product_id)
# 获取Skill信息
skill = (await db.execute(
select(Skill).where(Skill.id == body.skill_id, Skill.deleted_at == None)
)).scalar_one_or_none()
if not skill:
raise HTTPException(404, "Skill不存在")
if skill.status != "active":
raise HTTPException(400, "Skill已禁用")
# 创建任务
task_no = f"TASK-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6].upper()}"
task = Task(
task_no=task_no,
skill_id=body.skill_id,
product_id=body.product_id,
creator_id=current_user.id,
executor_type=skill.executor_type,
input_params=body.input_params,
status="PENDING",
)
db.add(task)
await db.flush()
# 异步提交到Celery执行
from app.celery_app.tasks.execute_task import execute_task
execute_task.delay(task.id)
return ResponseModel(
data=TaskResponse(
id=task.id, task_no=task.task_no,
skill_id=task.skill_id, product_id=task.product_id,
creator_id=task.creator_id, executor_type=task.executor_type,
input_params=task.input_params, status=task.status,
created_at=task.created_at,
),
message="✅ 任务已创建,等待执行",
)
@router.get("/{task_id}", response_model=ResponseModel[TaskResponse])
async def get_task(task_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
t = (await db.execute(select(Task).where(Task.id == task_id))).scalar_one_or_none()
if not t:
raise HTTPException(404, "任务不存在")
# 获取关联名称
skill = (await db.execute(select(Skill).where(Skill.id == t.skill_id))).scalar_one_or_none()
product = (await db.execute(select(Product).where(Product.id == t.product_id))).scalar_one_or_none()
creator = (await db.execute(select(User).where(User.id == t.creator_id))).scalar_one_or_none()
return ResponseModel(data=TaskResponse(
id=t.id, task_no=t.task_no, skill_id=t.skill_id,
skill_name=skill.name if skill else None,
product_id=t.product_id,
product_name=product.name if product else None,
creator_id=t.creator_id,
creator_name=creator.username if creator else None,
executor_type=t.executor_type,
input_params=t.input_params, output_data=t.output_data,
execution_log=t.execution_log, status=t.status,
duration_ms=t.duration_ms, error_message=t.error_message,
created_at=t.created_at, started_at=t.started_at,
completed_at=t.completed_at,
))
@router.post("/{task_id}/retry", response_model=ResponseModel)
async def retry_task(task_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
"""重试失败/超时的任务"""
t = (await db.execute(select(Task).where(Task.id == task_id))).scalar_one_or_none()
if not t:
raise HTTPException(404, "任务不存在")
if t.status not in ("FAILED", "TIMEOUT"):
raise HTTPException(400, "仅失败/超时任务可重试")
t.status = "PENDING"
t.error_message = None
t.output_data = None
t.started_at = None
t.completed_at = None
from app.celery_app.tasks.execute_task import execute_task
execute_task.delay(t.id)
return ResponseModel(message="✅ 已重新提交执行")
+217
View File
@@ -0,0 +1,217 @@
"""
用户管理 API
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from datetime import datetime, timezone
from app.core.database import get_db
from app.core.security import hash_password
from app.api.deps import get_current_user
from app.models.user import User, Role, Organization, UserProductGrant, user_role_table
from app.schemas.user import UserCreate, UserUpdate, UserResponse, ResetPasswordRequest
from app.schemas.common import ResponseModel, PageResponse
router = APIRouter(prefix="/users", tags=["用户管理"])
@router.get("", response_model=PageResponse[UserResponse])
async def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
search: str = Query("", description="搜索用户名/工号"),
org_id: int = Query(None, description="部门筛选"),
status: str = Query(None, description="状态筛选"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""用户列表"""
stmt = (
select(User)
.options(selectinload(User.roles), selectinload(User.organization), selectinload(User.product_grants))
.where(User.deleted_at == None)
)
if search:
stmt = stmt.where(
(User.username.ilike(f"%{search}%")) | (User.employee_no.ilike(f"%{search}%"))
)
if org_id:
stmt = stmt.where(User.org_id == org_id)
if status:
stmt = stmt.where(User.status == status)
# 总数
count_stmt = select(func.count()).select_from(stmt.subquery())
total = (await db.execute(count_stmt)).scalar() or 0
# 分页
stmt = stmt.order_by(User.id.desc()).offset((page - 1) * page_size).limit(page_size)
result = await db.execute(stmt)
users = result.scalars().all()
data = []
for u in users:
data.append(UserResponse(
id=u.id, username=u.username, email=u.email, phone=u.phone,
employee_no=u.employee_no, org_id=u.org_id,
org_name=u.organization.name if u.organization else None,
status=u.status.value,
roles=[{"id": r.id, "name": r.name, "code": r.code} for r in u.roles],
platform_account_count=0,
grant_count=len(u.product_grants),
created_at=u.created_at,
))
return PageResponse(data=data, total=total, page=page, page_size=page_size)
@router.post("", response_model=ResponseModel[UserResponse])
async def create_user(
body: UserCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""新增用户"""
# 检查用户名唯一
exists = await db.execute(select(User).where(User.username == body.username, User.deleted_at == None))
if exists.scalar_one_or_none():
raise HTTPException(status_code=400, detail="用户名已存在")
# 密码校验 RB-03
if len(body.password) < 6:
raise HTTPException(status_code=400, detail="密码至少6位")
user = User(
username=body.username,
password_hash=hash_password(body.password),
email=body.email, phone=body.phone,
employee_no=body.employee_no, org_id=body.org_id,
status=body.status,
)
db.add(user)
await db.flush()
# 绑定角色 — 只能绑定本部门角色 (RB-02)
if body.role_ids:
roles_stmt = select(Role).where(Role.id.in_(body.role_ids), Role.deleted_at == None)
roles_result = await db.execute(roles_stmt)
roles = roles_result.scalars().all()
for r in roles:
if not r.is_global and r.org_id != user.org_id:
raise HTTPException(status_code=400, detail=f"角色 {r.name} 不属于该部门 (RB-02)")
user.roles = list(roles)
# 额外产品授权
for grant in body.product_grants:
g = UserProductGrant(
user_id=user.id,
product_id=grant["product_id"],
expire_date=grant.get("expire_date"),
)
db.add(g)
await db.flush()
return ResponseModel(
data=UserResponse(
id=user.id, username=user.username, email=user.email, phone=user.phone,
employee_no=user.employee_no, org_id=user.org_id, status=user.status.value,
roles=[{"id": r.id, "name": r.name, "code": r.code} for r in user.roles],
created_at=user.created_at,
),
message="✅ 保存成功",
)
@router.put("/{user_id}", response_model=ResponseModel[UserResponse])
async def update_user(
user_id: int, body: UserUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""编辑用户"""
stmt = select(User).options(selectinload(User.roles)).where(User.id == user_id, User.deleted_at == None)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 更新基本字段
for field in ["email", "phone", "employee_no", "status"]:
val = getattr(body, field, None)
if val is not None:
setattr(user, field, val)
# 切换部门清空角色 (RB-08)
if body.org_id is not None and body.org_id != user.org_id:
user.org_id = body.org_id
user.roles = [] # 清空角色
# 重新绑定角色
if body.role_ids is not None:
roles_stmt = select(Role).where(Role.id.in_(body.role_ids), Role.deleted_at == None)
roles_result = await db.execute(roles_stmt)
roles = roles_result.scalars().all()
for r in roles:
if not r.is_global and r.org_id != user.org_id:
raise HTTPException(status_code=400, detail=f"角色 {r.name} 不属于该部门 (RB-02)")
user.roles = list(roles)
# 额外授权
if body.product_grants is not None:
# 清除旧的
old_grants = await db.execute(
select(UserProductGrant).where(UserProductGrant.user_id == user_id)
)
for g in old_grants.scalars().all():
await db.delete(g)
for grant in body.product_grants:
db.add(UserProductGrant(
user_id=user_id, product_id=grant["product_id"],
expire_date=grant.get("expire_date"),
))
await db.flush()
return ResponseModel(message="✅ 保存成功")
@router.delete("/{user_id}", response_model=ResponseModel)
async def delete_user(
user_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""删除用户 — RB-04: 级联清除角色绑定和平台账号"""
stmt = select(User).where(User.id == user_id, User.deleted_at == None)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.deleted_at = datetime.now(timezone.utc)
user.roles = [] # 清除角色绑定
# 平台账号由外键CASCADE处理
return ResponseModel(message="✅ 已删除")
@router.post("/{user_id}/reset-password", response_model=ResponseModel)
async def reset_password(
user_id: int, body: ResetPasswordRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""重置密码"""
if body.new_password != body.confirm_password:
raise HTTPException(status_code=400, detail="两次密码不一致")
if len(body.new_password) < 6:
raise HTTPException(status_code=400, detail="密码至少6位 (RB-03)")
stmt = select(User).where(User.id == user_id, User.deleted_at == None)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.password_hash = hash_password(body.new_password)
return ResponseModel(message="✅ 密码已重置")
+32
View File
@@ -0,0 +1,32 @@
"""
Celery App 配置
"""
from celery import Celery
from app.config import settings
celery_app = Celery(
"agent_task_system",
broker=settings.CELERY_BROKER_URL,
backend=settings.CELERY_RESULT_BACKEND,
)
celery_app.conf.update(
task_serializer="json",
result_serializer="json",
accept_content=["json"],
timezone="UTC",
enable_utc=True,
task_track_started=True,
task_acks_late=True,
worker_prefetch_multiplier=1,
task_soft_time_limit=300,
task_time_limit=600,
beat_schedule={
"check-schedule-tasks": {
"task": "app.celery_app.tasks.scheduler.check_schedule_tasks",
"schedule": 60.0, # 每分钟检查
},
},
)
celery_app.autodiscover_tasks(["app.celery_app.tasks"])
+1
View File
@@ -0,0 +1 @@
# Celery tasks package
@@ -0,0 +1,211 @@
"""
任务执行器 — 龙虾Agent + 脚本双执行器
"""
import time
import uuid
import httpx
import subprocess
import tempfile
import os
from datetime import datetime, timezone
from app.celery_app import celery_app
from app.config import settings
# 同步数据库连接(Celery Worker不用asyncio)
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
sync_engine = create_engine(
settings.DATABASE_URL.replace("+asyncpg", ""),
pool_size=5,
max_overflow=10,
)
def get_sync_session():
return Session(sync_engine)
@celery_app.task(bind=True, max_retries=3, default_retry_delay=30)
def execute_task(self, task_id: int):
"""
执行任务 — 根据executor_type分发到龙虾或脚本执行器
"""
from app.models.task import Task
from app.models.skill import Skill, SkillCallLog
from app.core.security import aes_decrypt
session = get_sync_session()
try:
task = session.get(Task, task_id)
if not task:
return {"error": "Task not found"}
# 更新状态
task.status = "RUNNING"
task.started_at = datetime.now(timezone.utc)
session.commit()
skill = session.get(Skill, task.skill_id)
if not skill:
task.status = "FAILED"
task.error_message = "关联Skill不存在"
task.completed_at = datetime.now(timezone.utc)
session.commit()
return
start_time = time.time()
call_id = f"CALL-{uuid.uuid4().hex[:8].upper()}"
try:
if skill.executor_type == "lobster":
result = _execute_lobster(skill, task, session)
elif skill.executor_type == "script":
result = _execute_script(skill, task, session)
else:
raise ValueError(f"Unknown executor type: {skill.executor_type}")
duration_ms = int((time.time() - start_time) * 1000)
task.status = "SUCCEEDED"
task.output_data = result.get("data")
task.execution_log = result.get("log", "")
task.duration_ms = duration_ms
task.completed_at = datetime.now(timezone.utc)
# 写Skill调用日志
call_log = SkillCallLog(
call_id=call_id,
skill_id=skill.id,
user_id=task.creator_id,
product_id=task.product_id,
executor_type=skill.executor_type,
request_params=task.input_params,
response_data=result.get("data"),
execution_log=result.get("log", ""),
status="SUCCESS",
duration_ms=duration_ms,
)
session.add(call_log)
session.commit()
except Exception as e:
duration_ms = int((time.time() - start_time) * 1000)
task.status = "FAILED"
task.error_message = str(e)[:2000]
task.duration_ms = duration_ms
task.completed_at = datetime.now(timezone.utc)
call_log = SkillCallLog(
call_id=call_id,
skill_id=skill.id,
user_id=task.creator_id,
product_id=task.product_id,
executor_type=skill.executor_type,
request_params=task.input_params,
execution_log=str(e),
status="FAILED",
duration_ms=duration_ms,
)
session.add(call_log)
session.commit()
# Celery重试
raise self.retry(exc=e)
finally:
session.close()
def _execute_lobster(skill, task, session) -> dict:
"""龙虾Agent执行器 — 调用第三方API"""
from app.core.security import aes_decrypt
api_url = skill.lobster_api_url
api_key = aes_decrypt(skill.lobster_api_key_enc) if skill.lobster_api_key_enc else ""
agent_id = skill.lobster_agent_id
# 参数映射
params = task.input_params or {}
if skill.param_mapping:
mapped = {}
for target_key, source_key in skill.param_mapping.items():
mapped[target_key] = params.get(source_key, "")
params = mapped
payload = {
"agent_id": agent_id,
"input": params,
"metadata": {
"task_no": task.task_no,
"product_id": task.product_id,
},
}
with httpx.Client(timeout=120) as client:
resp = client.post(
f"{api_url}/execute",
json=payload,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
)
resp.raise_for_status()
data = resp.json()
return {
"data": data.get("result", data),
"log": f"Lobster API responded: status={resp.status_code}",
}
def _execute_script(skill, task, session) -> dict:
"""脚本执行器 — Celery Worker沙箱隔离执行"""
from app.models.skill import ScriptTemplate
template = session.get(ScriptTemplate, skill.script_template_id)
if not template:
raise ValueError("脚本模板不存在")
code = template.code
script_type = template.script_type
timeout = skill.script_timeout or 30
# 合并环境变量
env = os.environ.copy()
if template.env_vars:
env.update({k: str(v) for k, v in template.env_vars.items()})
if skill.script_env_vars:
env.update({k: str(v) for k, v in skill.script_env_vars.items()})
# 注入任务参数
if task.input_params:
import json
env["TASK_INPUT"] = json.dumps(task.input_params, ensure_ascii=False)
env["TASK_NO"] = task.task_no
env["PRODUCT_ID"] = str(task.product_id)
# 写临时脚本
suffix_map = {"python": ".py", "nodejs": ".js", "shell": ".sh"}
suffix = suffix_map.get(script_type, ".sh")
with tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) as f:
f.write(code)
script_path = f.name
try:
cmd_map = {"python": ["python3", script_path], "nodejs": ["node", script_path], "shell": ["bash", script_path]}
cmd = cmd_map.get(script_type, ["bash", script_path])
result = subprocess.run(
cmd, env=env, capture_output=True, text=True,
timeout=timeout, cwd="/tmp",
)
if result.returncode != 0:
raise RuntimeError(f"Script exit code {result.returncode}: {result.stderr[:1000]}")
return {
"data": {"stdout": result.stdout[:5000], "exit_code": result.returncode},
"log": f"stdout({len(result.stdout)} chars), stderr({len(result.stderr)} chars)",
}
finally:
os.unlink(script_path)
+83
View File
@@ -0,0 +1,83 @@
"""
定时任务调度器 — Celery Beat
"""
from datetime import datetime, timezone
from croniter import croniter
from app.celery_app import celery_app
from app.celery_app.tasks.execute_task import get_sync_session
from app.models.task import Task, ScheduleTask, ScheduleRecord
from app.models.skill import Skill
import uuid
@celery_app.task
def check_schedule_tasks():
"""每分钟检查是否有到期的定时任务"""
session = get_sync_session()
try:
from sqlalchemy import select
stmt = select(ScheduleTask).where(
ScheduleTask.status == "active",
ScheduleTask.deleted_at == None,
)
tasks = session.execute(stmt).scalars().all()
now = datetime.now(timezone.utc)
for st in tasks:
# 判断是否到执行时间
if st.next_run_at and st.next_run_at > now:
continue
# 执行
try:
_run_schedule(session, st, now)
except Exception as e:
# 记录失败
record = ScheduleRecord(
schedule_id=st.id,
status="FAILED",
result_text=str(e)[:2000],
executed_at=now,
)
session.add(record)
# 计算下次执行时间
cron = croniter(st.cron_expression, now)
st.next_run_at = cron.get_next(datetime)
st.last_run_at = now
session.commit()
finally:
session.close()
def _run_schedule(session, schedule_task, now):
"""创建一个实际任务并提交执行"""
task_no = f"SCH-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6].upper()}"
task = Task(
task_no=task_no,
skill_id=schedule_task.skill_id,
product_id=schedule_task.product_id or 0,
creator_id=schedule_task.creator_id,
executor_type="auto",
input_params=schedule_task.exec_params,
status="PENDING",
)
session.add(task)
session.flush()
# 异步执行
from app.celery_app.tasks.execute_task import execute_task
execute_task.delay(task.id)
# 记录执行
record = ScheduleRecord(
schedule_id=schedule_task.id,
status="PENDING",
executed_at=now,
)
session.add(record)
+51
View File
@@ -0,0 +1,51 @@
"""
应用配置 - Pydantic Settings
"""
from pydantic_settings import BaseSettings
from typing import List
import json
class Settings(BaseSettings):
# App
APP_NAME: str = "Agent任务系统"
APP_VERSION: str = "2.1.0"
DEBUG: bool = False
# Database
DATABASE_URL: str = "postgresql+asyncpg://postgres:agent_system_2026@localhost:5432/agent_system"
DATABASE_URL_SYNC: str = "postgresql://postgres:agent_system_2026@localhost:5432/agent_system"
# Redis
REDIS_URL: str = "redis://localhost:6379/0"
# JWT
SECRET_KEY: str = "agent-task-system-secret-key-change-in-production-2026"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 420 # 7小时
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
# Encryption (AES-256 for API keys)
ENCRYPT_KEY: str = "0123456789abcdef0123456789abcdef"
# CORS
CORS_ORIGINS: str = '["http://localhost:5173","http://localhost:3000"]'
@property
def cors_origins_list(self) -> List[str]:
return json.loads(self.CORS_ORIGINS)
# Celery
CELERY_BROKER_URL: str = "redis://localhost:6379/1"
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/2"
# Script executor
SCRIPT_WORK_DIR: str = "/tmp/agent-scripts"
SCRIPT_DEFAULT_TIMEOUT: int = 60
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
+49
View File
@@ -0,0 +1,49 @@
"""
Redis 缓存工具
"""
import redis.asyncio as aioredis
import json
from typing import Optional, Any
from app.config import settings
_pool: Optional[aioredis.Redis] = None
async def get_redis() -> aioredis.Redis:
global _pool
if _pool is None:
_pool = aioredis.from_url(
settings.REDIS_URL,
decode_responses=True,
max_connections=20,
)
return _pool
async def cache_get(key: str) -> Optional[Any]:
r = await get_redis()
val = await r.get(key)
if val:
try:
return json.loads(val)
except json.JSONDecodeError:
return val
return None
async def cache_set(key: str, value: Any, ttl: int = 3600):
r = await get_redis()
if isinstance(value, (dict, list)):
value = json.dumps(value, ensure_ascii=False)
await r.set(key, value, ex=ttl)
async def cache_delete(key: str):
r = await get_redis()
await r.delete(key)
async def cache_delete_pattern(pattern: str):
r = await get_redis()
async for key in r.scan_iter(match=pattern):
await r.delete(key)
+43
View File
@@ -0,0 +1,43 @@
"""
数据库连接管理
"""
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_size=20,
max_overflow=10,
pool_pre_ping=True,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db():
"""FastAPI 依赖注入 — 获取数据库 session"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db():
"""初始化数据库表"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+175
View File
@@ -0,0 +1,175 @@
"""
权限服务 — 实现部门级RBAC三要素权限模型
RB-02: 多角色权限取并集
RB-07: Skill权限并集
RB-08: 切换部门清空角色
RB-12: 产品范围交集校验
"""
from typing import List, Set, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.user import User, Role, user_role_table
from app.models.menu import Menu, MenuButton
from app.models.product import Product
from app.models.skill import Skill
class EffectivePermissions:
"""用户有效权限集合"""
def __init__(self):
self.menu_ids: Set[int] = set()
self.button_codes: Set[str] = set()
self.product_ids: Set[int] = set()
self.skill_ids: Set[int] = set()
class PermissionService:
def __init__(self, db: AsyncSession):
self.db = db
async def get_user_effective_permissions(self, user_id: int) -> EffectivePermissions:
"""
计算用户有效权限 = 所有角色权限并集 (RB-02)
"""
perms = EffectivePermissions()
# 加载用户+角色+角色关联的菜单/产品/Skill
stmt = (
select(User)
.options(
selectinload(User.roles).selectinload(Role.menus).selectinload(Menu.buttons),
selectinload(User.roles).selectinload(Role.products),
selectinload(User.roles).selectinload(Role.skills),
selectinload(User.product_grants),
)
.where(User.id == user_id)
)
result = await self.db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
return perms
for role in user.roles:
if role.status != "active" or role.deleted_at is not None:
continue
# RB-02: 用户只能绑定本部门角色 (is_global 或同部门)
if not role.is_global and role.org_id != user.org_id:
continue
# 菜单权限
for menu in role.menus:
perms.menu_ids.add(menu.id)
for btn in menu.buttons:
if btn.status == "active":
perms.button_codes.add(btn.permission_code)
# 产品范围
for product in role.products:
if product.deleted_at is None:
perms.product_ids.add(product.id)
# Skill权限
for skill in role.skills:
if skill.deleted_at is None and skill.status == "active":
perms.skill_ids.add(skill.id)
# 额外产品授权 (跨组协作)
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
for grant in user.product_grants:
if grant.expire_date is None or grant.expire_date > now:
perms.product_ids.add(grant.product_id)
return perms
async def verify_task_permission(
self, user_id: int, skill_id: int, product_id: int
) -> bool:
"""
任务创建权限校验
RB-07: Skill权限
RB-12: Skill.product_scope ∩ 用户角色.data_scope
"""
perms = await self.get_user_effective_permissions(user_id)
# 校验1: Skill权限
if skill_id not in perms.skill_ids:
raise PermissionError("无权使用此Skill (RB-07)")
# 校验2: 产品范围
if product_id not in perms.product_ids:
raise PermissionError("无权操作此产品")
# 校验3: Skill的产品范围交集 (RB-12)
stmt = (
select(Skill)
.options(selectinload(Skill.products))
.where(Skill.id == skill_id)
)
result = await self.db.execute(stmt)
skill = result.scalar_one_or_none()
if not skill:
raise PermissionError("Skill不存在")
skill_product_ids = {p.id for p in skill.products}
allowed = perms.product_ids & skill_product_ids
if product_id not in allowed:
raise PermissionError("该Skill不可操作此产品 (RB-12交集校验)")
return True
async def get_menu_tree(self, user_id: int) -> list:
"""获取用户可见菜单树"""
perms = await self.get_user_effective_permissions(user_id)
# 查所有菜单
stmt = (
select(Menu)
.options(selectinload(Menu.buttons))
.where(Menu.status == "active", Menu.visible == True)
.order_by(Menu.sort_order)
)
result = await self.db.execute(stmt)
all_menus = result.scalars().all()
# 超级管理员看全部
is_super = await self._is_super_admin(user_id)
if is_super:
return self._build_tree(all_menus, None)
# 过滤:只保留有权限的菜单
visible = [m for m in all_menus if m.id in perms.menu_ids]
return self._build_tree(visible, None)
async def _is_super_admin(self, user_id: int) -> bool:
stmt = (
select(Role.code)
.join(user_role_table)
.where(user_role_table.c.user_id == user_id)
)
result = await self.db.execute(stmt)
codes = [r[0] for r in result.all()]
return "super_admin" in codes
def _build_tree(self, menus: list, parent_id) -> list:
tree = []
for m in menus:
if m.parent_id == parent_id:
node = {
"id": m.id,
"name": m.name,
"menu_type": m.menu_type,
"path": m.path,
"icon": m.icon,
"component": m.component,
"sort_order": m.sort_order,
"buttons": [
{"id": b.id, "name": b.name, "code": b.permission_code, "type": b.button_type}
for b in (m.buttons or []) if b.status == "active"
],
"children": self._build_tree(menus, m.id),
}
tree.append(node)
return tree
+72
View File
@@ -0,0 +1,72 @@
"""
安全工具 — JWT签发/验证 + 密码哈希 + AES加密
"""
from datetime import datetime, timedelta, timezone
from typing import Optional, Any
from jose import jwt, JWTError
from passlib.context import CryptContext
from cryptography.fernet import Fernet
import base64
import hashlib
from app.config import settings
# ============ 密码哈希 ============
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
# ============ JWT ============
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def create_refresh_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> dict:
"""解码JWT,失败抛 JWTError"""
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
# ============ AES-256 加密 (API Key / 密码存储) ============
def _get_fernet() -> Fernet:
"""将32字节hex key转为Fernet兼容的base64 key"""
raw = settings.ENCRYPT_KEY.encode()[:32]
key = base64.urlsafe_b64encode(hashlib.sha256(raw).digest())
return Fernet(key)
def encrypt_value(plain: str) -> str:
"""加密敏感字段"""
f = _get_fernet()
return f.encrypt(plain.encode()).decode()
def decrypt_value(cipher: str) -> str:
"""解密敏感字段"""
f = _get_fernet()
return f.decrypt(cipher.encode()).decode()
def mask_value(value: str, visible: int = 4) -> str:
"""掩码显示,仅显示末尾N位"""
if len(value) <= visible:
return "●" * len(value)
return "●" * (len(value) - visible) + value[-visible:]
+107
View File
@@ -0,0 +1,107 @@
"""
Agent任务系统 — FastAPI 主入口
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import time
import uuid
from app.config import settings
from app.core.database import engine, Base, get_db
from app.models import * # noqa: 确保所有模型注册到Base.metadata
@asynccontextmanager
async def lifespan(app: FastAPI):
"""启动/关闭"""
# 启动:建表(开发环境)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# 初始化种子数据
from app.scripts.seed import seed_initial_data
async for db in get_db():
await seed_initial_data(db)
break
yield
# 关闭
await engine.dispose()
app = FastAPI(
title="Agent任务系统",
version=settings.APP_VERSION,
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ---- 全局中间件:请求日志 + 耗时 ----
@app.middleware("http")
async def log_requests(request: Request, call_next):
request_id = uuid.uuid4().hex[:8]
start = time.time()
response = await call_next(request)
duration = round((time.time() - start) * 1000, 1)
response.headers["X-Request-ID"] = request_id
response.headers["X-Response-Time"] = f"{duration}ms"
return response
# ---- 全局异常处理 ----
@app.exception_handler(PermissionError)
async def permission_handler(request: Request, exc: PermissionError):
return JSONResponse(status_code=403, content={"code": 403, "message": str(exc)})
@app.exception_handler(Exception)
async def global_handler(request: Request, exc: Exception):
return JSONResponse(status_code=500, content={"code": 500, "message": "服务器内部错误"})
# ---- 注册路由 ----
from app.api.v1 import auth, organizations, users, roles, menus, tasks, platform_accounts, skills, products, logs
app.include_router(auth.router, prefix="/api/v1")
app.include_router(organizations.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1")
app.include_router(roles.router, prefix="/api/v1")
app.include_router(menus.router, prefix="/api/v1")
app.include_router(tasks.router, prefix="/api/v1")
app.include_router(platform_accounts.router, prefix="/api/v1")
# Skill相关 (多个子路由)
app.include_router(skills.skill_router, prefix="/api/v1")
app.include_router(skills.tpl_router, prefix="/api/v1")
app.include_router(skills.call_log_router, prefix="/api/v1")
# 产品相关 (多个子路由)
app.include_router(products.platform_router, prefix="/api/v1")
app.include_router(products.product_router, prefix="/api/v1")
app.include_router(products.channel_router, prefix="/api/v1")
# 日志/统计/执行器/定时任务
app.include_router(logs.log_router, prefix="/api/v1")
app.include_router(logs.stats_router, prefix="/api/v1")
app.include_router(logs.executor_router, prefix="/api/v1")
app.include_router(logs.schedule_router, prefix="/api/v1")
# ---- 健康检查 ----
@app.get("/health")
async def health():
return {"status": "ok", "version": settings.APP_VERSION}
@app.get("/api/v1/version")
async def version():
return {"version": settings.APP_VERSION, "name": "Agent任务系统"}
+23
View File
@@ -0,0 +1,23 @@
from app.models.user import (
Organization, Role, User, UserProductGrant,
user_role_table, role_menu_table, role_skill_table,
role_product_table, skill_product_table,
)
from app.models.menu import Menu, MenuButton
from app.models.product import Platform, Product, ChannelCode, channel_product_table
from app.models.skill import ScriptTemplate, Skill, SkillCallLog
from app.models.task import Task, ScheduleTask, ScheduleRecord
from app.models.platform_account import PlatformAccount
from app.models.log import OperationLog
from app.models.executor import ExecutorConfig
__all__ = [
"Organization", "Role", "User", "UserProductGrant",
"Menu", "MenuButton",
"Platform", "Product", "ChannelCode",
"ScriptTemplate", "Skill", "SkillCallLog",
"Task", "ScheduleTask", "ScheduleRecord",
"PlatformAccount",
"OperationLog",
"ExecutorConfig",
]
+33
View File
@@ -0,0 +1,33 @@
"""
基础模型 — 公共字段 (时间戳、UUID主键)
"""
from datetime import datetime, timezone
from sqlalchemy import Column, Integer, DateTime, String, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class TimestampMixin:
"""创建/更新时间"""
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
class SoftDeleteMixin:
"""软删除"""
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)
@property
def is_deleted(self) -> bool:
return self.deleted_at is not None
+20
View File
@@ -0,0 +1,20 @@
"""
执行器配置
"""
from sqlalchemy import Integer, String, JSON, Float
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.models.base import TimestampMixin
class ExecutorConfig(Base, TimestampMixin):
__tablename__ = "t_executor_config"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
executor_type: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, comment="lobster/script")
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="执行器名称")
config_data: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="全部配置JSON")
max_concurrency: Mapped[int] = mapped_column(Integer, default=10, comment="并发上限")
current_load: Mapped[int] = mapped_column(Integer, default=0, comment="当前负载")
status: Mapped[str] = mapped_column(String(20), default="active", comment="active/inactive/error")
health_data: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="24h健康数据")
+30
View File
@@ -0,0 +1,30 @@
"""
操作日志 — 全局审计
"""
from datetime import datetime, timezone
from sqlalchemy import Integer, String, Text, ForeignKey, JSON, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class OperationLog(Base):
__tablename__ = "t_operation_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
log_id: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, comment="OPLOG-xxx")
user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("t_user.id"), nullable=True)
username: Mapped[str] = mapped_column(String(50), nullable=False, comment="操作人(冗余)")
module: Mapped[str] = mapped_column(String(50), nullable=False, comment="模块名")
action: Mapped[str] = mapped_column(String(20), nullable=False, comment="create/update/delete/login/export")
description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="操作描述")
request_params: Mapped[dict | None] = mapped_column(JSON, nullable=True)
before_data: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="变更前")
after_data: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="变更后")
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
user = relationship("User")
+40
View File
@@ -0,0 +1,40 @@
"""
菜单 + 按钮权限模型
"""
from sqlalchemy import Integer, String, Text, Boolean, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin, SoftDeleteMixin
from app.models.user import role_menu_table
class Menu(Base, TimestampMixin):
__tablename__ = "t_menu"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(50), nullable=False, comment="菜单名称")
menu_type: Mapped[str] = mapped_column(String(20), nullable=False, comment="directory/menu/link")
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("t_menu.id"), nullable=True)
path: Mapped[str] = mapped_column(String(200), nullable=False, default="", comment="路由路径")
icon: Mapped[str | None] = mapped_column(String(50), nullable=True)
component: Mapped[str | None] = mapped_column(String(200), nullable=True, comment="前端组件路径")
sort_order: Mapped[int] = mapped_column(Integer, default=1)
visible: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否可见")
status: Mapped[str] = mapped_column(String(20), default="active")
parent = relationship("Menu", remote_side="Menu.id", backref="children")
buttons = relationship("MenuButton", back_populates="menu", cascade="all, delete-orphan")
roles = relationship("Role", secondary=role_menu_table, back_populates="menus")
class MenuButton(Base, TimestampMixin):
__tablename__ = "t_menu_button"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
menu_id: Mapped[int] = mapped_column(Integer, ForeignKey("t_menu.id", ondelete="CASCADE"))
name: Mapped[str] = mapped_column(String(50), nullable=False, comment="按钮名称")
permission_code: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, comment="权限标识如product:create")
button_type: Mapped[str] = mapped_column(String(20), default="default", comment="primary/default/danger/warning")
status: Mapped[str] = mapped_column(String(20), default="active")
menu = relationship("Menu", back_populates="buttons")
+26
View File
@@ -0,0 +1,26 @@
"""
平台产品账号 — v2.1归属用户
"""
from sqlalchemy import Integer, String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin, SoftDeleteMixin
class PlatformAccount(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "t_platform_account"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
platform_id: Mapped[int] = mapped_column(Integer, ForeignKey("t_platform.id"), comment="所属平台")
product_id: Mapped[int] = mapped_column(Integer, ForeignKey("t_product.id"), comment="关联产品")
owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("t_user.id"), comment="v2.1归属用户")
role_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("t_role.id"), nullable=True, comment="兼容旧版角色归属")
login_account: Mapped[str] = mapped_column(String(200), nullable=False, comment="登录账号")
login_password_enc: Mapped[str] = mapped_column(String(500), nullable=False, comment="AES加密存储密码")
google_key_enc: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="谷歌二次验证密钥加密")
status: Mapped[str] = mapped_column(String(20), default="active")
platform = relationship("Platform", back_populates="accounts")
product = relationship("Product")
owner = relationship("User", foreign_keys=[owner_id], back_populates="platform_accounts_owned")
role = relationship("Role", back_populates="platform_accounts")
+65
View File
@@ -0,0 +1,65 @@
"""
产品 + 渠道码 + 平台套件 模型
"""
from sqlalchemy import Integer, String, Text, ForeignKey, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin, SoftDeleteMixin
from app.models.user import role_product_table
# 渠道码-产品 关联表
channel_product_table = Table(
"t_channel_product",
Base.metadata,
Column("channel_code_id", Integer, ForeignKey("t_channel_code.id", ondelete="CASCADE"), primary_key=True),
Column("product_id", Integer, ForeignKey("t_product.id", ondelete="CASCADE"), primary_key=True),
)
class Platform(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "t_platform"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(50), nullable=False, comment="平台名称")
address: Mapped[str] = mapped_column(String(200), nullable=False, comment="平台地址")
icon: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="图标emoji或URL")
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="active")
products = relationship("Product", back_populates="platform")
accounts = relationship("PlatformAccount", back_populates="platform")
class Product(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "t_product"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="产品名称")
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, comment="产品代号PROD001")
platform_id: Mapped[int] = mapped_column(Integer, ForeignKey("t_platform.id"), comment="所属平台")
org_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("t_organization.id"), nullable=True, comment="所属部门")
team_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("t_organization.id"), nullable=True, comment="所属小组")
leader_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("t_user.id"), nullable=True, comment="负责人")
internal_channel_code: Mapped[str] = mapped_column(String(50), nullable=False, comment="内导渠道码RB-09")
natural_channel_code: Mapped[str] = mapped_column(String(50), nullable=False, comment="自然渠道码RB-09")
status: Mapped[str] = mapped_column(String(20), default="active")
platform = relationship("Platform", back_populates="products")
organization = relationship("Organization", foreign_keys=[org_id])
team = relationship("Organization", foreign_keys=[team_id])
leader = relationship("User", foreign_keys=[leader_id])
roles = relationship("Role", secondary=role_product_table, back_populates="products")
channel_codes = relationship("ChannelCode", secondary=channel_product_table, back_populates="products")
class ChannelCode(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "t_channel_code"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, comment="渠道码IC001/NC001")
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="渠道名称")
channel_type: Mapped[str] = mapped_column(String(20), nullable=False, comment="internal/natural")
status: Mapped[str] = mapped_column(String(20), default="active")
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
products = relationship("Product", secondary=channel_product_table, back_populates="channel_codes")
+71
View File
@@ -0,0 +1,71 @@
"""
Skill + 脚本模板 模型
"""
from sqlalchemy import Integer, String, Text, ForeignKey, JSON, Float
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin, SoftDeleteMixin
from app.models.user import role_skill_table, skill_product_table
class ScriptTemplate(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "t_script_template"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="模板名称")
script_type: Mapped[str] = mapped_column(String(20), nullable=False, comment="python/nodejs/shell")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
code: Mapped[str] = mapped_column(Text, nullable=False, comment="脚本代码")
env_vars: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="环境变量声明")
status: Mapped[str] = mapped_column(String(20), default="active")
skills = relationship("Skill", back_populates="script_template")
class Skill(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "t_skill"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="Skill名称")
category: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="分类")
executor_type: Mapped[str] = mapped_column(String(20), nullable=False, comment="lobster/script RB-13")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# 龙虾执行器配置
lobster_api_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
lobster_api_key_enc: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="加密存储")
lobster_agent_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
# 脚本执行器配置
script_template_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("t_script_template.id"), nullable=True)
script_env_vars: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="环境变量覆盖")
script_timeout: Mapped[int | None] = mapped_column(Integer, nullable=True, default=30)
# 参数
input_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="JSON Schema")
param_mapping: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="参数映射")
status: Mapped[str] = mapped_column(String(20), default="active")
# 统计
call_count: Mapped[int] = mapped_column(Integer, default=0, comment="调用次数")
success_rate: Mapped[float] = mapped_column(Float, default=0.0, comment="成功率")
script_template = relationship("ScriptTemplate", back_populates="skills")
roles = relationship("Role", secondary=role_skill_table, back_populates="skills")
products = relationship("Product", secondary=skill_product_table)
class SkillCallLog(Base, TimestampMixin):
__tablename__ = "t_skill_call_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
call_id: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, comment="LOG-xxx")
skill_id: Mapped[int] = mapped_column(Integer, ForeignKey("t_skill.id"))
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("t_user.id"))
product_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("t_product.id"), nullable=True)
executor_type: Mapped[str] = mapped_column(String(20))
request_params: Mapped[dict | None] = mapped_column(JSON, nullable=True)
response_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
execution_log: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), comment="success/failed/timeout")
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
skill = relationship("Skill")
user = relationship("User")
product = relationship("Product")
+68
View File
@@ -0,0 +1,68 @@
"""
任务 + 定时任务 模型
"""
from datetime import datetime
from sqlalchemy import Integer, String, Text, ForeignKey, JSON, Float, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class Task(Base, TimestampMixin):
__tablename__ = "t_task"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
task_no: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, comment="TASK-yyyymmdd-xxx")
skill_id: Mapped[int] = mapped_column(Integer, ForeignKey("t_skill.id"))
product_id: Mapped[int] = mapped_column(Integer, ForeignKey("t_product.id"))
creator_id: Mapped[int] = mapped_column(Integer, ForeignKey("t_user.id"), comment="创建人")
executor_type: Mapped[str] = mapped_column(String(20), comment="lobster/script")
input_params: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="输入参数")
output_data: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="执行结果")
execution_log: Mapped[str | None] = mapped_column(Text, nullable=True, comment="执行日志")
status: Mapped[str] = mapped_column(
String(20), default="pending",
comment="pending/running/succeeded/failed/timeout/cancelled"
)
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="耗时ms")
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
celery_task_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
skill = relationship("Skill")
product = relationship("Product")
creator = relationship("User")
class ScheduleTask(Base, TimestampMixin):
__tablename__ = "t_schedule_task"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="定时任务名")
skill_id: Mapped[int] = mapped_column(Integer, ForeignKey("t_skill.id"))
product_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("t_product.id"), nullable=True)
cron_expression: Mapped[str] = mapped_column(String(100), nullable=False, comment="如 0 8 * * *")
exec_params: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="执行参数JSON")
retry_policy: Mapped[str] = mapped_column(String(20), default="retry_3", comment="retry_3/retry_1/none")
timeout: Mapped[int] = mapped_column(Integer, default=120, comment="超时秒数")
status: Mapped[str] = mapped_column(String(20), default="active", comment="active/paused")
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)
skill = relationship("Skill")
product = relationship("Product")
records = relationship("ScheduleRecord", back_populates="schedule", cascade="all, delete-orphan")
class ScheduleRecord(Base, TimestampMixin):
__tablename__ = "t_schedule_record"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
schedule_id: Mapped[int] = mapped_column(Integer, ForeignKey("t_schedule_task.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(20), comment="success/failed")
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
result_text: Mapped[str | None] = mapped_column(Text, nullable=True)
executed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
schedule = relationship("ScheduleTask", back_populates="records")
+162
View File
@@ -0,0 +1,162 @@
"""
用户 / 组织架构 / 角色 模型
- Organization: 公司→部门→小组 三级树
- Role: 部门级角色,含四维权限配置
- User: 用户,多角色绑定
- UserRole: 用户-角色关联表
- UserProductGrant: 额外产品授权 (跨组协作)
"""
from datetime import datetime
from sqlalchemy import (
Column, Integer, String, Text, Boolean, DateTime, ForeignKey,
Table, JSON, Enum as SAEnum, UniqueConstraint, Index
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin, SoftDeleteMixin
import enum
# ============ 枚举 ============
class OrgType(str, enum.Enum):
company = "company"
department = "department"
team = "team"
class UserStatus(str, enum.Enum):
active = "active"
disabled = "disabled"
# ============ 用户-角色 多对多关联表 ============
user_role_table = Table(
"t_user_role",
Base.metadata,
Column("user_id", Integer, ForeignKey("t_user.id", ondelete="CASCADE"), primary_key=True),
Column("role_id", Integer, ForeignKey("t_role.id", ondelete="CASCADE"), primary_key=True),
)
# ============ 角色-菜单 多对多关联表 ============
role_menu_table = Table(
"t_role_menu",
Base.metadata,
Column("role_id", Integer, ForeignKey("t_role.id", ondelete="CASCADE"), primary_key=True),
Column("menu_id", Integer, ForeignKey("t_menu.id", ondelete="CASCADE"), primary_key=True),
)
# ============ 角色-Skill 多对多关联表 ============
role_skill_table = Table(
"t_role_skill",
Base.metadata,
Column("role_id", Integer, ForeignKey("t_role.id", ondelete="CASCADE"), primary_key=True),
Column("skill_id", Integer, ForeignKey("t_skill.id", ondelete="CASCADE"), primary_key=True),
)
# ============ 角色-产品范围 多对多 ============
role_product_table = Table(
"t_role_product",
Base.metadata,
Column("role_id", Integer, ForeignKey("t_role.id", ondelete="CASCADE"), primary_key=True),
Column("product_id", Integer, ForeignKey("t_product.id", ondelete="CASCADE"), primary_key=True),
)
# ============ Skill-产品范围 多对多 ============
skill_product_table = Table(
"t_skill_product",
Base.metadata,
Column("skill_id", Integer, ForeignKey("t_skill.id", ondelete="CASCADE"), primary_key=True),
Column("product_id", Integer, ForeignKey("t_product.id", ondelete="CASCADE"), primary_key=True),
)
# ============ 组织架构 ============
class Organization(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "t_organization"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="组织名称")
org_type: Mapped[OrgType] = mapped_column(
SAEnum(OrgType), nullable=False, comment="类型: company/department/team"
)
parent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("t_organization.id"), nullable=True, comment="上级组织ID"
)
leader_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("t_user.id", use_alter=True), nullable=True, comment="负责人"
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, comment="排序号")
status: Mapped[str] = mapped_column(String(20), default="active", comment="active/disabled")
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
# 关系
parent = relationship("Organization", remote_side="Organization.id", backref="children")
roles = relationship("Role", back_populates="organization")
users = relationship("User", back_populates="organization")
# ============ 角色 ============
class Role(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "t_role"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(50), nullable=False, comment="角色名称")
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, comment="角色编码")
org_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("t_organization.id"), nullable=True,
comment="所属部门ID(全局角色为null)"
)
is_global: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否全局角色")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="active")
# 关系
organization = relationship("Organization", back_populates="roles")
users = relationship("User", secondary=user_role_table, back_populates="roles")
menus = relationship("Menu", secondary=role_menu_table, back_populates="roles")
skills = relationship("Skill", secondary=role_skill_table, back_populates="roles")
products = relationship("Product", secondary=role_product_table, back_populates="roles")
platform_accounts = relationship("PlatformAccount", back_populates="role")
# ============ 用户 ============
class User(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "t_user"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(128), nullable=False)
email: Mapped[str | None] = mapped_column(String(100), nullable=True)
phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
employee_no: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="工号")
org_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("t_organization.id"), nullable=True, comment="所属部门"
)
status: Mapped[UserStatus] = mapped_column(
SAEnum(UserStatus), default=UserStatus.active
)
last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# 关系
organization = relationship("Organization", back_populates="users")
roles = relationship("Role", secondary=user_role_table, back_populates="users")
product_grants = relationship("UserProductGrant", back_populates="user", cascade="all, delete-orphan")
# 平台产品账号 (v2.1: 归属用户)
platform_accounts_owned = relationship(
"PlatformAccount",
foreign_keys="PlatformAccount.owner_id",
back_populates="owner",
)
# ============ 额外产品授权 (跨组协作) ============
class UserProductGrant(Base, TimestampMixin):
__tablename__ = "t_user_product_grant"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("t_user.id", ondelete="CASCADE"))
product_id: Mapped[int] = mapped_column(Integer, ForeignKey("t_product.id", ondelete="CASCADE"))
expire_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="授权过期时间")
user = relationship("User", back_populates="product_grants")
product = relationship("Product")
+38
View File
@@ -0,0 +1,38 @@
"""
认证相关 Schema
"""
from pydantic import BaseModel
from typing import Optional
class LoginRequest(BaseModel):
username: str
password: str
captcha: Optional[str] = None
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
class RefreshRequest(BaseModel):
refresh_token: str
class UserInfo(BaseModel):
id: int
username: str
email: Optional[str] = None
phone: Optional[str] = None
employee_no: Optional[str] = None
org_id: Optional[int] = None
org_name: Optional[str] = None
status: str
roles: list[dict] = []
menus: list[dict] = []
buttons: list[str] = [] # 按钮权限码列表
data_scope: list[int] = [] # 可操作产品ID列表
skill_scope: list[int] = [] # 可使用Skill ID列表
+30
View File
@@ -0,0 +1,30 @@
"""
通用响应模型
"""
from typing import TypeVar, Generic, Optional, List, Any
from pydantic import BaseModel
T = TypeVar("T")
class ResponseModel(BaseModel, Generic[T]):
"""统一响应格式"""
code: int = 0
message: str = "success"
data: Optional[T] = None
class PageResponse(BaseModel, Generic[T]):
"""分页响应"""
code: int = 0
message: str = "success"
data: Optional[List[T]] = None
total: int = 0
page: int = 1
page_size: int = 10
class ErrorResponse(BaseModel):
code: int
message: str
detail: Optional[Any] = None
+47
View File
@@ -0,0 +1,47 @@
"""
操作日志 / 执行器配置 Schema
"""
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
# ============ 操作日志 ============
class OperationLogResponse(BaseModel):
id: int
log_id: str
user_id: Optional[int]
username: str
module: str
action: str
description: Optional[str]
request_params: Optional[dict]
before_data: Optional[dict]
after_data: Optional[dict]
ip_address: Optional[str]
created_at: datetime
class Config:
from_attributes = True
# ============ 执行器配置 ============
class ExecutorConfigUpdate(BaseModel):
config_data: Optional[dict] = None
max_concurrency: Optional[int] = None
status: Optional[str] = None
class ExecutorConfigResponse(BaseModel):
id: int
executor_type: str
name: str
config_data: Optional[dict]
max_concurrency: int
current_load: int
status: str
health_data: Optional[dict]
updated_at: datetime
class Config:
from_attributes = True
+79
View File
@@ -0,0 +1,79 @@
"""
菜单 Schema
"""
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class MenuCreate(BaseModel):
name: str
menu_type: str = "menu" # directory/menu/link
parent_id: Optional[int] = None
path: str = ""
icon: Optional[str] = None
component: Optional[str] = None
sort_order: int = 1
visible: bool = True
status: str = "active"
class MenuUpdate(MenuCreate):
pass
class MenuButtonCreate(BaseModel):
menu_id: int
name: str
permission_code: str
button_type: str = "default"
status: str = "active"
class MenuButtonUpdate(MenuButtonCreate):
pass
class MenuButtonResponse(BaseModel):
id: int
menu_id: int
name: str
permission_code: str
button_type: str
status: str
class Config:
from_attributes = True
class MenuResponse(BaseModel):
id: int
name: str
menu_type: str
parent_id: Optional[int]
path: str
icon: Optional[str]
component: Optional[str]
sort_order: int
visible: bool
status: str
buttons: List[MenuButtonResponse] = []
created_at: datetime
class Config:
from_attributes = True
class MenuTreeNode(BaseModel):
id: int
name: str
menu_type: str
parent_id: Optional[int]
path: str
icon: Optional[str]
component: Optional[str]
sort_order: int
visible: bool
status: str
buttons: List[MenuButtonResponse] = []
children: List["MenuTreeNode"] = []
+44
View File
@@ -0,0 +1,44 @@
"""
平台产品账号 Schema
"""
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class PlatformAccountCreate(BaseModel):
platform_id: int
product_id: int
login_account: str
login_password: str # 明文传入,服务端加密存储
google_key: Optional[str] = None # 明文传入
status: str = "active"
class PlatformAccountUpdate(BaseModel):
platform_id: Optional[int] = None
product_id: Optional[int] = None
login_account: Optional[str] = None
login_password: Optional[str] = None # 传空则不更新
google_key: Optional[str] = None
status: Optional[str] = None
class PlatformAccountResponse(BaseModel):
id: int
platform_id: int
platform_name: Optional[str] = None
platform_address: Optional[str] = None
product_id: int
product_code: Optional[str] = None
product_name: Optional[str] = None
owner_id: int
owner_name: Optional[str] = None
login_account: str
login_password_masked: str = "••••••••" # 掩码
google_key_masked: Optional[str] = None # 掩码后4位
status: str
created_at: datetime
class Config:
from_attributes = True
+101
View File
@@ -0,0 +1,101 @@
"""
产品 / 渠道码 / 平台 Schema
"""
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
# ============ 平台套件 ============
class PlatformCreate(BaseModel):
name: str
address: str
icon: Optional[str] = None
remark: Optional[str] = None
status: str = "active"
class PlatformUpdate(PlatformCreate):
pass
class PlatformResponse(BaseModel):
id: int
name: str
address: str
icon: Optional[str]
remark: Optional[str]
status: str
product_count: int = 0
account_count: int = 0
created_at: datetime
class Config:
from_attributes = True
# ============ 产品 ============
class ProductCreate(BaseModel):
name: str
code: str
platform_id: int
org_id: Optional[int] = None
team_id: Optional[int] = None
leader_id: Optional[int] = None
internal_channel_code: str # RB-09必填
natural_channel_code: str # RB-09必填
status: str = "active"
class ProductUpdate(ProductCreate):
pass
class ProductResponse(BaseModel):
id: int
name: str
code: str
platform_id: int
platform_name: Optional[str] = None
org_id: Optional[int]
org_name: Optional[str] = None
team_id: Optional[int]
team_name: Optional[str] = None
leader_id: Optional[int]
leader_name: Optional[str] = None
internal_channel_code: str
natural_channel_code: str
status: str
created_at: datetime
class Config:
from_attributes = True
class ProductImportRow(BaseModel):
name: str
code: str
platform: str
department: Optional[str] = None
internal_channel_code: str
natural_channel_code: str
# ============ 渠道码 ============
class ChannelCodeCreate(BaseModel):
code: str
name: str
channel_type: str # internal/natural
status: str = "active"
remark: Optional[str] = None
class ChannelCodeUpdate(ChannelCodeCreate):
pass
class ChannelCodeResponse(BaseModel):
id: int
code: str
name: str
channel_type: str
status: str
remark: Optional[str]
product_count: int = 0
created_at: datetime
class Config:
from_attributes = True
class ChannelBindRequest(BaseModel):
product_ids: List[int]
+102
View File
@@ -0,0 +1,102 @@
"""
Skill / 脚本模板 Schema
"""
from pydantic import BaseModel
from typing import Optional, List, Any
from datetime import datetime
# ============ 脚本模板 ============
class ScriptTemplateCreate(BaseModel):
name: str
script_type: str # python/nodejs/shell
description: Optional[str] = None
code: str
env_vars: Optional[dict] = None
status: str = "active"
class ScriptTemplateUpdate(ScriptTemplateCreate):
pass
class ScriptTemplateResponse(BaseModel):
id: int
name: str
script_type: str
description: Optional[str]
code: str
env_vars: Optional[dict]
status: str
skill_count: int = 0
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# ============ Skill ============
class SkillCreate(BaseModel):
name: str
category: Optional[str] = None
executor_type: str # lobster/script
description: Optional[str] = None
# 龙虾配置
lobster_api_url: Optional[str] = None
lobster_api_key: Optional[str] = None # 明文传入,服务端加密
lobster_agent_id: Optional[str] = None
# 脚本配置
script_template_id: Optional[int] = None
script_env_vars: Optional[dict] = None
script_timeout: Optional[int] = 30
# 参数
input_schema: Optional[dict] = None
param_mapping: Optional[dict] = None
# 范围
product_ids: List[int] = []
status: str = "active"
class SkillUpdate(SkillCreate):
pass
class SkillResponse(BaseModel):
id: int
name: str
category: Optional[str]
executor_type: str
description: Optional[str]
lobster_api_url: Optional[str]
lobster_agent_id: Optional[str]
script_template_id: Optional[int]
script_timeout: Optional[int]
input_schema: Optional[dict]
param_mapping: Optional[dict]
product_count: int = 0
call_count: int = 0
success_rate: float = 0.0
status: str
created_at: datetime
class Config:
from_attributes = True
class SkillTestRequest(BaseModel):
params: dict
# ============ Skill调用日志 ============
class SkillCallLogResponse(BaseModel):
id: int
call_id: str
skill_id: int
skill_name: Optional[str] = None
user_id: int
username: Optional[str] = None
product_id: Optional[int]
product_name: Optional[str] = None
executor_type: str
request_params: Optional[dict]
response_data: Optional[dict]
execution_log: Optional[str]
status: str
duration_ms: Optional[int]
created_at: datetime
class Config:
from_attributes = True
+85
View File
@@ -0,0 +1,85 @@
"""
任务 / 定时任务 Schema
"""
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
# ============ 任务 ============
class TaskCreate(BaseModel):
skill_id: int
product_id: int
input_params: Optional[dict] = None
class TaskResponse(BaseModel):
id: int
task_no: str
skill_id: int
skill_name: Optional[str] = None
product_id: int
product_name: Optional[str] = None
creator_id: int
creator_name: Optional[str] = None
executor_type: str
input_params: Optional[dict]
output_data: Optional[dict]
execution_log: Optional[str]
status: str
duration_ms: Optional[int]
error_message: Optional[str]
created_at: datetime
started_at: Optional[datetime]
completed_at: Optional[datetime]
class Config:
from_attributes = True
# ============ 定时任务 ============
class ScheduleTaskCreate(BaseModel):
name: str
skill_id: int
product_id: Optional[int] = None
cron_expression: str
exec_params: Optional[dict] = None
retry_policy: str = "retry_3" # retry_3/retry_1/none
timeout: int = 120
status: str = "active"
class ScheduleTaskUpdate(ScheduleTaskCreate):
pass
class ScheduleTaskResponse(BaseModel):
id: int
name: str
skill_id: int
skill_name: Optional[str] = None
product_id: Optional[int]
product_name: Optional[str] = None
cron_expression: str
exec_params: Optional[dict]
retry_policy: str
timeout: int
status: str
last_run_at: Optional[datetime]
next_run_at: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True
class ScheduleRecordResponse(BaseModel):
id: int
schedule_id: int
status: str
duration_ms: Optional[int]
result_text: Optional[str]
executed_at: datetime
class Config:
from_attributes = True
+131
View File
@@ -0,0 +1,131 @@
"""
用户 / 组织 / 角色 Schema
"""
from pydantic import BaseModel, EmailStr
from typing import Optional, List
from datetime import datetime
# ============ 组织架构 ============
class OrgCreate(BaseModel):
name: str
org_type: str # company/department/team
parent_id: Optional[int] = None
leader_id: Optional[int] = None
sort_order: int = 0
status: str = "active"
remark: Optional[str] = None
class OrgUpdate(OrgCreate):
pass
class OrgResponse(BaseModel):
id: int
name: str
org_type: str
parent_id: Optional[int]
leader_id: Optional[int]
sort_order: int
status: str
remark: Optional[str]
created_at: datetime
member_count: int = 0
child_count: int = 0
class Config:
from_attributes = True
class OrgTreeNode(BaseModel):
id: int
name: str
org_type: str
parent_id: Optional[int]
sort_order: int
status: str
member_count: int = 0
children: List["OrgTreeNode"] = []
# ============ 角色 ============
class RoleCreate(BaseModel):
name: str
code: str
org_id: Optional[int] = None
is_global: bool = False
description: Optional[str] = None
status: str = "active"
menu_ids: List[int] = []
product_ids: List[int] = []
skill_ids: List[int] = []
class RoleUpdate(RoleCreate):
pass
class RoleResponse(BaseModel):
id: int
name: str
code: str
org_id: Optional[int]
org_name: Optional[str] = None
is_global: bool
description: Optional[str]
status: str
menu_count: int = 0
product_count: int = 0
skill_count: int = 0
member_count: int = 0
created_at: datetime
class Config:
from_attributes = True
# ============ 用户 ============
class UserCreate(BaseModel):
username: str
password: str
email: Optional[str] = None
phone: Optional[str] = None
employee_no: Optional[str] = None
org_id: Optional[int] = None
status: str = "active"
role_ids: List[int] = []
product_grants: List[dict] = [] # [{product_id, expire_date}]
class UserUpdate(BaseModel):
email: Optional[str] = None
phone: Optional[str] = None
employee_no: Optional[str] = None
org_id: Optional[int] = None
status: Optional[str] = None
role_ids: Optional[List[int]] = None
product_grants: Optional[List[dict]] = None
class UserResponse(BaseModel):
id: int
username: str
email: Optional[str]
phone: Optional[str]
employee_no: Optional[str]
org_id: Optional[int]
org_name: Optional[str] = None
status: str
roles: List[dict] = []
platform_account_count: int = 0
grant_count: int = 0
created_at: datetime
class Config:
from_attributes = True
class ResetPasswordRequest(BaseModel):
new_password: str
confirm_password: str
+1
View File
@@ -0,0 +1 @@
# scripts package
+156
View File
@@ -0,0 +1,156 @@
"""
种子数据 — 首次启动初始化
默认账号 admin / admin123
"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.security import hash_password
from app.models.user import Organization, Role, User, user_role_table
from app.models.menu import Menu, MenuButton
from app.models.executor import ExecutorConfig
from app.models.product import Platform
async def seed_initial_data(db: AsyncSession):
"""如果没有用户则初始化"""
exists = await db.execute(select(User).limit(1))
if exists.scalar_one_or_none():
return # 已有数据,跳过
# ---- 1. 组织架构 ----
company = Organization(name="总公司", org_type="company", sort_order=1, status="active")
db.add(company)
await db.flush()
ops_dept = Organization(name="运营部", org_type="department", parent_id=company.id, sort_order=1, status="active")
tech_dept = Organization(name="技术部", org_type="department", parent_id=company.id, sort_order=2, status="active")
db.add_all([ops_dept, tech_dept])
await db.flush()
team1 = Organization(name="小红书团队", org_type="team", parent_id=ops_dept.id, sort_order=1, status="active")
team2 = Organization(name="抖音团队", org_type="team", parent_id=ops_dept.id, sort_order=2, status="active")
db.add_all([team1, team2])
await db.flush()
# ---- 2. 菜单 ----
menus_data = [
# (名称, 类型, 路径, 图标, 组件, 排序, 父级index)
("仪表盘", "menu", "/dashboard", "DashboardOutlined", "pages/Dashboard", 1, None),
("系统管理", "directory", "/system", "SettingOutlined", None, 2, None),
("产品管理", "directory", "/product", "ShopOutlined", None, 3, None),
("Skill中心", "directory", "/skill", "ThunderboltOutlined", None, 4, None),
("任务中心", "directory", "/task", "OrderedListOutlined", None, 5, None),
("运维管理", "directory", "/settings", "ToolOutlined", None, 6, None),
("日志统计", "directory", "/logs", "BarChartOutlined", None, 7, None),
]
menu_objs = []
for name, mtype, path, icon, comp, sort, _ in menus_data:
m = Menu(name=name, menu_type=mtype, path=path, icon=icon, component=comp, sort_order=sort, visible=True, status="active")
db.add(m)
menu_objs.append(m)
await db.flush()
# 子菜单
sub_menus = [
# (名称, 路径, 组件, 排序, 父级)
("组织架构", "/system/organizations", "pages/System/Orgs", 1, menu_objs[1].id),
("角色管理", "/system/roles", "pages/System/Roles", 2, menu_objs[1].id),
("用户管理", "/system/users", "pages/System/Users", 3, menu_objs[1].id),
("菜单管理", "/system/menus", "pages/System/Menus", 4, menu_objs[1].id),
("产品列表", "/product/list", "pages/Product/Products", 1, menu_objs[2].id),
("渠道码管理", "/product/channels", "pages/Product/Channels", 2, menu_objs[2].id),
("平台套件", "/product/platforms", "pages/Product/PlatformSuite", 3, menu_objs[2].id),
("平台产品账号", "/product/accounts", "pages/PlatformAccount", 4, menu_objs[2].id),
("Skill列表", "/skill/list", "pages/Skill/SkillList", 1, menu_objs[3].id),
("调用日志", "/skill/call-logs", "pages/Skill/CallLogs", 2, menu_objs[3].id),
("任务创建", "/task/create", "pages/Task/Create", 1, menu_objs[4].id),
("任务列表", "/task/list", "pages/Task/List", 2, menu_objs[4].id),
("执行器配置", "/settings/executor", "pages/Settings/Executor", 1, menu_objs[5].id),
("脚本模板", "/settings/script-templates", "pages/Settings/ScriptTemplate", 2, menu_objs[5].id),
("定时任务", "/settings/schedules", "pages/Settings/Schedule", 3, menu_objs[5].id),
("操作日志", "/logs/operation", "pages/Logs/OpLog", 1, menu_objs[6].id),
("统计报表", "/logs/stats", "pages/Logs/Stats", 2, menu_objs[6].id),
]
sub_objs = []
for name, path, comp, sort, pid in sub_menus:
m = Menu(name=name, menu_type="menu", path=path, component=comp, sort_order=sort, parent_id=pid, visible=True, status="active")
db.add(m)
sub_objs.append(m)
await db.flush()
# 按钮权限
button_data = [
# (菜单名, [(按钮名, 权限码)])
("组织架构", [("新增", "org:create"), ("编辑", "org:update"), ("删除", "org:delete")]),
("角色管理", [("新增", "role:create"), ("编辑", "role:update"), ("删除", "role:delete"), ("分配权限", "role:assign")]),
("用户管理", [("新增", "user:create"), ("编辑", "user:update"), ("删除", "user:delete"), ("重置密码", "user:reset_pwd")]),
("菜单管理", [("新增", "menu:create"), ("编辑", "menu:update"), ("删除", "menu:delete")]),
("产品列表", [("新增", "product:create"), ("编辑", "product:update"), ("删除", "product:delete"), ("导入", "product:import")]),
("Skill列表", [("新增", "skill:create"), ("编辑", "skill:update"), ("删除", "skill:delete"), ("测试", "skill:test")]),
("任务列表", [("重试", "task:retry")]),
("平台产品账号", [("新增", "account:create"), ("编辑", "account:update"), ("删除", "account:delete"), ("查看密码", "account:reveal")]),
]
menu_name_map = {m.name: m.id for m in sub_objs}
for menu_name, buttons in button_data:
mid = menu_name_map.get(menu_name)
if not mid:
continue
for btn_name, code in buttons:
db.add(MenuButton(menu_id=mid, name=btn_name, permission_code=code, button_type="default", status="active"))
await db.flush()
# ---- 3. 角色 ----
super_role = Role(name="超级管理员", code="super_admin", is_global=True, description="拥有全部权限", status="active")
ops_role = Role(name="运营主管", code="ops_manager", org_id=ops_dept.id, description="运营部管理", status="active")
ops_staff = Role(name="运营专员", code="ops_staff", org_id=ops_dept.id, description="运营执行人员", status="active")
db.add_all([super_role, ops_role, ops_staff])
await db.flush()
# 角色绑菜单(超管绑全部)
all_menus_result = await db.execute(select(Menu))
all_menus = all_menus_result.scalars().all()
super_role.menus = list(all_menus)
await db.flush()
# ---- 4. 用户 admin ----
admin = User(
username="admin",
password_hash=hash_password("admin123"),
email="admin@agent.local",
phone="13800000000",
employee_no="EMP001",
org_id=company.id,
status="active",
)
db.add(admin)
await db.flush()
admin.roles = [super_role]
await db.flush()
# ---- 5. 平台字典 ----
platforms = [
("小红书", "pro.xiaohongshu.com", "xhs"),
("抖音", "business.douyin.com", "dy"),
("微信公众号", "mp.weixin.qq.com", "wx"),
("微博", "weibo.com", "wb"),
("Facebook", "business.facebook.com", "fb"),
("TikTok", "ads.tiktok.com", "tt"),
]
for name, addr, icon in platforms:
db.add(Platform(name=name, address=addr, icon=icon, status="active"))
# ---- 6. 执行器配置 ----
db.add(ExecutorConfig(
executor_type="lobster", name="龙虾Agent执行器",
config_data={"api_url": "https://api.lobster-agent.com/v1", "default_agent": "agent_xhs_publish_01"},
max_concurrency=10, status="active",
))
db.add(ExecutorConfig(
executor_type="script", name="脚本执行器",
config_data={"sandbox": True, "max_timeout": 300, "allowed_types": ["python", "nodejs", "shell"]},
max_concurrency=5, status="active",
))
await db.commit()
print("✅ 种子数据初始化完成 (admin/admin123)")
+16
View File
@@ -0,0 +1,16 @@
fastapi==0.115.0
uvicorn[standard]==0.30.0
sqlalchemy[asyncio]==2.0.35
asyncpg==0.29.0
psycopg2-binary==2.9.9
alembic==1.13.0
pydantic==2.9.0
pydantic-settings==2.5.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.9
redis==5.1.0
celery==5.4.0
cryptography==43.0.0
httpx==0.27.0
openpyxl==3.1.5
+111
View File
@@ -0,0 +1,111 @@
version: '3.9'
services:
# ── PostgreSQL ──
db:
image: postgres:16-alpine
container_name: agent_db
restart: always
environment:
POSTGRES_DB: agent_system
POSTGRES_USER: postgres
POSTGRES_PASSWORD: agent_system_2026
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# ── Redis ──
redis:
image: redis:7-alpine
container_name: agent_redis
restart: always
ports:
- "6379:6379"
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ── Backend API ──
backend:
build: ./backend
container_name: agent_backend
restart: always
env_file: ./backend/.env
environment:
DATABASE_URL: postgresql+asyncpg://postgres:agent_system_2026@db:5432/agent_system
REDIS_URL: redis://redis:6379/0
CELERY_BROKER_URL: redis://redis:6379/1
CELERY_RESULT_BACKEND: redis://redis:6379/2
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
command: >
sh -c "
alembic upgrade head &&
python -m app.scripts.seed &&
uvicorn app.main:app --host 0.0.0.0 --port 8000
"
# ── Celery Worker ──
celery-worker:
build: ./backend
container_name: agent_celery_worker
restart: always
env_file: ./backend/.env
environment:
DATABASE_URL: postgresql+psycopg2://postgres:agent_system_2026@db:5432/agent_system
REDIS_URL: redis://redis:6379/0
CELERY_BROKER_URL: redis://redis:6379/1
CELERY_RESULT_BACKEND: redis://redis:6379/2
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
command: celery -A app.celery_app worker --loglevel=info --concurrency=4
# ── Celery Beat (定时调度) ──
celery-beat:
build: ./backend
container_name: agent_celery_beat
restart: always
env_file: ./backend/.env
environment:
DATABASE_URL: postgresql+psycopg2://postgres:agent_system_2026@db:5432/agent_system
REDIS_URL: redis://redis:6379/0
CELERY_BROKER_URL: redis://redis:6379/1
CELERY_RESULT_BACKEND: redis://redis:6379/2
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
command: celery -A app.celery_app beat --loglevel=info
# ── Frontend (Nginx + SPA) ──
frontend:
build: ./frontend
container_name: agent_frontend
restart: always
ports:
- "80:80"
depends_on:
- backend
volumes:
pgdata:
redisdata:
+13
View File
@@ -0,0 +1,13 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install --registry https://registry.npmmirror.com
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent任务系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# SPA路由
location / {
try_files $uri $uri/ /index.html;
}
# API代理
location /api/ {
proxy_pass http://backend:8000;
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 120s;
}
# 健康检查
location /health {
proxy_pass http://backend:8000;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1024;
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "agent-task-system-frontend",
"private": true,
"version": "2.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"antd": "^5.20.0",
"@ant-design/icons": "^5.4.0",
"axios": "^1.7.4",
"zustand": "^4.5.4",
"dayjs": "^1.11.12",
"@ant-design/charts": "^2.1.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.4.0"
}
}
+21
View File
@@ -0,0 +1,21 @@
import { useRoutes, Navigate } from 'react-router-dom';
import { useEffect } from 'react';
import { routes } from '@/router';
import { useAuthStore } from '@/stores/auth';
function App() {
const token = useAuthStore((s) => s.token);
const userInfo = useAuthStore((s) => s.userInfo);
const fetchUserInfo = useAuthStore((s) => s.fetchUserInfo);
useEffect(() => {
if (token && !userInfo) {
fetchUserInfo();
}
}, [token]);
const element = useRoutes(routes);
return <>{element}</>;
}
export default App;
+152
View File
@@ -0,0 +1,152 @@
/**
* 所有API接口定义
*/
import request from './request';
// ============ 认证 ============
export const authApi = {
login: (data: { username: string; password: string }) =>
request.post('/auth/login', data),
refresh: (refresh_token: string) =>
request.post('/auth/refresh', { refresh_token }),
getUserInfo: () => request.get('/auth/userinfo'),
};
// ============ 组织架构 ============
export const orgApi = {
getTree: () => request.get('/organizations/tree'),
getDetail: (id: number) => request.get(`/organizations/${id}`),
create: (data: any) => request.post('/organizations', data),
update: (id: number, data: any) => request.put(`/organizations/${id}`, data),
delete: (id: number) => request.delete(`/organizations/${id}`),
};
// ============ 角色管理 ============
export const roleApi = {
list: (params?: any) => request.get('/roles', { params }),
create: (data: any) => request.post('/roles', data),
update: (id: number, data: any) => request.put(`/roles/${id}`, data),
delete: (id: number) => request.delete(`/roles/${id}`),
};
// ============ 用户管理 ============
export const userApi = {
list: (params?: any) => request.get('/users', { params }),
create: (data: any) => request.post('/users', data),
update: (id: number, data: any) => request.put(`/users/${id}`, data),
delete: (id: number) => request.delete(`/users/${id}`),
resetPassword: (id: number, data: any) =>
request.post(`/users/${id}/reset-password`, data),
};
// ============ 菜单管理 ============
export const menuApi = {
getTree: () => request.get('/menus/tree'),
create: (data: any) => request.post('/menus', data),
update: (id: number, data: any) => request.put(`/menus/${id}`, data),
delete: (id: number) => request.delete(`/menus/${id}`),
createButton: (data: any) => request.post('/menus/buttons', data),
updateButton: (id: number, data: any) => request.put(`/menus/buttons/${id}`, data),
deleteButton: (id: number) => request.delete(`/menus/buttons/${id}`),
};
// ============ 平台套件 ============
export const platformApi = {
list: () => request.get('/platforms'),
create: (data: any) => request.post('/platforms', data),
update: (id: number, data: any) => request.put(`/platforms/${id}`, data),
delete: (id: number) => request.delete(`/platforms/${id}`),
};
// ============ 产品管理 ============
export const productApi = {
list: (params?: any) => request.get('/products', { params }),
create: (data: any) => request.post('/products', data),
update: (id: number, data: any) => request.put(`/products/${id}`, data),
delete: (id: number) => request.delete(`/products/${id}`),
import: (file: File) => {
const formData = new FormData();
formData.append('file', file);
return request.post('/products/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
};
// ============ 渠道码 ============
export const channelApi = {
list: (params?: any) => request.get('/channel-codes', { params }),
create: (data: any) => request.post('/channel-codes', data),
update: (id: number, data: any) => request.put(`/channel-codes/${id}`, data),
delete: (id: number) => request.delete(`/channel-codes/${id}`),
bind: (id: number, product_ids: number[]) =>
request.post(`/channel-codes/${id}/bind`, { product_ids }),
};
// ============ Skill管理 ============
export const skillApi = {
list: (params?: any) => request.get('/skills', { params }),
getDetail: (id: number) => request.get(`/skills/${id}`),
create: (data: any) => request.post('/skills', data),
update: (id: number, data: any) => request.put(`/skills/${id}`, data),
delete: (id: number) => request.delete(`/skills/${id}`),
};
// ============ 脚本模板 ============
export const scriptTemplateApi = {
list: (params?: any) => request.get('/script-templates', { params }),
create: (data: any) => request.post('/script-templates', data),
update: (id: number, data: any) => request.put(`/script-templates/${id}`, data),
delete: (id: number) => request.delete(`/script-templates/${id}`),
};
// ============ Skill调用日志 ============
export const skillCallLogApi = {
list: (params?: any) => request.get('/skill-call-logs', { params }),
};
// ============ 任务中心 ============
export const taskApi = {
list: (params?: any) => request.get('/tasks', { params }),
getDetail: (id: number) => request.get(`/tasks/${id}`),
create: (data: any) => request.post('/tasks', data),
retry: (id: number) => request.post(`/tasks/${id}/retry`),
};
// ============ 平台账号 ============
export const platformAccountApi = {
list: (params?: any) => request.get('/platform-accounts', { params }),
create: (data: any) => request.post('/platform-accounts', data),
update: (id: number, data: any) => request.put(`/platform-accounts/${id}`, data),
delete: (id: number) => request.delete(`/platform-accounts/${id}`),
revealPassword: (id: number) => request.get(`/platform-accounts/${id}/reveal-password`),
};
// ============ 操作日志 ============
export const operationLogApi = {
list: (params?: any) => request.get('/operation-logs', { params }),
};
// ============ 统计 ============
export const statsApi = {
dashboard: () => request.get('/stats/dashboard'),
taskTrend: (days?: number) => request.get('/stats/task-trend', { params: { days } }),
skillRanking: (limit?: number) => request.get('/stats/skill-ranking', { params: { limit } }),
};
// ============ 执行器配置 ============
export const executorApi = {
list: () => request.get('/executor-configs'),
update: (id: number, data: any) => request.put(`/executor-configs/${id}`, data),
};
// ============ 定时任务 ============
export const scheduleApi = {
list: (params?: any) => request.get('/schedule-tasks', { params }),
create: (data: any) => request.post('/schedule-tasks', data),
update: (id: number, data: any) => request.put(`/schedule-tasks/${id}`, data),
delete: (id: number) => request.delete(`/schedule-tasks/${id}`),
toggle: (id: number) => request.post(`/schedule-tasks/${id}/toggle`),
getRecords: (id: number, params?: any) =>
request.get(`/schedule-tasks/${id}/records`, { params }),
};
+72
View File
@@ -0,0 +1,72 @@
/**
* Axios 封装 — 统一拦截器 + Token自动刷新
*/
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import { message } from 'antd';
const request = axios.create({
baseURL: '/api/v1',
timeout: 30000,
});
// 请求拦截:附加Token
request.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截
request.interceptors.response.use(
(response: AxiosResponse) => {
return response.data;
},
async (error) => {
const { response } = error;
if (!response) {
message.error('网络连接异常');
return Promise.reject(error);
}
const { status, data } = response;
if (status === 401) {
// 尝试刷新Token
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken && !error.config._retry) {
error.config._retry = true;
try {
const res = await axios.post('/api/v1/auth/refresh', {
refresh_token: refreshToken,
});
const { access_token, refresh_token: newRefresh } = res.data.data;
localStorage.setItem('access_token', access_token);
localStorage.setItem('refresh_token', newRefresh);
error.config.headers.Authorization = `Bearer ${access_token}`;
return request(error.config);
} catch {
// 刷新也失败,跳转登录
localStorage.clear();
window.location.href = '/login';
}
} else {
localStorage.clear();
window.location.href = '/login';
}
} else if (status === 403) {
message.error(data?.detail || '权限不足');
} else if (status === 404) {
message.error(data?.detail || '资源不存在');
} else if (status >= 500) {
message.error('服务器错误');
} else {
message.error(data?.detail || data?.message || '请求失败');
}
return Promise.reject(error);
},
);
export default request;
@@ -0,0 +1,266 @@
/**
* 主布局 — 左侧菜单 + 顶部Header + 内容区
* 参考原型: 侧边栏深色主题, 顶部白色, 面包屑导航
*/
import React, { useState, useMemo } from 'react';
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
import { Layout, Menu, Avatar, Dropdown, Space, Breadcrumb, Button, Tag } from 'antd';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
UserOutlined,
LogoutOutlined,
DashboardOutlined,
SettingOutlined,
ShopOutlined,
ThunderboltOutlined,
OrderedListOutlined,
ToolOutlined,
BarChartOutlined,
TeamOutlined,
SafetyOutlined,
AppstoreOutlined,
FileTextOutlined,
LinkOutlined,
CloudServerOutlined,
KeyOutlined,
CodeOutlined,
ClockCircleOutlined,
AuditOutlined,
LineChartOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { useAuthStore } from '@/stores/auth';
const { Header, Sider, Content } = Layout;
// 图标映射
const iconMap: Record<string, React.ReactNode> = {
DashboardOutlined: <DashboardOutlined />,
SettingOutlined: <SettingOutlined />,
ShopOutlined: <ShopOutlined />,
ThunderboltOutlined: <ThunderboltOutlined />,
OrderedListOutlined: <OrderedListOutlined />,
ToolOutlined: <ToolOutlined />,
BarChartOutlined: <BarChartOutlined />,
TeamOutlined: <TeamOutlined />,
SafetyOutlined: <SafetyOutlined />,
AppstoreOutlined: <AppstoreOutlined />,
FileTextOutlined: <FileTextOutlined />,
LinkOutlined: <LinkOutlined />,
CloudServerOutlined: <CloudServerOutlined />,
KeyOutlined: <KeyOutlined />,
CodeOutlined: <CodeOutlined />,
ClockCircleOutlined: <ClockCircleOutlined />,
AuditOutlined: <AuditOutlined />,
LineChartOutlined: <LineChartOutlined />,
UserOutlined: <UserOutlined />,
};
// 从菜单树生成antd Menu items
function buildMenuItems(menus: any[]): MenuProps['items'] {
return menus.map((m) => {
const item: any = {
key: m.path,
icon: iconMap[m.icon] || <AppstoreOutlined />,
label: m.name,
};
if (m.children && m.children.length > 0) {
item.children = buildMenuItems(m.children);
}
return item;
});
}
// 面包屑映射
const pathNameMap: Record<string, string> = {
'/dashboard': '仪表盘',
'/system': '系统管理',
'/system/organizations': '组织架构',
'/system/roles': '角色管理',
'/system/users': '用户管理',
'/system/menus': '菜单管理',
'/product': '产品管理',
'/product/list': '产品列表',
'/product/channels': '渠道码管理',
'/product/platforms': '平台套件',
'/product/accounts': '平台产品账号',
'/skill': 'Skill中心',
'/skill/list': 'Skill列表',
'/skill/call-logs': '调用日志',
'/task': '任务中心',
'/task/create': '任务创建',
'/task/list': '任务列表',
'/settings': '运维管理',
'/settings/executor': '执行器配置',
'/settings/script-templates': '脚本模板',
'/settings/schedules': '定时任务',
'/logs': '日志统计',
'/logs/operation': '操作日志',
'/logs/stats': '统计报表',
};
const MainLayout: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { userInfo, menuTree, logout } = useAuthStore();
// 未登录跳转
if (!localStorage.getItem('access_token')) {
return <Navigate to="/login" replace />;
}
// 动态菜单
const menuItems = useMemo(() => buildMenuItems(menuTree), [menuTree]);
// 当前展开的菜单
const selectedKeys = [location.pathname];
const openKeys = useMemo(() => {
const parts = location.pathname.split('/').filter(Boolean);
if (parts.length >= 2) {
return ['/' + parts[0]];
}
return [];
}, [location.pathname]);
// 面包屑
const breadcrumbItems = useMemo(() => {
const parts = location.pathname.split('/').filter(Boolean);
const items: Array<{ title: string }> = [{ title: '首页' }];
let path = '';
for (const p of parts) {
path += '/' + p;
const name = pathNameMap[path];
if (name) items.push({ title: name });
}
return items;
}, [location.pathname]);
// 用户下拉
const userMenuItems: MenuProps['items'] = [
{
key: 'info',
label: (
<Space direction="vertical" size={0}>
<span style={{ fontWeight: 600 }}>{userInfo?.username || '-'}</span>
<span style={{ fontSize: 12, color: '#999' }}>
{userInfo?.roles.map((r) => r.name).join(', ')}
</span>
</Space>
),
disabled: true,
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: logout,
},
];
return (
<Layout style={{ minHeight: '100vh' }}>
{/* 侧边栏 */}
<Sider
trigger={null}
collapsible
collapsed={collapsed}
width={240}
style={{
overflow: 'auto',
height: '100vh',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
background: '#001529',
}}
>
{/* Logo */}
<div
style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid rgba(255,255,255,0.1)',
}}
>
<ThunderboltOutlined style={{ fontSize: 24, color: '#1677ff' }} />
{!collapsed && (
<span
style={{
color: '#fff',
fontSize: 16,
fontWeight: 700,
marginLeft: 10,
whiteSpace: 'nowrap',
}}
>
Agent任务系统
</span>
)}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={selectedKeys}
defaultOpenKeys={openKeys}
items={menuItems}
onClick={({ key }) => navigate(key)}
style={{ borderRight: 0 }}
/>
</Sider>
{/* 右侧 */}
<Layout style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.2s' }}>
{/* 顶部 */}
<Header
style={{
padding: '0 24px',
background: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
position: 'sticky',
top: 0,
zIndex: 10,
}}
>
<Space>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{ fontSize: 16 }}
/>
<Breadcrumb items={breadcrumbItems} />
</Space>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Space style={{ cursor: 'pointer' }}>
<Avatar size="small" icon={<UserOutlined />} style={{ background: '#1677ff' }} />
<span>{userInfo?.username}</span>
{userInfo?.org_name && (
<Tag color="blue" style={{ marginLeft: 4 }}>
{userInfo.org_name}
</Tag>
)}
</Space>
</Dropdown>
</Header>
{/* 内容区 */}
<Content style={{ margin: 24, minHeight: 280 }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default MainLayout;
+54
View File
@@ -0,0 +1,54 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* 滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-track {
background: transparent;
}
/* 登录页背景 */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 420px;
padding: 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.login-card h1 {
text-align: center;
font-size: 24px;
margin-bottom: 8px;
color: #1a1a1a;
}
.login-card .subtitle {
text-align: center;
color: #999;
margin-bottom: 32px;
font-size: 14px;
}
+22
View File
@@ -0,0 +1,22 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ConfigProvider locale={zhCN} theme={{
token: {
colorPrimary: '#1677ff',
borderRadius: 6,
},
}}>
<BrowserRouter>
<App />
</BrowserRouter>
</ConfigProvider>
</React.StrictMode>,
);
+254
View File
@@ -0,0 +1,254 @@
/**
* 仪表盘 — 数据概览卡片 + 任务趋势图 + Skill排行
*/
import React, { useEffect, useState } from 'react';
import { Row, Col, Card, Statistic, Spin, Table, Tag, Progress } from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
SyncOutlined,
ClockCircleOutlined,
ThunderboltOutlined,
BarChartOutlined,
RiseOutlined,
FireOutlined,
} from '@ant-design/icons';
import { statsApi } from '@/api';
import type { DashboardData } from '@/types';
const Dashboard: React.FC = () => {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<DashboardData | null>(null);
const [trend, setTrend] = useState<any[]>([]);
const [ranking, setRanking] = useState<any[]>([]);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const [dashRes, trendRes, rankRes]: any = await Promise.all([
statsApi.dashboard(),
statsApi.taskTrend(7),
statsApi.skillRanking(10),
]);
setData(dashRes.data);
setTrend(trendRes.data || []);
setRanking(rankRes.data || []);
} catch {
// ignore
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
const cardStyle: React.CSSProperties = {
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
};
return (
<div>
{/* 统计卡片 */}
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={6}>
<Card style={{ ...cardStyle, borderLeft: '4px solid #1677ff' }}>
<Statistic
title="今日任务"
value={data?.today_tasks || 0}
prefix={<RiseOutlined />}
valueStyle={{ color: '#1677ff' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card style={{ ...cardStyle, borderLeft: '4px solid #52c41a' }}>
<Statistic
title="任务成功率"
value={data?.task_success_rate || 0}
suffix="%"
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card style={{ ...cardStyle, borderLeft: '4px solid #722ed1' }}>
<Statistic
title="Skill总数"
value={data?.skill_total || 0}
prefix={<ThunderboltOutlined />}
valueStyle={{ color: '#722ed1' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card style={{ ...cardStyle, borderLeft: '4px solid #fa8c16' }}>
<Statistic
title="总调用次数"
value={data?.call_total || 0}
prefix={<BarChartOutlined />}
valueStyle={{ color: '#fa8c16' }}
/>
</Card>
</Col>
</Row>
{/* 任务状态分布 */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} lg={12}>
<Card title="任务状态分布" style={cardStyle}>
<Row gutter={16}>
<Col span={6}>
<div style={{ textAlign: 'center' }}>
<Progress
type="circle"
percent={data?.task_total ? Math.round((data.task_success / data.task_total) * 100) : 0}
size={80}
strokeColor="#52c41a"
/>
<div style={{ marginTop: 8 }}>
<Tag color="success" icon={<CheckCircleOutlined />}>
成功 {data?.task_success || 0}
</Tag>
</div>
</div>
</Col>
<Col span={6}>
<div style={{ textAlign: 'center' }}>
<Progress
type="circle"
percent={data?.task_total ? Math.round((data.task_failed / data.task_total) * 100) : 0}
size={80}
strokeColor="#ff4d4f"
/>
<div style={{ marginTop: 8 }}>
<Tag color="error" icon={<CloseCircleOutlined />}>
失败 {data?.task_failed || 0}
</Tag>
</div>
</div>
</Col>
<Col span={6}>
<div style={{ textAlign: 'center' }}>
<Progress
type="circle"
percent={data?.task_total ? Math.round((data.task_running / data.task_total) * 100) : 0}
size={80}
strokeColor="#1677ff"
/>
<div style={{ marginTop: 8 }}>
<Tag color="processing" icon={<SyncOutlined spin />}>
运行 {data?.task_running || 0}
</Tag>
</div>
</div>
</Col>
<Col span={6}>
<div style={{ textAlign: 'center' }}>
<Progress
type="circle"
percent={data?.task_total ? Math.round((data.task_pending / data.task_total) * 100) : 0}
size={80}
strokeColor="#faad14"
/>
<div style={{ marginTop: 8 }}>
<Tag color="warning" icon={<ClockCircleOutlined />}>
等待 {data?.task_pending || 0}
</Tag>
</div>
</div>
</Col>
</Row>
<div style={{ textAlign: 'center', marginTop: 16, color: '#999' }}>
总任务数: {data?.task_total || 0}
</div>
</Card>
</Col>
{/* 近7天任务趋势 */}
<Col xs={24} lg={12}>
<Card title="近7天任务趋势" style={cardStyle}>
{trend.length === 0 ? (
<div style={{ textAlign: 'center', color: '#999', padding: 40 }}>暂无数据</div>
) : (
<Table
dataSource={trend}
columns={[
{ title: '日期', dataIndex: 'date', key: 'date', width: 120 },
{ title: '总数', dataIndex: 'total', key: 'total', width: 80, align: 'center' },
{
title: '成功', dataIndex: 'success', key: 'success', width: 80, align: 'center',
render: (v: number) => <span style={{ color: '#52c41a' }}>{v}</span>,
},
{
title: '失败', dataIndex: 'failed', key: 'failed', width: 80, align: 'center',
render: (v: number) => <span style={{ color: '#ff4d4f' }}>{v}</span>,
},
{
title: '成功率', key: 'rate', width: 100, align: 'center',
render: (_: any, r: any) => {
const rate = r.total > 0 ? Math.round((r.success / r.total) * 100) : 0;
return <Progress percent={rate} size="small" />;
},
},
]}
rowKey="date"
pagination={false}
size="small"
/>
)}
</Card>
</Col>
</Row>
{/* Skill调用排行 */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={24}>
<Card title={<><FireOutlined style={{ color: '#ff4d4f' }} /> Skill调用排行 TOP 10</>} style={cardStyle}>
{ranking.length === 0 ? (
<div style={{ textAlign: 'center', color: '#999', padding: 40 }}>暂无数据</div>
) : (
<Table
dataSource={ranking}
columns={[
{
title: '排名', key: 'rank', width: 60, align: 'center',
render: (_: any, __: any, i: number) => {
const colors = ['#ff4d4f', '#fa8c16', '#faad14'];
return (
<span style={{ fontWeight: 700, color: colors[i] || '#999' }}>
{i + 1}
</span>
);
},
},
{ title: 'Skill名称', dataIndex: 'skill_name', key: 'skill_name' },
{
title: '调用次数', dataIndex: 'call_count', key: 'call_count', width: 120, align: 'center',
render: (v: number) => <Tag color="blue">{v} 次</Tag>,
},
]}
rowKey="skill_id"
pagination={false}
size="small"
/>
)}
</Card>
</Col>
</Row>
</div>
);
};
export default Dashboard;
+67
View File
@@ -0,0 +1,67 @@
/**
* 登录页 — 参考原型渐变背景 + 居中卡片
*/
import React, { useState } from 'react';
import { Form, Input, Button, message } from 'antd';
import { UserOutlined, LockOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth';
const Login: React.FC = () => {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
const onFinish = async (values: { username: string; password: string }) => {
setLoading(true);
try {
await login(values.username, values.password);
message.success('登录成功');
navigate('/dashboard', { replace: true });
} catch (err: any) {
message.error(err?.response?.data?.detail || '登录失败');
} finally {
setLoading(false);
}
};
return (
<div className="login-container">
<div className="login-card">
<div style={{ textAlign: 'center', marginBottom: 8 }}>
<ThunderboltOutlined style={{ fontSize: 40, color: '#667eea' }} />
</div>
<h1>Agent任务系统</h1>
<p className="subtitle">v2.1 — 智能任务管理平台</p>
<Form name="login" onFinish={onFinish} size="large" autoComplete="off">
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
登 录
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', color: '#999', fontSize: 12 }}>
默认账号: admin / admin123
</div>
</div>
</div>
);
};
export default Login;
+123
View File
@@ -0,0 +1,123 @@
/**
* 操作日志 — 模块/动作筛选 + 变更对比
*/
import React, { useEffect, useState } from 'react';
import { Card, Table, Tag, Select, Space, Input, Button, Modal, Descriptions, Typography, Row, Col } from 'antd';
import { AuditOutlined, ReloadOutlined } from '@ant-design/icons';
import { operationLogApi } from '@/api';
import type { OperationLogItem } from '@/types';
const moduleOptions = [
'登录', '组织架构', '角色管理', '用户管理', '菜单管理',
'产品管理', '渠道码', '平台套件', '平台账号',
'Skill管理', '脚本模板', '任务中心', '定时任务', '执行器配置',
];
const OpLog: React.FC = () => {
const [data, setData] = useState<OperationLogItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [filterModule, setFilterModule] = useState<string | undefined>();
const [loading, setLoading] = useState(false);
const [detailModal, setDetailModal] = useState(false);
const [detail, setDetail] = useState<OperationLogItem | null>(null);
useEffect(() => { loadList(); }, [page, filterModule]);
const loadList = async () => {
setLoading(true);
try {
const res: any = await operationLogApi.list({ page, page_size: 20, module: filterModule });
setData(res.data || []);
setTotal(res.total || 0);
} finally { setLoading(false); }
};
const actionColors: Record<string, string> = {
login: 'blue', create: 'green', update: 'orange', delete: 'red', import: 'purple',
};
const columns = [
{ title: '日志ID', dataIndex: 'log_id', width: 140, render: (v: string) => <Tag>{v}</Tag> },
{ title: '操作人', dataIndex: 'username', width: 90 },
{ title: '模块', dataIndex: 'module', width: 100 },
{
title: '动作', dataIndex: 'action', width: 80,
render: (v: string) => <Tag color={actionColors[v] || 'default'}>{v}</Tag>,
},
{ title: '描述', dataIndex: 'description', width: 260, ellipsis: true },
{ title: 'IP', dataIndex: 'ip_address', width: 130 },
{ title: '时间', dataIndex: 'created_at', width: 160, render: (v: string) => v?.slice(0, 19).replace('T', ' ') },
{
title: '操作', key: 'action_btn', width: 80,
render: (_: any, r: OperationLogItem) => (
<Button size="small" type="link" onClick={() => { setDetail(r); setDetailModal(true); }}>详情</Button>
),
},
];
return (
<Card
title={<><AuditOutlined /> 操作日志</>}
extra={
<Space>
<Select allowClear placeholder="模块" onChange={(v) => { setFilterModule(v); setPage(1); }} style={{ width: 130 }}
options={moduleOptions.map((m) => ({ value: m, label: m }))} />
<Button icon={<ReloadOutlined />} onClick={loadList} />
</Space>
}
>
<Table dataSource={data} columns={columns} rowKey="id" loading={loading}
pagination={{ current: page, total, pageSize: 20, onChange: setPage, showTotal: (t) => `共 ${t} 条` }}
size="small" scroll={{ x: 1100 }} />
<Modal title="日志详情" open={detailModal} onCancel={() => setDetailModal(false)} footer={null} width={640}>
{detail && (
<>
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="日志ID">{detail.log_id}</Descriptions.Item>
<Descriptions.Item label="操作人">{detail.username}</Descriptions.Item>
<Descriptions.Item label="模块">{detail.module}</Descriptions.Item>
<Descriptions.Item label="动作"><Tag color={actionColors[detail.action]}>{detail.action}</Tag></Descriptions.Item>
<Descriptions.Item label="描述" span={2}>{detail.description}</Descriptions.Item>
<Descriptions.Item label="IP">{detail.ip_address || '-'}</Descriptions.Item>
<Descriptions.Item label="时间">{detail.created_at?.slice(0, 19).replace('T', ' ')}</Descriptions.Item>
</Descriptions>
{detail.request_params && (
<>
<h4 style={{ marginTop: 16 }}>请求参数</h4>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, maxHeight: 200, overflow: 'auto', fontSize: 12 }}>
{JSON.stringify(detail.request_params, null, 2)}
</pre>
</>
)}
{(detail.before_data || detail.after_data) && (
<Row gutter={16} style={{ marginTop: 16 }}>
{detail.before_data && (
<Col span={12}>
<h4 style={{ color: '#ff4d4f' }}>变更前</h4>
<pre style={{ background: '#fff2f0', padding: 12, borderRadius: 4, maxHeight: 200, overflow: 'auto', fontSize: 12 }}>
{JSON.stringify(detail.before_data, null, 2)}
</pre>
</Col>
)}
{detail.after_data && (
<Col span={12}>
<h4 style={{ color: '#52c41a' }}>变更后</h4>
<pre style={{ background: '#f6ffed', padding: 12, borderRadius: 4, maxHeight: 200, overflow: 'auto', fontSize: 12 }}>
{JSON.stringify(detail.after_data, null, 2)}
</pre>
</Col>
)}
</Row>
)}
</>
)}
</Modal>
</Card>
);
};
export default OpLog;
+132
View File
@@ -0,0 +1,132 @@
/**
* 统计报表 — 仪表盘数据 + 任务趋势 + Skill排行
*/
import React, { useEffect, useState } from 'react';
import { Card, Row, Col, Statistic, Table, Tag, Select, Space, Button, Progress } from 'antd';
import {
BarChartOutlined, CheckCircleOutlined, CloseCircleOutlined, ThunderboltOutlined,
RiseOutlined, ReloadOutlined, FireOutlined, TrophyOutlined,
} from '@ant-design/icons';
import { statsApi } from '@/api';
import type { DashboardData } from '@/types';
const Stats: React.FC = () => {
const [data, setData] = useState<DashboardData | null>(null);
const [trend, setTrend] = useState<any[]>([]);
const [ranking, setRanking] = useState<any[]>([]);
const [days, setDays] = useState(7);
const [loading, setLoading] = useState(false);
useEffect(() => { loadAll(); }, [days]);
const loadAll = async () => {
setLoading(true);
try {
const [dRes, tRes, rRes]: any = await Promise.all([
statsApi.dashboard(),
statsApi.taskTrend(days),
statsApi.skillRanking(10),
]);
setData(dRes.data);
setTrend(tRes.data || []);
setRanking(rRes.data || []);
} finally { setLoading(false); }
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<h3 style={{ margin: 0 }}><BarChartOutlined /> 统计报表</h3>
<Space>
<Select value={days} onChange={setDays} style={{ width: 120 }}
options={[{ value: 7, label: '近7天' }, { value: 14, label: '近14天' }, { value: 30, label: '近30天' }, { value: 90, label: '近90天' }]} />
<Button icon={<ReloadOutlined />} onClick={loadAll}>刷新</Button>
</Space>
</div>
{/* 核心指标 */}
<Row gutter={[16, 16]}>
<Col xs={12} sm={8} lg={4}>
<Card size="small"><Statistic title="任务总数" value={data?.task_total || 0} prefix={<BarChartOutlined />} /></Card>
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small"><Statistic title="成功" value={data?.task_success || 0} valueStyle={{ color: '#52c41a' }} prefix={<CheckCircleOutlined />} /></Card>
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small"><Statistic title="失败" value={data?.task_failed || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<CloseCircleOutlined />} /></Card>
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small"><Statistic title="成功率" value={data?.task_success_rate || 0} suffix="%" valueStyle={{ color: '#1677ff' }} /></Card>
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small"><Statistic title="Skill总数" value={data?.skill_total || 0} prefix={<ThunderboltOutlined />} /></Card>
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small"><Statistic title="今日任务" value={data?.today_tasks || 0} valueStyle={{ color: '#722ed1' }} prefix={<RiseOutlined />} /></Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
{/* 任务趋势 */}
<Col xs={24} lg={14}>
<Card title={`任务趋势(近${days}天)`} size="small">
<Table
dataSource={trend}
rowKey="date"
size="small"
pagination={false}
scroll={{ y: 400 }}
columns={[
{ title: '日期', dataIndex: 'date', width: 110 },
{ title: '总数', dataIndex: 'total', width: 70, align: 'center' },
{
title: '成功', dataIndex: 'success', width: 70, align: 'center',
render: (v: number) => <span style={{ color: '#52c41a', fontWeight: 600 }}>{v}</span>,
},
{
title: '失败', dataIndex: 'failed', width: 70, align: 'center',
render: (v: number) => <span style={{ color: v > 0 ? '#ff4d4f' : '#999', fontWeight: v > 0 ? 600 : 400 }}>{v}</span>,
},
{
title: '成功率', key: 'rate', width: 120,
render: (_: any, r: any) => {
const rate = r.total > 0 ? Math.round((r.success / r.total) * 100) : 0;
return <Progress percent={rate} size="small" status={rate >= 80 ? 'success' : rate >= 50 ? 'normal' : 'exception'} />;
},
},
]}
/>
</Card>
</Col>
{/* Skill排行 */}
<Col xs={24} lg={10}>
<Card title={<><TrophyOutlined style={{ color: '#faad14' }} /> Skill调用排行 TOP 10</>} size="small">
<Table
dataSource={ranking}
rowKey="skill_id"
size="small"
pagination={false}
columns={[
{
title: '#', key: 'rank', width: 40, align: 'center',
render: (_: any, __: any, i: number) => {
const medals = ['🥇', '🥈', '🥉'];
return medals[i] || <span style={{ color: '#999' }}>{i + 1}</span>;
},
},
{ title: 'Skill', dataIndex: 'skill_name' },
{
title: '调用次数', dataIndex: 'call_count', width: 100, align: 'center',
render: (v: number) => <Tag color="blue">{v}</Tag>,
},
]}
/>
</Card>
</Col>
</Row>
</div>
);
};
export default Stats;
@@ -0,0 +1,179 @@
/**
* 平台产品账号 — 密码加密 + 掩码查看 + 查看真实密码
*/
import React, { useEffect, useState } from 'react';
import { Card, Table, Button, Space, Tag, Modal, Form, Input, Select, message, Popconfirm, Typography, Row, Col } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, EyeInvisibleOutlined, ReloadOutlined, KeyOutlined } from '@ant-design/icons';
import { platformAccountApi, platformApi, productApi } from '@/api';
import type { PlatformAccountItem } from '@/types';
import { useAuthStore } from '@/stores/auth';
const PlatformAccount: React.FC = () => {
const [data, setData] = useState<PlatformAccountItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [filterPlatform, setFilterPlatform] = useState<number | undefined>();
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form] = Form.useForm();
const [revealData, setRevealData] = useState<{ id: number; password: string; google_key?: string } | null>(null);
const hasButton = useAuthStore((s) => s.hasButton);
const [platforms, setPlatforms] = useState<any[]>([]);
const [products, setProducts] = useState<any[]>([]);
useEffect(() => { loadList(); }, [page, search, filterPlatform]);
useEffect(() => { loadOptions(); }, []);
const loadList = async () => {
setLoading(true);
try {
const res: any = await platformAccountApi.list({ page, page_size: 10, search, platform_id: filterPlatform });
setData(res.data || []);
setTotal(res.total || 0);
} finally { setLoading(false); }
};
const loadOptions = async () => {
const [pRes, prRes]: any = await Promise.all([platformApi.list(), productApi.list({ page_size: 500 })]);
setPlatforms(pRes.data || []);
setProducts(prRes.data || []);
};
const handleAdd = () => { setEditingId(null); form.resetFields(); setModalOpen(true); };
const handleEdit = (r: PlatformAccountItem) => {
setEditingId(r.id);
form.setFieldsValue({ platform_id: r.platform_id, product_id: r.product_id, login_account: r.login_account, status: r.status });
setModalOpen(true);
};
const handleSave = async () => {
const values = await form.validateFields();
if (editingId) { await platformAccountApi.update(editingId, values); }
else { await platformAccountApi.create(values); }
message.success('保存成功');
setModalOpen(false);
loadList();
};
const handleDelete = async (id: number) => {
await platformAccountApi.delete(id);
message.success('已删除');
loadList();
};
const handleReveal = async (id: number) => {
try {
const res: any = await platformAccountApi.revealPassword(id);
setRevealData({ id, password: res.data.login_password, google_key: res.data.google_key });
} catch { /* handled */ }
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '平台', dataIndex: 'platform_name', width: 100 },
{ title: '产品', dataIndex: 'product_name', width: 120, render: (v: string, r: PlatformAccountItem) => <>{v} <Tag>{r.product_code}</Tag></> },
{ title: '归属人', dataIndex: 'owner_name', width: 90 },
{ title: '登录账号', dataIndex: 'login_account', width: 160 },
{
title: '密码', key: 'password', width: 140,
render: (_: any, r: PlatformAccountItem) => {
if (revealData?.id === r.id) {
return (
<Space>
<Typography.Text copyable={{ text: revealData.password }}>{revealData.password}</Typography.Text>
<Button size="small" type="text" icon={<EyeInvisibleOutlined />} onClick={() => setRevealData(null)} />
</Space>
);
}
return (
<Space>
<span>{r.login_password_masked}</span>
{hasButton('account:reveal') && (
<Button size="small" type="text" icon={<EyeOutlined />} onClick={() => handleReveal(r.id)} />
)}
</Space>
);
},
},
{
title: '谷歌密钥', key: 'google_key', width: 120,
render: (_: any, r: PlatformAccountItem) => {
if (revealData?.id === r.id && revealData.google_key) {
return <Typography.Text copyable={{ text: revealData.google_key }}>{revealData.google_key}</Typography.Text>;
}
return r.google_key_masked || '-';
},
},
{
title: '状态', dataIndex: 'status', width: 80,
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'red'}>{v === 'active' ? '启用' : '禁用'}</Tag>,
},
{ title: '创建时间', dataIndex: 'created_at', width: 160, render: (v: string) => v?.slice(0, 19).replace('T', ' ') },
{
title: '操作', key: 'action', width: 160, fixed: 'right' as const,
render: (_: any, r: PlatformAccountItem) => (
<Space>
{hasButton('account:update') && <Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)}>编辑</Button>}
{hasButton('account:delete') && (
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
];
return (
<Card
title={<><KeyOutlined /> 平台产品账号</>}
extra={
<Space>
<Input.Search placeholder="搜索账号" allowClear onSearch={(v) => { setSearch(v); setPage(1); }} style={{ width: 180 }} />
<Select allowClear placeholder="平台" onChange={(v) => { setFilterPlatform(v); setPage(1); }} style={{ width: 120 }}
options={platforms.map((p: any) => ({ value: p.id, label: p.name }))} />
<Button icon={<ReloadOutlined />} onClick={loadList} />
{hasButton('account:create') && <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增账号</Button>}
</Space>
}
>
<Table dataSource={data} columns={columns} rowKey="id" loading={loading}
pagination={{ current: page, total, pageSize: 10, onChange: setPage, showTotal: (t) => `共 ${t} 条` }}
size="small" scroll={{ x: 1400 }} />
<Modal title={editingId ? '编辑账号' : '新增账号'} open={modalOpen} onCancel={() => setModalOpen(false)} onOk={handleSave} width={560} destroyOnClose>
<Form form={form} layout="vertical" preserve={false}>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="platform_id" label="平台" rules={[{ required: true }]}>
<Select options={platforms.map((p: any) => ({ value: p.id, label: p.name }))} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="product_id" label="产品" rules={[{ required: true }]}>
<Select showSearch optionFilterProp="label"
options={products.map((p: any) => ({ value: p.id, label: `${p.name} (${p.code})` }))} />
</Form.Item>
</Col>
</Row>
<Form.Item name="login_account" label="登录账号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="login_password" label="登录密码" rules={editingId ? [] : [{ required: true }]}
extra={editingId ? '留空则不修改' : '密码将AES加密存储'}>
<Input.Password />
</Form.Item>
<Form.Item name="google_key" label="谷歌验证密钥" extra="选填,将加密存储">
<Input.Password />
</Form.Item>
<Form.Item name="status" label="状态" initialValue="active">
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
</Form.Item>
</Form>
</Modal>
</Card>
);
};
export default PlatformAccount;
@@ -0,0 +1,141 @@
/**
* 渠道码管理 — 列表 + 绑定产品
*/
import React, { useEffect, useState } from 'react';
import { Card, Table, Button, Space, Tag, Modal, Form, Input, Select, message, Popconfirm, Row, Col } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, LinkOutlined, ReloadOutlined } from '@ant-design/icons';
import { channelApi, productApi } from '@/api';
import type { ChannelCodeItem } from '@/types';
import { useAuthStore } from '@/stores/auth';
const Channels: React.FC = () => {
const [data, setData] = useState<ChannelCodeItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [filterType, setFilterType] = useState<string | undefined>();
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [bindModal, setBindModal] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [bindingId, setBindingId] = useState<number | null>(null);
const [form] = Form.useForm();
const [bindProductIds, setBindProductIds] = useState<number[]>([]);
const [allProducts, setAllProducts] = useState<any[]>([]);
const hasButton = useAuthStore((s) => s.hasButton);
useEffect(() => { loadList(); }, [page, filterType, search]);
useEffect(() => { loadProducts(); }, []);
const loadList = async () => {
setLoading(true);
try {
const res: any = await channelApi.list({ page, page_size: 20, channel_type: filterType, search });
setData(res.data || []);
setTotal(res.total || 0);
} finally { setLoading(false); }
};
const loadProducts = async () => {
const res: any = await productApi.list({ page_size: 500 });
setAllProducts(res.data || []);
};
const handleAdd = () => { setEditingId(null); form.resetFields(); setModalOpen(true); };
const handleEdit = (r: ChannelCodeItem) => { setEditingId(r.id); form.setFieldsValue(r); setModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields();
if (editingId) { await channelApi.update(editingId, values); }
else { await channelApi.create(values); }
message.success('保存成功');
setModalOpen(false);
loadList();
};
const handleDelete = async (id: number) => {
await channelApi.delete(id);
message.success('已删除');
loadList();
};
const openBind = (id: number) => { setBindingId(id); setBindProductIds([]); setBindModal(true); };
const handleBind = async () => {
if (!bindingId) return;
await channelApi.bind(bindingId, bindProductIds);
message.success('绑定成功');
setBindModal(false);
loadList();
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '渠道码', dataIndex: 'code', width: 120, render: (v: string) => <Tag color="purple">{v}</Tag> },
{ title: '名称', dataIndex: 'name', width: 140 },
{
title: '类型', dataIndex: 'channel_type', width: 100,
render: (v: string) => <Tag color={v === 'internal' ? 'orange' : 'cyan'}>{v === 'internal' ? '内导' : '自然'}</Tag>,
},
{ title: '关联产品数', dataIndex: 'product_count', width: 100, align: 'center' as const },
{
title: '状态', dataIndex: 'status', width: 80,
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'red'}>{v === 'active' ? '启用' : '禁用'}</Tag>,
},
{ title: '备注', dataIndex: 'remark', width: 160, ellipsis: true },
{ title: '创建时间', dataIndex: 'created_at', width: 160, render: (v: string) => v?.slice(0, 19).replace('T', ' ') },
{
title: '操作', key: 'action', width: 220, fixed: 'right' as const,
render: (_: any, r: ChannelCodeItem) => (
<Space>
<Button size="small" icon={<LinkOutlined />} onClick={() => openBind(r.id)}>绑定产品</Button>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)}>编辑</Button>
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<Card
title={<><LinkOutlined /> 渠道码管理</>}
extra={
<Space>
<Input.Search placeholder="搜索" allowClear onSearch={(v) => { setSearch(v); setPage(1); }} style={{ width: 160 }} />
<Select allowClear placeholder="类型" onChange={(v) => { setFilterType(v); setPage(1); }} style={{ width: 100 }}
options={[{ value: 'internal', label: '内导' }, { value: 'natural', label: '自然' }]} />
<Button icon={<ReloadOutlined />} onClick={loadList} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增渠道码</Button>
</Space>
}
>
<Table dataSource={data} columns={columns} rowKey="id" loading={loading}
pagination={{ current: page, total, pageSize: 20, onChange: setPage, showTotal: (t) => `共 ${t} 条` }}
size="small" scroll={{ x: 1200 }} />
{/* 新增/编辑 */}
<Modal title={editingId ? '编辑渠道码' : '新增渠道码'} open={modalOpen} onCancel={() => setModalOpen(false)} onOk={handleSave} destroyOnClose>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item name="code" label="渠道码" rules={[{ required: true }]}><Input disabled={!!editingId} placeholder="如 IC001" /></Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="channel_type" label="类型" rules={[{ required: true }]}>
<Select options={[{ value: 'internal', label: '内导' }, { value: 'natural', label: '自然' }]} />
</Form.Item>
<Form.Item name="remark" label="备注"><Input.TextArea rows={2} /></Form.Item>
<Form.Item name="status" label="状态" initialValue="active">
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
</Form.Item>
</Form>
</Modal>
{/* 绑定产品 */}
<Modal title="绑定产品" open={bindModal} onCancel={() => setBindModal(false)} onOk={handleBind} destroyOnClose>
<Select mode="multiple" style={{ width: '100%' }} placeholder="选择产品" value={bindProductIds} onChange={setBindProductIds}
options={allProducts.map((p: any) => ({ value: p.id, label: `${p.name} (${p.code})` }))} maxTagCount={5} />
</Modal>
</Card>
);
};
export default Channels;
@@ -0,0 +1,118 @@
/**
* 平台套件管理 — 卡片式展示 + CRUD
*/
import React, { useEffect, useState } from 'react';
import { Card, Row, Col, Button, Space, Tag, Modal, Form, Input, Select, message, Popconfirm, Empty, Statistic } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, ReloadOutlined, LinkOutlined } from '@ant-design/icons';
import { platformApi } from '@/api';
import type { PlatformItem } from '@/types';
const PlatformSuite: React.FC = () => {
const [data, setData] = useState<PlatformItem[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form] = Form.useForm();
useEffect(() => { loadList(); }, []);
const loadList = async () => {
setLoading(true);
try {
const res: any = await platformApi.list();
setData(res.data || []);
} finally { setLoading(false); }
};
const handleAdd = () => { setEditingId(null); form.resetFields(); setModalOpen(true); };
const handleEdit = (r: PlatformItem) => { setEditingId(r.id); form.setFieldsValue(r); setModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields();
if (editingId) { await platformApi.update(editingId, values); }
else { await platformApi.create(values); }
message.success('保存成功');
setModalOpen(false);
loadList();
};
const handleDelete = async (id: number) => {
await platformApi.delete(id);
message.success('已删除');
loadList();
};
// 平台图标色
const colorMap: Record<string, string> = {
xhs: '#ff2442', dy: '#000', wx: '#07c160', wb: '#ff8200', fb: '#1877f2', tt: '#010101',
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<h3 style={{ margin: 0 }}><GlobalOutlined /> 平台套件</h3>
<Space>
<Button icon={<ReloadOutlined />} onClick={loadList} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增平台</Button>
</Space>
</div>
{data.length === 0 && !loading ? (
<Empty description="暂无平台" />
) : (
<Row gutter={[16, 16]}>
{data.map((p) => (
<Col xs={24} sm={12} lg={8} xl={6} key={p.id}>
<Card
hoverable
style={{ borderRadius: 8, borderTop: `3px solid ${colorMap[p.icon || ''] || '#1677ff'}` }}
actions={[
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(p)}>编辑</Button>,
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(p.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>,
<Button type="link" size="small" icon={<LinkOutlined />} onClick={() => window.open(`https://${p.address}`, '_blank')}>
访问
</Button>,
]}
>
<Card.Meta
title={
<Space>
<span style={{ fontSize: 18 }}>{p.name}</span>
<Tag color={p.status === 'active' ? 'green' : 'red'}>{p.status === 'active' ? '启用' : '禁用'}</Tag>
</Space>
}
description={p.address}
/>
<Row gutter={16} style={{ marginTop: 16 }}>
<Col span={12}>
<Statistic title="产品数" value={p.product_count} valueStyle={{ fontSize: 20 }} />
</Col>
<Col span={12}>
<Statistic title="账号数" value={p.account_count} valueStyle={{ fontSize: 20 }} />
</Col>
</Row>
{p.remark && <div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>{p.remark}</div>}
</Card>
</Col>
))}
</Row>
)}
<Modal title={editingId ? '编辑平台' : '新增平台'} open={modalOpen} onCancel={() => setModalOpen(false)} onOk={handleSave} destroyOnClose>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item name="name" label="平台名称" rules={[{ required: true }]}><Input placeholder="如 小红书" /></Form.Item>
<Form.Item name="address" label="平台地址" rules={[{ required: true }]}><Input placeholder="如 pro.xiaohongshu.com" /></Form.Item>
<Form.Item name="icon" label="图标标识"><Input placeholder="如 xhs, dy, wx" /></Form.Item>
<Form.Item name="remark" label="备注"><Input.TextArea rows={2} /></Form.Item>
<Form.Item name="status" label="状态" initialValue="active">
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default PlatformSuite;
@@ -0,0 +1,169 @@
/**
* 产品列表 — CRUD + CSV导入 + 渠道码必填(RB-09)
*/
import React, { useEffect, useState } from 'react';
import { Card, Table, Button, Space, Tag, Modal, Form, Input, Select, message, Popconfirm, Upload, Row, Col } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, UploadOutlined, ReloadOutlined, ShopOutlined } from '@ant-design/icons';
import { productApi, platformApi, orgApi } from '@/api';
import type { ProductItem } from '@/types';
import { useAuthStore } from '@/stores/auth';
const Products: React.FC = () => {
const [data, setData] = useState<ProductItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [filterPlatform, setFilterPlatform] = useState<number | undefined>();
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form] = Form.useForm();
const hasButton = useAuthStore((s) => s.hasButton);
const [platforms, setPlatforms] = useState<any[]>([]);
const [orgs, setOrgs] = useState<any[]>([]);
useEffect(() => { loadList(); }, [page, search, filterPlatform]);
useEffect(() => { loadOptions(); }, []);
const loadList = async () => {
setLoading(true);
try {
const res: any = await productApi.list({ page, page_size: 10, search, platform_id: filterPlatform });
setData(res.data || []);
setTotal(res.total || 0);
} finally { setLoading(false); }
};
const loadOptions = async () => {
const [pRes, oRes]: any = await Promise.all([platformApi.list(), orgApi.getTree()]);
setPlatforms(pRes.data || []);
const flat: any[] = [];
const flatten = (nodes: any[]) => { nodes.forEach((n: any) => { flat.push({ value: n.id, label: n.name }); if (n.children) flatten(n.children); }); };
flatten(oRes.data || []);
setOrgs(flat);
};
const handleAdd = () => { setEditingId(null); form.resetFields(); setModalOpen(true); };
const handleEdit = (r: ProductItem) => {
setEditingId(r.id);
form.setFieldsValue(r);
setModalOpen(true);
};
const handleSave = async () => {
const values = await form.validateFields();
if (editingId) { await productApi.update(editingId, values); }
else { await productApi.create(values); }
message.success('保存成功');
setModalOpen(false);
loadList();
};
const handleDelete = async (id: number) => {
await productApi.delete(id);
message.success('已删除');
loadList();
};
const handleImport = async (file: File) => {
try {
const res: any = await productApi.import(file);
message.success(res.message || '导入成功');
loadList();
} catch { /* handled by interceptor */ }
return false; // prevent upload default behavior
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '产品名称', dataIndex: 'name', width: 140 },
{ title: '代号', dataIndex: 'code', width: 100, render: (v: string) => <Tag color="blue">{v}</Tag> },
{ title: '平台', dataIndex: 'platform_name', width: 100 },
{ title: '部门', dataIndex: 'org_name', width: 100, render: (v: string) => v || '-' },
{ title: '内导渠道码', dataIndex: 'internal_channel_code', width: 110 },
{ title: '自然渠道码', dataIndex: 'natural_channel_code', width: 110 },
{
title: '状态', dataIndex: 'status', width: 80,
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'red'}>{v === 'active' ? '启用' : '禁用'}</Tag>,
},
{ title: '创建时间', dataIndex: 'created_at', width: 160, render: (v: string) => v?.slice(0, 19).replace('T', ' ') },
{
title: '操作', key: 'action', width: 160, fixed: 'right' as const,
render: (_: any, r: ProductItem) => (
<Space>
{hasButton('product:update') && <Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)}>编辑</Button>}
{hasButton('product:delete') && (
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
];
return (
<Card
title={<><ShopOutlined /> 产品列表</>}
extra={
<Space>
<Input.Search placeholder="搜索产品名/代号/渠道码" allowClear onSearch={(v) => { setSearch(v); setPage(1); }} style={{ width: 220 }} />
<Select allowClear placeholder="平台" onChange={(v) => { setFilterPlatform(v); setPage(1); }} style={{ width: 120 }}
options={platforms.map((p: any) => ({ value: p.id, label: p.name }))} />
<Button icon={<ReloadOutlined />} onClick={loadList} />
{hasButton('product:import') && (
<Upload accept=".csv" showUploadList={false} beforeUpload={handleImport}>
<Button icon={<UploadOutlined />}>CSV导入</Button>
</Upload>
)}
{hasButton('product:create') && <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增产品</Button>}
</Space>
}
>
<Table dataSource={data} columns={columns} rowKey="id" loading={loading}
pagination={{ current: page, total, pageSize: 10, onChange: setPage, showTotal: (t) => `共 ${t} 条` }}
size="small" scroll={{ x: 1300 }} />
<Modal title={editingId ? '编辑产品' : '新增产品'} open={modalOpen} onCancel={() => setModalOpen(false)} onOk={handleSave} width={640} destroyOnClose>
<Form form={form} layout="vertical" preserve={false}>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="产品名称" rules={[{ required: true }]}><Input /></Form.Item>
</Col>
<Col span={12}>
<Form.Item name="code" label="产品代号" rules={[{ required: true }]}><Input disabled={!!editingId} placeholder="如 PROD001" /></Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="platform_id" label="所属平台" rules={[{ required: true }]}>
<Select options={platforms.map((p: any) => ({ value: p.id, label: p.name }))} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="org_id" label="归属部门"><Select allowClear options={orgs} /></Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="internal_channel_code" label="内导渠道码" rules={[{ required: true, message: '内导渠道码必填 (RB-09)' }]}>
<Input placeholder="如 IC001" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="natural_channel_code" label="自然渠道码" rules={[{ required: true, message: '自然渠道码必填 (RB-09)' }]}>
<Input placeholder="如 NC001" />
</Form.Item>
</Col>
</Row>
<Form.Item name="status" label="状态" initialValue="active">
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
</Form.Item>
</Form>
</Modal>
</Card>
);
};
export default Products;
@@ -0,0 +1,133 @@
/**
* 执行器配置 — 龙虾/脚本双执行器状态卡片
*/
import React, { useEffect, useState } from 'react';
import { Row, Col, Card, Descriptions, Tag, Button, Modal, Form, InputNumber, Select, Input, message, Progress, Statistic, Space } from 'antd';
import { ApiOutlined, CodeOutlined, ReloadOutlined, SettingOutlined, HeartFilled } from '@ant-design/icons';
import { executorApi } from '@/api';
import type { ExecutorConfigItem } from '@/types';
const Executor: React.FC = () => {
const [data, setData] = useState<ExecutorConfigItem[]>([]);
const [loading, setLoading] = useState(false);
const [editModal, setEditModal] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form] = Form.useForm();
useEffect(() => { loadList(); }, []);
const loadList = async () => {
setLoading(true);
try {
const res: any = await executorApi.list();
setData(res.data || []);
} finally { setLoading(false); }
};
const handleEdit = (r: ExecutorConfigItem) => {
setEditingId(r.id);
form.setFieldsValue({
max_concurrency: r.max_concurrency,
status: r.status,
});
setEditModal(true);
};
const handleSave = async () => {
const values = await form.validateFields();
if (editingId) {
await executorApi.update(editingId, values);
message.success('配置已保存');
}
setEditModal(false);
loadList();
};
const getIcon = (type: string) => type === 'lobster' ? <ApiOutlined style={{ fontSize: 32, color: '#ff4d4f' }} /> : <CodeOutlined style={{ fontSize: 32, color: '#1677ff' }} />;
const getColor = (type: string) => type === 'lobster' ? '#ff4d4f' : '#1677ff';
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<h3 style={{ margin: 0 }}><SettingOutlined /> 执行器配置</h3>
<Button icon={<ReloadOutlined />} onClick={loadList}>刷新</Button>
</div>
<Row gutter={[24, 24]}>
{data.map((e) => {
const loadPercent = e.max_concurrency > 0 ? Math.round((e.current_load / e.max_concurrency) * 100) : 0;
return (
<Col xs={24} lg={12} key={e.id}>
<Card
style={{ borderRadius: 12, borderTop: `4px solid ${getColor(e.executor_type)}` }}
actions={[
<Button type="link" icon={<SettingOutlined />} onClick={() => handleEdit(e)}>配置</Button>,
]}
>
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start' }}>
<div style={{ flexShrink: 0 }}>{getIcon(e.executor_type)}</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ margin: 0 }}>{e.name}</h3>
<Space>
<Tag color={e.status === 'active' ? 'green' : 'red'}>
{e.status === 'active' ? '运行中' : '已停止'}
</Tag>
{e.status === 'active' && <HeartFilled style={{ color: '#52c41a', animation: 'pulse 1.5s infinite' }} />}
</Space>
</div>
<Row gutter={16}>
<Col span={8}>
<Statistic title="最大并发" value={e.max_concurrency} />
</Col>
<Col span={8}>
<Statistic title="当前负载" value={e.current_load} />
</Col>
<Col span={8}>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#999', fontSize: 12, marginBottom: 4 }}>负载率</div>
<Progress
type="circle"
percent={loadPercent}
size={60}
strokeColor={loadPercent > 80 ? '#ff4d4f' : loadPercent > 50 ? '#faad14' : '#52c41a'}
/>
</div>
</Col>
</Row>
{e.config_data && (
<div style={{ marginTop: 12, background: '#f5f5f5', padding: 8, borderRadius: 4, fontSize: 12 }}>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
{JSON.stringify(e.config_data, null, 2)}
</pre>
</div>
)}
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
最后更新: {e.updated_at?.slice(0, 19).replace('T', ' ')}
</div>
</div>
</div>
</Card>
</Col>
);
})}
</Row>
<Modal title="编辑执行器配置" open={editModal} onCancel={() => setEditModal(false)} onOk={handleSave} destroyOnClose>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item name="max_concurrency" label="最大并发数" rules={[{ required: true }]}>
<InputNumber min={1} max={100} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '停用' }]} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default Executor;
@@ -0,0 +1,207 @@
/**
* 定时任务管理 — Cron表达式 + 启停 + 执行记录
*/
import React, { useEffect, useState } from 'react';
import { Card, Table, Button, Space, Tag, Modal, Form, Input, Select, InputNumber, message, Popconfirm, Switch, Drawer } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, ClockCircleOutlined, ReloadOutlined, PlayCircleOutlined, PauseCircleOutlined, HistoryOutlined } from '@ant-design/icons';
import { scheduleApi, skillApi } from '@/api';
import type { ScheduleTaskItem } from '@/types';
const Schedule: React.FC = () => {
const [data, setData] = useState<ScheduleTaskItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [recordDrawer, setRecordDrawer] = useState(false);
const [records, setRecords] = useState<any[]>([]);
const [recordTotal, setRecordTotal] = useState(0);
const [form] = Form.useForm();
const [skills, setSkills] = useState<any[]>([]);
useEffect(() => { loadList(); loadSkills(); }, [page]);
const loadList = async () => {
setLoading(true);
try {
const res: any = await scheduleApi.list({ page, page_size: 10 });
setData(res.data || []);
setTotal(res.total || 0);
} finally { setLoading(false); }
};
const loadSkills = async () => {
const res: any = await skillApi.list({ page_size: 200, status: 'active' });
setSkills(res.data || []);
};
const handleAdd = () => { setEditingId(null); form.resetFields(); setModalOpen(true); };
const handleEdit = (r: ScheduleTaskItem) => {
setEditingId(r.id);
form.setFieldsValue({
...r,
exec_params_str: r.exec_params ? JSON.stringify(r.exec_params, null, 2) : '',
});
setModalOpen(true);
};
const handleSave = async () => {
const values = await form.validateFields();
let exec_params = null;
if (values.exec_params_str) {
try { exec_params = JSON.parse(values.exec_params_str); }
catch { message.error('参数JSON格式错误'); return; }
}
const payload = { ...values, exec_params };
delete payload.exec_params_str;
if (editingId) { await scheduleApi.update(editingId, payload); }
else { await scheduleApi.create(payload); }
message.success('保存成功');
setModalOpen(false);
loadList();
};
const handleDelete = async (id: number) => {
await scheduleApi.delete(id);
message.success('已删除');
loadList();
};
const handleToggle = async (id: number) => {
await scheduleApi.toggle(id);
message.success('状态已切换');
loadList();
};
const handleViewRecords = async (id: number) => {
const res: any = await scheduleApi.getRecords(id, { page_size: 50 });
setRecords(res.data || []);
setRecordTotal(res.total || 0);
setRecordDrawer(true);
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '任务名称', dataIndex: 'name', width: 160 },
{ title: 'Skill', dataIndex: 'skill_name', width: 130, render: (v: string) => v || '-' },
{ title: 'Cron表达式', dataIndex: 'cron_expression', width: 140, render: (v: string) => <Tag color="purple">{v}</Tag> },
{ title: '重试策略', dataIndex: 'retry_policy', width: 100, render: (v: string) => <Tag>{v}</Tag> },
{ title: '超时(秒)', dataIndex: 'timeout', width: 80, align: 'center' as const },
{
title: '状态', dataIndex: 'status', width: 100,
render: (v: string, r: ScheduleTaskItem) => (
<Space>
<Tag color={v === 'active' ? 'green' : 'orange'}>{v === 'active' ? '运行中' : '已暂停'}</Tag>
</Space>
),
},
{ title: '上次执行', dataIndex: 'last_run_at', width: 160, render: (v: string) => v?.slice(0, 19).replace('T', ' ') || '-' },
{ title: '下次执行', dataIndex: 'next_run_at', width: 160, render: (v: string) => v?.slice(0, 19).replace('T', ' ') || '-' },
{
title: '操作', key: 'action', width: 240, fixed: 'right' as const,
render: (_: any, r: ScheduleTaskItem) => (
<Space>
<Button size="small" type="link"
icon={r.status === 'active' ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
onClick={() => handleToggle(r.id)}>
{r.status === 'active' ? '暂停' : '启用'}
</Button>
<Button size="small" type="link" icon={<HistoryOutlined />} onClick={() => handleViewRecords(r.id)}>记录</Button>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)}>编辑</Button>
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
// Cron预设
const cronPresets = [
{ label: '每分钟', value: '* * * * *' },
{ label: '每5分钟', value: '*/5 * * * *' },
{ label: '每小时', value: '0 * * * *' },
{ label: '每天9点', value: '0 9 * * *' },
{ label: '每天9和18点', value: '0 9,18 * * *' },
{ label: '工作日9点', value: '0 9 * * 1-5' },
{ label: '每周一9点', value: '0 9 * * 1' },
];
return (
<Card
title={<><ClockCircleOutlined /> 定时任务</>}
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={loadList} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增定时任务</Button>
</Space>
}
>
<Table dataSource={data} columns={columns} rowKey="id" loading={loading}
pagination={{ current: page, total, pageSize: 10, onChange: setPage, showTotal: (t) => `共 ${t} 条` }}
size="small" scroll={{ x: 1400 }} />
<Modal title={editingId ? '编辑定时任务' : '新增定时任务'} open={modalOpen} onCancel={() => setModalOpen(false)} onOk={handleSave} width={600} destroyOnClose>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item name="name" label="任务名称" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="skill_id" label="Skill" rules={[{ required: true }]}>
<Select showSearch optionFilterProp="label"
options={skills.map((s: any) => ({ value: s.id, label: `${s.name} [${s.executor_type}]` }))} />
</Form.Item>
<Form.Item name="cron_expression" label="Cron表达式" rules={[{ required: true }]}
extra={
<Space wrap style={{ marginTop: 4 }}>
{cronPresets.map((p) => (
<Button key={p.value} size="small" type="dashed" onClick={() => form.setFieldsValue({ cron_expression: p.value })}>
{p.label}
</Button>
))}
</Space>
}
>
<Input placeholder="如 0 9 * * * (每天9点)" style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="exec_params_str" label="执行参数 (JSON)">
<Input.TextArea rows={3} style={{ fontFamily: 'monospace' }} placeholder='{"key": "value"}' />
</Form.Item>
<Form.Item name="retry_policy" label="重试策略" initialValue="retry_3">
<Select options={[
{ value: 'retry_3', label: '重试3次' },
{ value: 'retry_1', label: '重试1次' },
{ value: 'none', label: '不重试' },
]} />
</Form.Item>
<Form.Item name="timeout" label="超时(秒)" initialValue={120}>
<InputNumber min={10} max={3600} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态" initialValue="active">
<Select options={[{ value: 'active', label: '启用' }, { value: 'paused', label: '暂停' }]} />
</Form.Item>
</Form>
</Modal>
{/* 执行记录 */}
<Drawer title="执行记录" open={recordDrawer} onClose={() => setRecordDrawer(false)} width={500}>
<Table
dataSource={records}
rowKey="id"
size="small"
pagination={false}
columns={[
{
title: '状态', dataIndex: 'status', width: 80,
render: (v: string) => <Tag color={v === 'SUCCEEDED' ? 'green' : v === 'FAILED' ? 'red' : 'blue'}>{v}</Tag>,
},
{ title: '耗时', dataIndex: 'duration_ms', width: 80, render: (v: number) => v ? `${(v / 1000).toFixed(1)}s` : '-' },
{ title: '结果', dataIndex: 'result_text', ellipsis: true },
{ title: '执行时间', dataIndex: 'executed_at', width: 160, render: (v: string) => v?.slice(0, 19).replace('T', ' ') },
]}
/>
</Drawer>
</Card>
);
};
export default Schedule;
@@ -0,0 +1,159 @@
/**
* 脚本模板管理 — 代码编辑器 + 环境变量
*/
import React, { useEffect, useState } from 'react';
import { Card, Table, Button, Space, Tag, Modal, Form, Input, Select, message, Popconfirm, Row, Col } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, CodeOutlined, ReloadOutlined } from '@ant-design/icons';
import { scriptTemplateApi } from '@/api';
import type { ScriptTemplateItem } from '@/types';
const ScriptTemplate: React.FC = () => {
const [data, setData] = useState<ScriptTemplateItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [previewModal, setPreviewModal] = useState(false);
const [previewCode, setPreviewCode] = useState('');
const [form] = Form.useForm();
useEffect(() => { loadList(); }, [page]);
const loadList = async () => {
setLoading(true);
try {
const res: any = await scriptTemplateApi.list({ page, page_size: 10 });
setData(res.data || []);
setTotal(res.total || 0);
} finally { setLoading(false); }
};
const handleAdd = () => { setEditingId(null); form.resetFields(); setModalOpen(true); };
const handleEdit = (r: ScriptTemplateItem) => {
setEditingId(r.id);
form.setFieldsValue({
...r,
env_vars_str: r.env_vars ? JSON.stringify(r.env_vars, null, 2) : '',
});
setModalOpen(true);
};
const handleSave = async () => {
const values = await form.validateFields();
// 解析环境变量JSON
let env_vars = null;
if (values.env_vars_str) {
try { env_vars = JSON.parse(values.env_vars_str); }
catch { message.error('环境变量JSON格式错误'); return; }
}
const payload = { ...values, env_vars };
delete payload.env_vars_str;
if (editingId) { await scriptTemplateApi.update(editingId, payload); }
else { await scriptTemplateApi.create(payload); }
message.success('保存成功');
setModalOpen(false);
loadList();
};
const handleDelete = async (id: number) => {
await scriptTemplateApi.delete(id);
message.success('已删除');
loadList();
};
const langColors: Record<string, string> = { python: '#3776ab', nodejs: '#339933', shell: '#4eaa25' };
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '模板名称', dataIndex: 'name', width: 160 },
{
title: '脚本类型', dataIndex: 'script_type', width: 100,
render: (v: string) => <Tag color={langColors[v] || 'blue'}>{v}</Tag>,
},
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
{ title: '关联Skill', dataIndex: 'skill_count', width: 90, align: 'center' as const },
{
title: '状态', dataIndex: 'status', width: 80,
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'red'}>{v === 'active' ? '启用' : '禁用'}</Tag>,
},
{ title: '更新时间', dataIndex: 'updated_at', width: 160, render: (v: string) => v?.slice(0, 19).replace('T', ' ') },
{
title: '操作', key: 'action', width: 200, fixed: 'right' as const,
render: (_: any, r: ScriptTemplateItem) => (
<Space>
<Button size="small" type="link" onClick={() => { setPreviewCode(r.code); setPreviewModal(true); }}>
查看代码
</Button>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)}>编辑</Button>
<Popconfirm title="确认删除?被Skill引用的模板不可删除" onConfirm={() => handleDelete(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<Card
title={<><CodeOutlined /> 脚本模板管理</>}
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={loadList} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增模板</Button>
</Space>
}
>
<Table dataSource={data} columns={columns} rowKey="id" loading={loading}
pagination={{ current: page, total, pageSize: 10, onChange: setPage, showTotal: (t) => `共 ${t} 条` }}
size="small" scroll={{ x: 1100 }} />
{/* 新增/编辑 */}
<Modal title={editingId ? '编辑脚本模板' : '新增脚本模板'} open={modalOpen} onCancel={() => setModalOpen(false)} onOk={handleSave} width={720} destroyOnClose>
<Form form={form} layout="vertical" preserve={false}>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="模板名称" rules={[{ required: true }]}><Input /></Form.Item>
</Col>
<Col span={12}>
<Form.Item name="script_type" label="脚本类型" rules={[{ required: true }]}>
<Select options={[
{ value: 'python', label: '🐍 Python' },
{ value: 'nodejs', label: '🟩 Node.js' },
{ value: 'shell', label: '🐚 Shell' },
]} />
</Form.Item>
</Col>
</Row>
<Form.Item name="description" label="描述"><Input.TextArea rows={2} /></Form.Item>
<Form.Item name="code" label="脚本代码" rules={[{ required: true }]}>
<Input.TextArea
rows={12}
style={{ fontFamily: 'Consolas, Monaco, monospace', fontSize: 13, background: '#1e1e1e', color: '#d4d4d4' }}
placeholder="# 输入参数通过环境变量 TASK_INPUT (JSON) 获取&#10;import os, json&#10;params = json.loads(os.environ.get('TASK_INPUT', '{}'))&#10;print('Hello from script!')"
/>
</Form.Item>
<Form.Item name="env_vars_str" label="环境变量 (JSON)" extra='如 {"API_KEY": "xxx"}'>
<Input.TextArea rows={3} style={{ fontFamily: 'monospace' }} placeholder='{"KEY": "value"}' />
</Form.Item>
<Form.Item name="status" label="状态" initialValue="active">
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
</Form.Item>
</Form>
</Modal>
{/* 代码预览 */}
<Modal title="脚本代码" open={previewModal} onCancel={() => setPreviewModal(false)} footer={null} width={640}>
<pre style={{
background: '#1e1e1e', color: '#d4d4d4', padding: 16, borderRadius: 8,
maxHeight: 500, overflow: 'auto', fontFamily: 'Consolas, Monaco, monospace', fontSize: 13,
}}>
{previewCode}
</pre>
</Modal>
</Card>
);
};
export default ScriptTemplate;
+131
View File
@@ -0,0 +1,131 @@
/**
* Skill调用日志 — 查看调用详情
*/
import React, { useEffect, useState } from 'react';
import { Card, Table, Tag, Select, Space, Input, DatePicker, Button, Modal, Descriptions, Typography } from 'antd';
import { FileTextOutlined, ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, ClockCircleOutlined } from '@ant-design/icons';
import { skillCallLogApi } from '@/api';
import type { SkillCallLogItem } from '@/types';
import dayjs from 'dayjs';
const { RangePicker } = DatePicker;
const CallLogs: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [filterStatus, setFilterStatus] = useState<string | undefined>();
const [loading, setLoading] = useState(false);
const [detailModal, setDetailModal] = useState(false);
const [detailData, setDetailData] = useState<any>(null);
useEffect(() => { loadList(); }, [page, filterStatus]);
const loadList = async () => {
setLoading(true);
try {
const res: any = await skillCallLogApi.list({ page, page_size: 20, status: filterStatus });
setData(res.data || []);
setTotal(res.total || 0);
} finally { setLoading(false); }
};
const statusConfig: Record<string, { color: string; icon: React.ReactNode; text: string }> = {
SUCCESS: { color: 'success', icon: <CheckCircleOutlined />, text: '成功' },
FAILED: { color: 'error', icon: <CloseCircleOutlined />, text: '失败' },
TIMEOUT: { color: 'warning', icon: <ClockCircleOutlined />, text: '超时' },
};
const columns = [
{ title: '调用ID', dataIndex: 'call_id', width: 140, render: (v: string) => <Tag>{v}</Tag> },
{ title: 'Skill', dataIndex: 'skill_name', width: 140, render: (v: string) => v || '-' },
{
title: '执行器', dataIndex: 'executor_type', width: 80,
render: (v: string) => <Tag color={v === 'lobster' ? 'volcano' : 'geekblue'}>{v === 'lobster' ? '龙虾' : '脚本'}</Tag>,
},
{
title: '状态', dataIndex: 'status', width: 80,
render: (v: string) => {
const cfg = statusConfig[v] || { color: 'default', icon: null, text: v };
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
},
},
{
title: '耗时', dataIndex: 'duration_ms', width: 90,
render: (v: number) => v ? `${(v / 1000).toFixed(2)}s` : '-',
},
{ title: '调用时间', dataIndex: 'created_at', width: 160, render: (v: string) => v?.slice(0, 19).replace('T', ' ') },
{
title: '操作', key: 'action', width: 80,
render: (_: any, r: any) => (
<Button size="small" type="link" onClick={() => { setDetailData(r); setDetailModal(true); }}>详情</Button>
),
},
];
return (
<Card
title={<><FileTextOutlined /> Skill调用日志</>}
extra={
<Space>
<Select allowClear placeholder="状态" onChange={(v) => { setFilterStatus(v); setPage(1); }} style={{ width: 100 }}
options={[{ value: 'SUCCESS', label: '成功' }, { value: 'FAILED', label: '失败' }, { value: 'TIMEOUT', label: '超时' }]} />
<Button icon={<ReloadOutlined />} onClick={loadList} />
</Space>
}
>
<Table dataSource={data} columns={columns} rowKey="id" loading={loading}
pagination={{ current: page, total, pageSize: 20, onChange: setPage, showTotal: (t) => `共 ${t} 条` }}
size="small" />
<Modal title="调用日志详情" open={detailModal} onCancel={() => setDetailModal(false)} footer={null} width={640}>
{detailData && (
<>
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="调用ID">{detailData.call_id}</Descriptions.Item>
<Descriptions.Item label="状态">
{(() => { const cfg = statusConfig[detailData.status]; return <Tag color={cfg?.color}>{cfg?.text || detailData.status}</Tag>; })()}
</Descriptions.Item>
<Descriptions.Item label="Skill ID">{detailData.skill_id}</Descriptions.Item>
<Descriptions.Item label="执行器">{detailData.executor_type}</Descriptions.Item>
<Descriptions.Item label="耗时">{detailData.duration_ms ? `${(detailData.duration_ms / 1000).toFixed(2)}s` : '-'}</Descriptions.Item>
<Descriptions.Item label="时间">{detailData.created_at?.slice(0, 19).replace('T', ' ')}</Descriptions.Item>
</Descriptions>
{detailData.request_params && (
<>
<h4 style={{ marginTop: 16 }}>请求参数</h4>
<Typography.Paragraph>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, maxHeight: 200, overflow: 'auto' }}>
{JSON.stringify(detailData.request_params, null, 2)}
</pre>
</Typography.Paragraph>
</>
)}
{detailData.response_data && (
<>
<h4>响应数据</h4>
<Typography.Paragraph>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, maxHeight: 200, overflow: 'auto' }}>
{JSON.stringify(detailData.response_data, null, 2)}
</pre>
</Typography.Paragraph>
</>
)}
{detailData.execution_log && (
<>
<h4>执行日志</h4>
<Typography.Paragraph>
<pre style={{ background: '#1a1a1a', color: '#0f0', padding: 12, borderRadius: 4, maxHeight: 200, overflow: 'auto', fontSize: 12 }}>
{detailData.execution_log}
</pre>
</Typography.Paragraph>
</>
)}
</>
)}
</Modal>
</Card>
);
};
export default CallLogs;
@@ -0,0 +1,244 @@
/**
* Skill列表 — 双执行器(龙虾/脚本) + RB-13互斥校验 + 产品范围绑定
*/
import React, { useEffect, useState } from 'react';
import { Card, Table, Button, Space, Tag, Modal, Form, Input, Select, InputNumber, message, Popconfirm, Row, Col, Divider, Descriptions, Progress, Tabs } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, ThunderboltOutlined, ReloadOutlined, ApiOutlined, CodeOutlined } from '@ant-design/icons';
import { skillApi, productApi, scriptTemplateApi } from '@/api';
import type { SkillItem } from '@/types';
import { useAuthStore } from '@/stores/auth';
const SkillList: React.FC = () => {
const [data, setData] = useState<SkillItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState<string | undefined>();
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [detailModal, setDetailModal] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [detailData, setDetailData] = useState<SkillItem | null>(null);
const [form] = Form.useForm();
const [executorType, setExecutorType] = useState('lobster');
const hasButton = useAuthStore((s) => s.hasButton);
const [products, setProducts] = useState<any[]>([]);
const [templates, setTemplates] = useState<any[]>([]);
useEffect(() => { loadList(); }, [page, search, filterType]);
useEffect(() => { loadOptions(); }, []);
const loadList = async () => {
setLoading(true);
try {
const res: any = await skillApi.list({ page, page_size: 10, search, executor_type: filterType });
setData(res.data || []);
setTotal(res.total || 0);
} finally { setLoading(false); }
};
const loadOptions = async () => {
const [pRes, tRes]: any = await Promise.all([
productApi.list({ page_size: 500 }),
scriptTemplateApi.list({ page_size: 100 }),
]);
setProducts(pRes.data || []);
setTemplates(tRes.data || []);
};
const handleAdd = () => {
setEditingId(null);
form.resetFields();
setExecutorType('lobster');
setModalOpen(true);
};
const handleEdit = (r: SkillItem) => {
setEditingId(r.id);
setExecutorType(r.executor_type);
form.setFieldsValue({
...r,
product_ids: [], // 需要从详情接口获取
});
setModalOpen(true);
};
const handleViewDetail = async (id: number) => {
const res: any = await skillApi.getDetail(id);
setDetailData(res.data);
setDetailModal(true);
};
const handleSave = async () => {
const values = await form.validateFields();
if (editingId) { await skillApi.update(editingId, values); }
else { await skillApi.create(values); }
message.success('保存成功');
setModalOpen(false);
loadList();
};
const handleDelete = async (id: number) => {
await skillApi.delete(id);
message.success('已删除');
loadList();
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{
title: 'Skill名称', dataIndex: 'name', width: 160,
render: (v: string, r: SkillItem) => (
<Button type="link" onClick={() => handleViewDetail(r.id)}>{v}</Button>
),
},
{ title: '分类', dataIndex: 'category', width: 100, render: (v: string) => v || '-' },
{
title: '执行器', dataIndex: 'executor_type', width: 100,
render: (v: string) => (
<Tag color={v === 'lobster' ? 'volcano' : 'geekblue'} icon={v === 'lobster' ? <ApiOutlined /> : <CodeOutlined />}>
{v === 'lobster' ? '龙虾' : '脚本'}
</Tag>
),
},
{ title: '关联产品', dataIndex: 'product_count', width: 90, align: 'center' as const },
{ title: '调用次数', dataIndex: 'call_count', width: 90, align: 'center' as const },
{
title: '成功率', dataIndex: 'success_rate', width: 100,
render: (v: number) => <Progress percent={v} size="small" status={v >= 80 ? 'success' : v >= 50 ? 'normal' : 'exception'} />,
},
{
title: '状态', dataIndex: 'status', width: 80,
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'red'}>{v === 'active' ? '启用' : '禁用'}</Tag>,
},
{
title: '操作', key: 'action', width: 160, fixed: 'right' as const,
render: (_: any, r: SkillItem) => (
<Space>
{hasButton('skill:update') && <Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)}>编辑</Button>}
{hasButton('skill:delete') && (
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
];
return (
<Card
title={<><ThunderboltOutlined /> Skill列表</>}
extra={
<Space>
<Input.Search placeholder="搜索Skill" allowClear onSearch={(v) => { setSearch(v); setPage(1); }} style={{ width: 180 }} />
<Select allowClear placeholder="执行器" onChange={(v) => { setFilterType(v); setPage(1); }} style={{ width: 100 }}
options={[{ value: 'lobster', label: '龙虾' }, { value: 'script', label: '脚本' }]} />
<Button icon={<ReloadOutlined />} onClick={loadList} />
{hasButton('skill:create') && <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增Skill</Button>}
</Space>
}
>
<Table dataSource={data} columns={columns} rowKey="id" loading={loading}
pagination={{ current: page, total, pageSize: 10, onChange: setPage, showTotal: (t) => `共 ${t} 条` }}
size="small" scroll={{ x: 1100 }} />
{/* 新增/编辑 */}
<Modal title={editingId ? '编辑Skill' : '新增Skill'} open={modalOpen} onCancel={() => setModalOpen(false)} onOk={handleSave} width={720} destroyOnClose>
<Form form={form} layout="vertical" preserve={false}>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="Skill名称" rules={[{ required: true }]}><Input /></Form.Item>
</Col>
<Col span={12}>
<Form.Item name="category" label="分类"><Input placeholder="如 内容发布、数据采集" /></Form.Item>
</Col>
</Row>
<Form.Item name="description" label="描述"><Input.TextArea rows={2} /></Form.Item>
<Form.Item name="executor_type" label="执行器类型 (RB-13互斥)" rules={[{ required: true }]}>
<Select onChange={(v) => setExecutorType(v)} options={[
{ value: 'lobster', label: '🦞 龙虾Agent执行器' },
{ value: 'script', label: '📝 脚本执行器' },
]} />
</Form.Item>
{/* 龙虾配置 */}
{executorType === 'lobster' && (
<>
<Divider orientation="left" plain>🦞 龙虾Agent配置</Divider>
<Row gutter={16}>
<Col span={12}><Form.Item name="lobster_api_url" label="API URL" rules={[{ required: true }]}><Input placeholder="https://api.lobster-agent.com/v1" /></Form.Item></Col>
<Col span={12}><Form.Item name="lobster_agent_id" label="Agent ID" rules={[{ required: true }]}><Input placeholder="agent_xhs_publish_01" /></Form.Item></Col>
</Row>
<Form.Item name="lobster_api_key" label="API Key" rules={editingId ? [] : [{ required: true }]}
extra={editingId ? '留空则不修改,已加密存储' : '将AES加密存储'}>
<Input.Password />
</Form.Item>
</>
)}
{/* 脚本配置 */}
{executorType === 'script' && (
<>
<Divider orientation="left" plain>📝 脚本执行器配置</Divider>
<Form.Item name="script_template_id" label="脚本模板" rules={[{ required: true }]}>
<Select placeholder="选择脚本模板"
options={templates.map((t: any) => ({ value: t.id, label: `${t.name} [${t.script_type}]` }))} />
</Form.Item>
<Form.Item name="script_timeout" label="超时时间(秒)" initialValue={30}>
<InputNumber min={5} max={600} />
</Form.Item>
</>
)}
<Divider orientation="left" plain>产品范围</Divider>
<Form.Item name="product_ids" label="关联产品">
<Select mode="multiple" placeholder="选择可使用此Skill的产品" maxTagCount={5}
options={products.map((p: any) => ({ value: p.id, label: `${p.name} (${p.code})` }))} />
</Form.Item>
<Form.Item name="status" label="状态" initialValue="active">
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
</Form.Item>
</Form>
</Modal>
{/* 详情 */}
<Modal title="Skill详情" open={detailModal} onCancel={() => setDetailModal(false)} footer={null} width={640}>
{detailData && (
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="名称">{detailData.name}</Descriptions.Item>
<Descriptions.Item label="分类">{detailData.category || '-'}</Descriptions.Item>
<Descriptions.Item label="执行器">
<Tag color={detailData.executor_type === 'lobster' ? 'volcano' : 'geekblue'}>
{detailData.executor_type === 'lobster' ? '龙虾Agent' : '脚本执行器'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={detailData.status === 'active' ? 'green' : 'red'}>{detailData.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="描述" span={2}>{detailData.description || '-'}</Descriptions.Item>
{detailData.executor_type === 'lobster' && (
<>
<Descriptions.Item label="API URL">{detailData.lobster_api_url}</Descriptions.Item>
<Descriptions.Item label="Agent ID">{detailData.lobster_agent_id}</Descriptions.Item>
</>
)}
{detailData.executor_type === 'script' && (
<>
<Descriptions.Item label="模板ID">{detailData.script_template_id}</Descriptions.Item>
<Descriptions.Item label="超时">{detailData.script_timeout}秒</Descriptions.Item>
</>
)}
<Descriptions.Item label="关联产品">{detailData.product_count} 个</Descriptions.Item>
<Descriptions.Item label="调用次数">{detailData.call_count} 次</Descriptions.Item>
</Descriptions>
)}
</Modal>
</Card>
);
};
export default SkillList;
+229
View File
@@ -0,0 +1,229 @@
/**
* 菜单管理 — 树形表格 + 按钮权限管理
*/
import React, { useEffect, useState } from 'react';
import { Card, Table, Button, Space, Tag, Modal, Form, Input, Select, InputNumber, Switch, message, Popconfirm, Divider } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, MenuOutlined, ReloadOutlined } from '@ant-design/icons';
import { menuApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
const Menus: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [menuModal, setMenuModal] = useState(false);
const [btnModal, setBtnModal] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [menuForm] = Form.useForm();
const [btnForm] = Form.useForm();
const hasButton = useAuthStore((s) => s.hasButton);
useEffect(() => {
loadTree();
}, []);
const loadTree = async () => {
setLoading(true);
try {
const res: any = await menuApi.getTree();
setData(res.data || []);
} finally {
setLoading(false);
}
};
// 扁平化菜单用于parent选择
const flatMenus = (nodes: any[], result: any[] = []): any[] => {
nodes.forEach((n) => {
result.push({ value: n.id, label: `${' '.repeat(0)}${n.name}` });
if (n.children) flatMenus(n.children, result);
});
return result;
};
const handleAddMenu = (parentId?: number) => {
setEditingId(null);
menuForm.resetFields();
if (parentId) menuForm.setFieldsValue({ parent_id: parentId });
setMenuModal(true);
};
const handleEditMenu = (record: any) => {
setEditingId(record.id);
menuForm.setFieldsValue(record);
setMenuModal(true);
};
const handleSaveMenu = async () => {
const values = await menuForm.validateFields();
if (editingId) {
await menuApi.update(editingId, values);
} else {
await menuApi.create(values);
}
message.success('保存成功');
setMenuModal(false);
loadTree();
};
const handleDeleteMenu = async (id: number) => {
await menuApi.delete(id);
message.success('已删除');
loadTree();
};
// 按钮
const handleAddBtn = (menuId: number) => {
btnForm.resetFields();
btnForm.setFieldsValue({ menu_id: menuId });
setBtnModal(true);
};
const handleSaveBtn = async () => {
const values = await btnForm.validateFields();
await menuApi.createButton(values);
message.success('按钮已保存');
setBtnModal(false);
loadTree();
};
const handleDeleteBtn = async (id: number) => {
await menuApi.deleteButton(id);
message.success('已删除');
loadTree();
};
const typeColors: Record<string, string> = {
directory: 'orange', menu: 'blue', link: 'cyan',
};
const columns = [
{ title: '菜单名称', dataIndex: 'name', width: 200 },
{
title: '类型', dataIndex: 'menu_type', width: 80,
render: (v: string) => <Tag color={typeColors[v]}>{v === 'directory' ? '目录' : v === 'menu' ? '菜单' : '链接'}</Tag>,
},
{ title: '路径', dataIndex: 'path', width: 200, ellipsis: true },
{ title: '图标', dataIndex: 'icon', width: 140, ellipsis: true },
{ title: '组件', dataIndex: 'component', width: 200, ellipsis: true },
{ title: '排序', dataIndex: 'sort_order', width: 60, align: 'center' as const },
{
title: '可见', dataIndex: 'visible', width: 60,
render: (v: boolean) => v ? <Tag color="green">是</Tag> : <Tag color="red">否</Tag>,
},
{
title: '按钮权限', key: 'buttons', width: 240,
render: (_: any, record: any) => (
<Space wrap>
{(record.buttons || []).map((b: any) => (
<Tag
key={b.id}
closable={hasButton('menu:delete')}
onClose={(e) => { e.preventDefault(); handleDeleteBtn(b.id); }}
color="geekblue"
>
{b.name} ({b.code})
</Tag>
))}
{hasButton('menu:create') && (
<Button size="small" type="dashed" icon={<PlusOutlined />} onClick={() => handleAddBtn(record.id)}>
按钮
</Button>
)}
</Space>
),
},
{
title: '操作', key: 'action', width: 200, fixed: 'right' as const,
render: (_: any, record: any) => (
<Space>
{hasButton('menu:create') && <Button size="small" icon={<PlusOutlined />} onClick={() => handleAddMenu(record.id)}>子级</Button>}
{hasButton('menu:update') && <Button size="small" icon={<EditOutlined />} onClick={() => handleEditMenu(record)}>编辑</Button>}
{hasButton('menu:delete') && (
<Popconfirm title="确认删除?" onConfirm={() => handleDeleteMenu(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
];
return (
<Card
title={<><MenuOutlined /> 菜单管理</>}
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={loadTree} />
{hasButton('menu:create') && <Button type="primary" icon={<PlusOutlined />} onClick={() => handleAddMenu()}>新增菜单</Button>}
</Space>
}
>
<Table
dataSource={data}
columns={columns}
rowKey="id"
loading={loading}
pagination={false}
size="small"
scroll={{ x: 1400 }}
expandable={{ defaultExpandAllRows: true }}
/>
{/* 菜单弹窗 */}
<Modal title={editingId ? '编辑菜单' : '新增菜单'} open={menuModal} onCancel={() => setMenuModal(false)} onOk={handleSaveMenu} destroyOnClose>
<Form form={menuForm} layout="vertical" preserve={false}>
<Form.Item name="name" label="菜单名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="menu_type" label="类型" initialValue="menu">
<Select options={[
{ value: 'directory', label: '目录' },
{ value: 'menu', label: '菜单' },
{ value: 'link', label: '外部链接' },
]} />
</Form.Item>
<Form.Item name="parent_id" label="上级菜单">
<Select allowClear placeholder="无(顶级)" options={flatMenus(data)} />
</Form.Item>
<Form.Item name="path" label="路由路径" rules={[{ required: true }]}>
<Input placeholder="如 /system/users" />
</Form.Item>
<Form.Item name="icon" label="图标">
<Input placeholder="如 UserOutlined" />
</Form.Item>
<Form.Item name="component" label="组件路径">
<Input placeholder="如 pages/System/Users" />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={1}>
<InputNumber min={1} max={999} />
</Form.Item>
<Form.Item name="visible" label="是否可见" valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Form>
</Modal>
{/* 按钮弹窗 */}
<Modal title="新增按钮权限" open={btnModal} onCancel={() => setBtnModal(false)} onOk={handleSaveBtn} destroyOnClose>
<Form form={btnForm} layout="vertical" preserve={false}>
<Form.Item name="menu_id" hidden><Input /></Form.Item>
<Form.Item name="name" label="按钮名称" rules={[{ required: true }]}>
<Input placeholder="如 新增、编辑、删除" />
</Form.Item>
<Form.Item name="permission_code" label="权限标识" rules={[{ required: true }]}>
<Input placeholder="如 user:create" />
</Form.Item>
<Form.Item name="button_type" label="按钮类型" initialValue="default">
<Select options={[
{ value: 'default', label: '默认' },
{ value: 'primary', label: '主要' },
{ value: 'danger', label: '危险' },
]} />
</Form.Item>
</Form>
</Modal>
</Card>
);
};
export default Menus;
+245
View File
@@ -0,0 +1,245 @@
/**
* 组织架构管理 — 左侧树 + 右侧详情/编辑
*/
import React, { useEffect, useState } from 'react';
import { Row, Col, Card, Tree, Button, Modal, Form, Input, Select, InputNumber, Space, Tag, message, Popconfirm, Descriptions, Empty } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, ApartmentOutlined, TeamOutlined, UserOutlined, ReloadOutlined } from '@ant-design/icons';
import { orgApi } from '@/api';
import type { OrgNode } from '@/types';
import { useAuthStore } from '@/stores/auth';
const { TreeNode } = Tree;
const Orgs: React.FC = () => {
const [tree, setTree] = useState<OrgNode[]>([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<OrgNode | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form] = Form.useForm();
const hasButton = useAuthStore((s) => s.hasButton);
useEffect(() => {
loadTree();
}, []);
const loadTree = async () => {
setLoading(true);
try {
const res: any = await orgApi.getTree();
setTree(res.data || []);
} finally {
setLoading(false);
}
};
// 选中节点
const onSelect = async (keys: any[]) => {
if (keys.length === 0) return;
try {
const res: any = await orgApi.getDetail(keys[0]);
setSelected(res.data);
} catch {
// ignore
}
};
// 新增
const handleAdd = (parentId?: number) => {
setEditingId(null);
form.resetFields();
if (parentId) {
form.setFieldsValue({ parent_id: parentId });
}
setModalOpen(true);
};
// 编辑
const handleEdit = (node: OrgNode) => {
setEditingId(node.id);
form.setFieldsValue({
name: node.name,
org_type: node.org_type,
parent_id: node.parent_id,
sort_order: node.sort_order,
status: node.status,
});
setModalOpen(true);
};
// 保存
const handleSave = async () => {
const values = await form.validateFields();
if (editingId) {
await orgApi.update(editingId, values);
} else {
await orgApi.create(values);
}
message.success('保存成功');
setModalOpen(false);
loadTree();
};
// 删除
const handleDelete = async (id: number) => {
await orgApi.delete(id);
message.success('已删除');
setSelected(null);
loadTree();
};
// 渲染树节点
const renderTreeNodes = (nodes: OrgNode[]): any[] =>
nodes.map((n) => ({
key: n.id,
title: (
<Space>
{n.org_type === 'company' ? <ApartmentOutlined /> : n.org_type === 'department' ? <TeamOutlined /> : <UserOutlined />}
<span>{n.name}</span>
<Tag color={n.status === 'active' ? 'green' : 'default'} style={{ fontSize: 10 }}>
{n.member_count}人
</Tag>
</Space>
),
children: n.children ? renderTreeNodes(n.children) : [],
}));
const orgTypeLabel: Record<string, string> = {
company: '公司', department: '部门', team: '小组',
};
// 扁平化tree用于parent_id选择
const flattenTree = (nodes: OrgNode[], result: Array<{ id: number; name: string; org_type: string }> = []) => {
nodes.forEach((n) => {
result.push({ id: n.id, name: n.name, org_type: n.org_type });
if (n.children) flattenTree(n.children, result);
});
return result;
};
const flatOrgs = flattenTree(tree);
return (
<Row gutter={16}>
{/* 左侧: 组织树 */}
<Col xs={24} lg={8}>
<Card
title="组织架构"
size="small"
extra={
<Space>
<Button icon={<ReloadOutlined />} size="small" onClick={loadTree} />
{hasButton('org:create') && (
<Button type="primary" icon={<PlusOutlined />} size="small" onClick={() => handleAdd()}>
新增
</Button>
)}
</Space>
}
>
{tree.length === 0 ? (
<Empty description="暂无组织" />
) : (
<Tree
treeData={renderTreeNodes(tree)}
onSelect={onSelect}
defaultExpandAll
blockNode
/>
)}
</Card>
</Col>
{/* 右侧: 详情 */}
<Col xs={24} lg={16}>
<Card
title={selected ? `${selected.name} — 详情` : '请选择组织'}
size="small"
extra={
selected && (
<Space>
{hasButton('org:create') && (
<Button size="small" icon={<PlusOutlined />} onClick={() => handleAdd(selected.id)}>
新增子级
</Button>
)}
{hasButton('org:update') && (
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(selected as any)}>
编辑
</Button>
)}
{hasButton('org:delete') && (
<Popconfirm title="确认删除?子组织和成员将移至上级" onConfirm={() => handleDelete(selected.id)}>
<Button size="small" danger icon={<DeleteOutlined />}>
删除
</Button>
</Popconfirm>
)}
</Space>
)
}
>
{selected ? (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="名称">{selected.name}</Descriptions.Item>
<Descriptions.Item label="类型">
<Tag>{orgTypeLabel[(selected as any).org_type] || (selected as any).org_type}</Tag>
</Descriptions.Item>
<Descriptions.Item label="排序">{selected.sort_order}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={selected.status === 'active' ? 'green' : 'red'}>
{selected.status === 'active' ? '启用' : '禁用'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="成员数">{selected.member_count}</Descriptions.Item>
<Descriptions.Item label="子组织数">{(selected as any).child_count || 0}</Descriptions.Item>
</Descriptions>
) : (
<Empty description="点击左侧树节点查看详情" />
)}
</Card>
</Col>
{/* 编辑弹窗 */}
<Modal
title={editingId ? '编辑组织' : '新增组织'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleSave}
destroyOnClose
>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="组织名称" />
</Form.Item>
<Form.Item name="org_type" label="类型" rules={[{ required: true }]}>
<Select options={[
{ value: 'company', label: '公司' },
{ value: 'department', label: '部门' },
{ value: 'team', label: '小组' },
]} />
</Form.Item>
<Form.Item name="parent_id" label="上级组织">
<Select allowClear placeholder="无(顶级)">
{flatOrgs.map((o) => (
<Select.Option key={o.id} value={o.id}>
{o.name} ({orgTypeLabel[o.org_type]})
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={1}>
<InputNumber min={1} max={999} />
</Form.Item>
<Form.Item name="status" label="状态" initialValue="active">
<Select options={[
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '禁用' },
]} />
</Form.Item>
</Form>
</Modal>
</Row>
);
};
export default Orgs;
+263
View File
@@ -0,0 +1,263 @@
/**
* 角色管理 — 列表 + 四维权限分配弹窗
*/
import React, { useEffect, useState } from 'react';
import { Card, Table, Button, Space, Tag, Modal, Form, Input, Select, Switch, Tabs, Tree, message, Popconfirm, Row, Col, InputNumber } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyOutlined, ReloadOutlined } from '@ant-design/icons';
import { roleApi, orgApi, menuApi, productApi, skillApi } from '@/api';
import type { RoleItem } from '@/types';
import { useAuthStore } from '@/stores/auth';
const Roles: React.FC = () => {
const [data, setData] = useState<RoleItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form] = Form.useForm();
const hasButton = useAuthStore((s) => s.hasButton);
// 四维权限数据
const [orgs, setOrgs] = useState<any[]>([]);
const [menuTree, setMenuTree] = useState<any[]>([]);
const [products, setProducts] = useState<any[]>([]);
const [skills, setSkills] = useState<any[]>([]);
const [checkedMenus, setCheckedMenus] = useState<number[]>([]);
const [checkedProducts, setCheckedProducts] = useState<number[]>([]);
const [checkedSkills, setCheckedSkills] = useState<number[]>([]);
useEffect(() => {
loadList();
loadOptions();
}, [page]);
const loadList = async () => {
setLoading(true);
try {
const res: any = await roleApi.list({ page, page_size: 10 });
setData(res.data || []);
setTotal(res.total || 0);
} finally {
setLoading(false);
}
};
const loadOptions = async () => {
const [orgRes, menuRes, prodRes, skillRes]: any = await Promise.all([
orgApi.getTree(),
menuApi.getTree(),
productApi.list({ page_size: 200 }),
skillApi.list({ page_size: 200 }),
]);
setOrgs(flattenOrgs(orgRes.data || []));
setMenuTree(orgRes.data ? convertMenuTree(menuRes.data || []) : []);
setProducts(prodRes.data || []);
setSkills(skillRes.data || []);
};
const flattenOrgs = (nodes: any[], result: any[] = []): any[] => {
nodes.forEach((n: any) => {
result.push({ value: n.id, label: n.name });
if (n.children) flattenOrgs(n.children, result);
});
return result;
};
const convertMenuTree = (nodes: any[]): any[] =>
nodes.map((n: any) => ({
key: n.id,
title: n.name,
children: n.children ? convertMenuTree(n.children) : [],
}));
const handleAdd = () => {
setEditingId(null);
form.resetFields();
setCheckedMenus([]);
setCheckedProducts([]);
setCheckedSkills([]);
setModalOpen(true);
};
const handleEdit = (record: RoleItem) => {
setEditingId(record.id);
form.setFieldsValue({
name: record.name,
code: record.code,
org_id: record.org_id,
is_global: record.is_global,
description: record.description,
status: record.status,
});
setModalOpen(true);
};
const handleSave = async () => {
const values = await form.validateFields();
const payload = {
...values,
menu_ids: checkedMenus,
product_ids: checkedProducts,
skill_ids: checkedSkills,
};
if (editingId) {
await roleApi.update(editingId, payload);
} else {
await roleApi.create(payload);
}
message.success('保存成功');
setModalOpen(false);
loadList();
};
const handleDelete = async (id: number) => {
await roleApi.delete(id);
message.success('已删除');
loadList();
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '角色名称', dataIndex: 'name', width: 140 },
{ title: '编码', dataIndex: 'code', width: 120, render: (v: string) => <Tag>{v}</Tag> },
{ title: '归属部门', dataIndex: 'org_name', width: 120, render: (v: string, r: RoleItem) => r.is_global ? <Tag color="gold">全局</Tag> : v },
{ title: '菜单', dataIndex: 'menu_count', width: 70, align: 'center' as const },
{ title: '产品', dataIndex: 'product_count', width: 70, align: 'center' as const },
{ title: 'Skill', dataIndex: 'skill_count', width: 70, align: 'center' as const },
{ title: '成员', dataIndex: 'member_count', width: 70, align: 'center' as const },
{
title: '状态', dataIndex: 'status', width: 80,
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'red'}>{v === 'active' ? '启用' : '禁用'}</Tag>,
},
{
title: '操作', key: 'action', width: 160,
render: (_: any, record: RoleItem) => (
<Space>
{hasButton('role:update') && <Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>}
{hasButton('role:delete') && record.code !== 'super_admin' && (
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
)}
</Space>
),
},
];
return (
<Card
title={<><SafetyOutlined /> 角色管理</>}
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={loadList}>刷新</Button>
{hasButton('role:create') && <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增角色</Button>}
</Space>
}
>
<Table
dataSource={data}
columns={columns}
rowKey="id"
loading={loading}
pagination={{ current: page, total, pageSize: 10, onChange: setPage, showTotal: (t) => `共 ${t} 条` }}
size="small"
/>
<Modal
title={editingId ? '编辑角色' : '新增角色'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleSave}
width={720}
destroyOnClose
>
<Form form={form} layout="vertical" preserve={false}>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="角色名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="code" label="角色编码" rules={[{ required: true }]}>
<Input disabled={!!editingId} placeholder="如 ops_manager" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="org_id" label="归属部门">
<Select allowClear placeholder="不选则为全局" options={orgs} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="is_global" label="全局角色" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="status" label="状态" initialValue="active">
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
</Form.Item>
</Col>
</Row>
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} />
</Form.Item>
<Tabs
items={[
{
key: 'menus',
label: `菜单权限 (${checkedMenus.length})`,
children: (
<Tree
checkable
treeData={menuTree}
checkedKeys={checkedMenus}
onCheck={(keys: any) => setCheckedMenus(keys as number[])}
defaultExpandAll
style={{ maxHeight: 300, overflow: 'auto' }}
/>
),
},
{
key: 'products',
label: `产品范围 (${checkedProducts.length})`,
children: (
<Select
mode="multiple"
style={{ width: '100%' }}
placeholder="选择可操作的产品"
value={checkedProducts}
onChange={setCheckedProducts}
options={products.map((p: any) => ({ value: p.id, label: `${p.name} (${p.code})` }))}
maxTagCount={5}
/>
),
},
{
key: 'skills',
label: `Skill权限 (${checkedSkills.length})`,
children: (
<Select
mode="multiple"
style={{ width: '100%' }}
placeholder="选择可使用的Skill"
value={checkedSkills}
onChange={setCheckedSkills}
options={skills.map((s: any) => ({ value: s.id, label: `${s.name} [${s.executor_type}]` }))}
maxTagCount={5}
/>
),
},
]}
/>
</Form>
</Modal>
</Card>
);
};
export default Roles;
+261
View File
@@ -0,0 +1,261 @@
/**
* 用户管理 — 列表 + 新增/编辑弹窗 + 重置密码
* 规则: RB-02(部门角色), RB-08(切换部门清空角色)
*/
import React, { useEffect, useState } from 'react';
import { Card, Table, Button, Space, Tag, Modal, Form, Input, Select, message, Popconfirm, Row, Col, Tooltip } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined, SearchOutlined, UserOutlined } from '@ant-design/icons';
import { userApi, orgApi, roleApi } from '@/api';
import type { UserItem } from '@/types';
import { useAuthStore } from '@/stores/auth';
const Users: React.FC = () => {
const [data, setData] = useState<UserItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [filterOrg, setFilterOrg] = useState<number | undefined>();
const [filterStatus, setFilterStatus] = useState<string | undefined>();
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [pwdModalOpen, setPwdModalOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form] = Form.useForm();
const [pwdForm] = Form.useForm();
const hasButton = useAuthStore((s) => s.hasButton);
const [orgs, setOrgs] = useState<any[]>([]);
const [roles, setRoles] = useState<any[]>([]);
useEffect(() => {
loadList();
}, [page, search, filterOrg, filterStatus]);
useEffect(() => {
loadOptions();
}, []);
const loadList = async () => {
setLoading(true);
try {
const res: any = await userApi.list({
page, page_size: 10, search,
org_id: filterOrg, status: filterStatus,
});
setData(res.data || []);
setTotal(res.total || 0);
} finally {
setLoading(false);
}
};
const loadOptions = async () => {
const [orgRes, roleRes]: any = await Promise.all([
orgApi.getTree(),
roleApi.list({ page_size: 200 }),
]);
const flat = flattenOrgs(orgRes.data || []);
setOrgs(flat);
setRoles(roleRes.data || []);
};
const flattenOrgs = (nodes: any[], result: any[] = []): any[] => {
nodes.forEach((n: any) => {
result.push({ value: n.id, label: n.name });
if (n.children) flattenOrgs(n.children, result);
});
return result;
};
const handleAdd = () => {
setEditingId(null);
form.resetFields();
setModalOpen(true);
};
const handleEdit = (record: UserItem) => {
setEditingId(record.id);
form.setFieldsValue({
username: record.username,
email: record.email,
phone: record.phone,
employee_no: record.employee_no,
org_id: record.org_id,
status: record.status,
role_ids: record.roles.map((r) => r.id),
});
setModalOpen(true);
};
const handleSave = async () => {
const values = await form.validateFields();
const payload = { ...values, product_grants: [] };
if (editingId) {
await userApi.update(editingId, payload);
} else {
await userApi.create(payload);
}
message.success('保存成功');
setModalOpen(false);
loadList();
};
const handleDelete = async (id: number) => {
await userApi.delete(id);
message.success('已删除');
loadList();
};
const handleResetPwd = (id: number) => {
setEditingId(id);
pwdForm.resetFields();
setPwdModalOpen(true);
};
const confirmResetPwd = async () => {
const values = await pwdForm.validateFields();
await userApi.resetPassword(editingId!, values);
message.success('密码已重置');
setPwdModalOpen(false);
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{
title: '用户名', dataIndex: 'username', width: 120,
render: (v: string) => <><UserOutlined style={{ marginRight: 4 }} />{v}</>,
},
{ title: '工号', dataIndex: 'employee_no', width: 90 },
{ title: '部门', dataIndex: 'org_name', width: 100, render: (v: string) => v || '-' },
{
title: '角色', dataIndex: 'roles', width: 180,
render: (roles: any[]) => roles.map((r) => <Tag key={r.id} color="blue">{r.name}</Tag>),
},
{ title: '邮箱', dataIndex: 'email', width: 160, ellipsis: true },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '状态', dataIndex: 'status', width: 80,
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'red'}>{v === 'active' ? '启用' : '禁用'}</Tag>,
},
{
title: '操作', key: 'action', width: 200, fixed: 'right' as const,
render: (_: any, record: UserItem) => (
<Space>
{hasButton('user:update') && <Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>}
{hasButton('user:reset_pwd') && (
<Tooltip title="重置密码">
<Button size="small" icon={<KeyOutlined />} onClick={() => handleResetPwd(record.id)} />
</Tooltip>
)}
{hasButton('user:delete') && record.username !== 'admin' && (
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
];
return (
<Card
title={<><UserOutlined /> 用户管理</>}
extra={
<Space>
<Input.Search placeholder="搜索用户名/工号" allowClear onSearch={(v) => { setSearch(v); setPage(1); }} style={{ width: 200 }} />
<Select allowClear placeholder="部门" options={orgs} onChange={(v) => { setFilterOrg(v); setPage(1); }} style={{ width: 120 }} />
<Select allowClear placeholder="状态" options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} onChange={(v) => { setFilterStatus(v); setPage(1); }} style={{ width: 90 }} />
<Button icon={<ReloadOutlined />} onClick={loadList} />
{hasButton('user:create') && <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增用户</Button>}
</Space>
}
>
<Table
dataSource={data}
columns={columns}
rowKey="id"
loading={loading}
pagination={{ current: page, total, pageSize: 10, onChange: setPage, showTotal: (t) => `共 ${t} 条` }}
size="small"
scroll={{ x: 1200 }}
/>
{/* 新增/编辑弹窗 */}
<Modal title={editingId ? '编辑用户' : '新增用户'} open={modalOpen} onCancel={() => setModalOpen(false)} onOk={handleSave} width={640} destroyOnClose>
<Form form={form} layout="vertical" preserve={false}>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="username" label="用户名" rules={[{ required: true }]}>
<Input disabled={!!editingId} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="employee_no" label="工号">
<Input />
</Form.Item>
</Col>
</Row>
{!editingId && (
<Form.Item name="password" label="密码" rules={[{ required: true, min: 6, message: '密码至少6位 (RB-03)' }]}>
<Input.Password />
</Form.Item>
)}
<Row gutter={16}>
<Col span={12}>
<Form.Item name="email" label="邮箱">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="phone" label="手机">
<Input />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="org_id" label="所属部门" rules={[{ required: true }]}
extra="⚠️ 切换部门会清空已有角色 (RB-08)"
>
<Select options={orgs} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="status" label="状态" initialValue="active">
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
</Form.Item>
</Col>
</Row>
<Form.Item name="role_ids" label="角色" extra="只能选择本部门角色 (RB-02)">
<Select mode="multiple" placeholder="选择角色" maxTagCount={3}>
{roles.map((r: any) => (
<Select.Option key={r.id} value={r.id}>
{r.name} {r.is_global ? '(全局)' : `(${r.org_name})`}
</Select.Option>
))}
</Select>
</Form.Item>
</Form>
</Modal>
{/* 重置密码 */}
<Modal title="重置密码" open={pwdModalOpen} onCancel={() => setPwdModalOpen(false)} onOk={confirmResetPwd} destroyOnClose>
<Form form={pwdForm} layout="vertical" preserve={false}>
<Form.Item name="new_password" label="新密码" rules={[{ required: true, min: 6, message: '密码至少6位' }]}>
<Input.Password />
</Form.Item>
<Form.Item name="confirm_password" label="确认密码" rules={[{ required: true }, ({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('new_password') === value) return Promise.resolve();
return Promise.reject(new Error('两次密码不一致'));
},
})]}>
<Input.Password />
</Form.Item>
</Form>
</Modal>
</Card>
);
};
export default Users;
+245
View File
@@ -0,0 +1,245 @@
/**
* 任务创建 — 四步向导: 选Skill → 选产品 → 填参数 → 确认提交
*/
import React, { useEffect, useState } from 'react';
import { Card, Steps, Button, Space, Table, Tag, Form, Input, InputNumber, Select, Descriptions, Result, message, Row, Col, Alert } from 'antd';
import { ThunderboltOutlined, ShopOutlined, FormOutlined, CheckCircleOutlined, ArrowLeftOutlined, ArrowRightOutlined, SendOutlined } from '@ant-design/icons';
import { skillApi, productApi, taskApi } from '@/api';
import { useNavigate } from 'react-router-dom';
const TaskCreate: React.FC = () => {
const [step, setStep] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<any>(null);
const navigate = useNavigate();
// Step 1: 选Skill
const [skills, setSkills] = useState<any[]>([]);
const [selectedSkill, setSelectedSkill] = useState<any>(null);
const [skillSearch, setSkillSearch] = useState('');
// Step 2: 选产品
const [products, setProducts] = useState<any[]>([]);
const [selectedProduct, setSelectedProduct] = useState<any>(null);
const [productSearch, setProductSearch] = useState('');
// Step 3: 参数
const [form] = Form.useForm();
useEffect(() => { loadSkills(); }, [skillSearch]);
useEffect(() => { if (selectedSkill) loadProducts(); }, [selectedSkill, productSearch]);
const loadSkills = async () => {
const res: any = await skillApi.list({ page_size: 50, search: skillSearch, status: 'active' });
setSkills(res.data || []);
};
const loadProducts = async () => {
// 实际项目应根据Skill的产品范围过滤,这里简化
const res: any = await productApi.list({ page_size: 100, search: productSearch, status: 'active' });
setProducts(res.data || []);
};
const handleSubmit = async () => {
setSubmitting(true);
try {
const params = await form.validateFields().catch(() => ({}));
const res: any = await taskApi.create({
skill_id: selectedSkill.id,
product_id: selectedProduct.id,
input_params: params,
});
setResult(res.data);
setStep(4); // 完成
message.success('任务已创建');
} catch (err: any) {
message.error(err?.response?.data?.detail || '创建失败');
} finally {
setSubmitting(false);
}
};
const steps = [
{ title: '选择Skill', icon: <ThunderboltOutlined /> },
{ title: '选择产品', icon: <ShopOutlined /> },
{ title: '填写参数', icon: <FormOutlined /> },
{ title: '确认提交', icon: <CheckCircleOutlined /> },
];
// 渲染各步骤
const renderStep = () => {
switch (step) {
case 0: // 选Skill
return (
<div>
<Input.Search placeholder="搜索Skill" allowClear onSearch={setSkillSearch} style={{ width: 300, marginBottom: 16 }} />
<Table
dataSource={skills}
rowKey="id"
size="small"
pagination={false}
rowSelection={{
type: 'radio',
selectedRowKeys: selectedSkill ? [selectedSkill.id] : [],
onChange: (_, rows) => setSelectedSkill(rows[0]),
}}
onRow={(record) => ({
onClick: () => setSelectedSkill(record),
style: { cursor: 'pointer' },
})}
columns={[
{ title: 'Skill名称', dataIndex: 'name', width: 160 },
{ title: '分类', dataIndex: 'category', width: 100, render: (v: string) => v || '-' },
{
title: '执行器', dataIndex: 'executor_type', width: 90,
render: (v: string) => <Tag color={v === 'lobster' ? 'volcano' : 'geekblue'}>{v === 'lobster' ? '龙虾' : '脚本'}</Tag>,
},
{ title: '描述', dataIndex: 'description', ellipsis: true },
{ title: '成功率', dataIndex: 'success_rate', width: 80, render: (v: number) => `${v}%` },
]}
/>
</div>
);
case 1: // 选产品
return (
<div>
<Alert message={`已选Skill: ${selectedSkill?.name} [${selectedSkill?.executor_type}]`} type="info" showIcon style={{ marginBottom: 16 }} />
<Input.Search placeholder="搜索产品" allowClear onSearch={setProductSearch} style={{ width: 300, marginBottom: 16 }} />
<Table
dataSource={products}
rowKey="id"
size="small"
pagination={false}
rowSelection={{
type: 'radio',
selectedRowKeys: selectedProduct ? [selectedProduct.id] : [],
onChange: (_, rows) => setSelectedProduct(rows[0]),
}}
onRow={(record) => ({
onClick: () => setSelectedProduct(record),
style: { cursor: 'pointer' },
})}
columns={[
{ title: '产品名称', dataIndex: 'name', width: 140 },
{ title: '代号', dataIndex: 'code', width: 100, render: (v: string) => <Tag color="blue">{v}</Tag> },
{ title: '平台', dataIndex: 'platform_name', width: 100 },
{ title: '部门', dataIndex: 'org_name', width: 100 },
{ title: '内导渠道码', dataIndex: 'internal_channel_code', width: 110 },
]}
/>
</div>
);
case 2: // 填参数
return (
<div>
<Alert message={
<Space>
<span>Skill: <Tag>{selectedSkill?.name}</Tag></span>
<span>产品: <Tag color="blue">{selectedProduct?.name} ({selectedProduct?.code})</Tag></span>
</Space>
} type="info" showIcon style={{ marginBottom: 16 }} />
{selectedSkill?.input_schema ? (
<Form form={form} layout="vertical">
{Object.entries(selectedSkill.input_schema as Record<string, any>).map(([key, schema]: [string, any]) => (
<Form.Item
key={key}
name={key}
label={schema.label || key}
rules={schema.required ? [{ required: true }] : []}
>
{schema.type === 'number' ? <InputNumber style={{ width: '100%' }} /> :
schema.type === 'select' ? <Select options={(schema.options || []).map((o: string) => ({ value: o, label: o }))} /> :
schema.type === 'textarea' ? <Input.TextArea rows={3} /> :
<Input placeholder={schema.placeholder || ''} />}
</Form.Item>
))}
</Form>
) : (
<Form form={form} layout="vertical">
<Alert message="该Skill未定义输入参数Schema,可自由输入JSON参数" type="warning" style={{ marginBottom: 16 }} />
<Form.Item name="custom_params" label="自定义参数 (JSON)">
<Input.TextArea rows={6} placeholder='{"key": "value"}' />
</Form.Item>
</Form>
)}
</div>
);
case 3: // 确认
return (
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="Skill">
<Space>
{selectedSkill?.name}
<Tag color={selectedSkill?.executor_type === 'lobster' ? 'volcano' : 'geekblue'}>
{selectedSkill?.executor_type}
</Tag>
</Space>
</Descriptions.Item>
<Descriptions.Item label="产品">
{selectedProduct?.name} ({selectedProduct?.code}) — {selectedProduct?.platform_name}
</Descriptions.Item>
<Descriptions.Item label="参数">
<pre style={{ margin: 0, maxHeight: 200, overflow: 'auto' }}>
{JSON.stringify(form.getFieldsValue(), null, 2)}
</pre>
</Descriptions.Item>
</Descriptions>
);
case 4: // 完成
return (
<Result
status="success"
title="任务创建成功!"
subTitle={`任务编号: ${result?.task_no || '-'},状态: ${result?.status || 'PENDING'}`}
extra={[
<Button key="list" onClick={() => navigate('/task/list')}>查看任务列表</Button>,
<Button key="new" type="primary" onClick={() => { setStep(0); setSelectedSkill(null); setSelectedProduct(null); setResult(null); }}>
继续创建
</Button>,
]}
/>
);
}
};
return (
<Card title="任务创建">
{step < 4 && <Steps current={step} items={steps} style={{ marginBottom: 24 }} />}
{renderStep()}
{step < 4 && (
<div style={{ marginTop: 24, textAlign: 'right' }}>
<Space>
{step > 0 && <Button icon={<ArrowLeftOutlined />} onClick={() => setStep(step - 1)}>上一步</Button>}
{step < 3 && (
<Button
type="primary"
icon={<ArrowRightOutlined />}
disabled={
(step === 0 && !selectedSkill) ||
(step === 1 && !selectedProduct)
}
onClick={() => setStep(step + 1)}
>
下一步
</Button>
)}
{step === 3 && (
<Button type="primary" icon={<SendOutlined />} loading={submitting} onClick={handleSubmit}>
确认提交
</Button>
)}
</Space>
</div>
)}
</Card>
);
};
export default TaskCreate;
+182
View File
@@ -0,0 +1,182 @@
/**
* 任务列表 — 状态筛选 + 详情抽屉 + 重试
*/
import React, { useEffect, useState } from 'react';
import { Card, Table, Button, Space, Tag, Input, Select, Drawer, Descriptions, Typography, message, Popconfirm, Progress } from 'antd';
import {
ReloadOutlined, OrderedListOutlined, CheckCircleOutlined, CloseCircleOutlined,
SyncOutlined, ClockCircleOutlined, ExclamationCircleOutlined, RedoOutlined,
} from '@ant-design/icons';
import { taskApi } from '@/api';
import type { TaskItem } from '@/types';
import { useAuthStore } from '@/stores/auth';
const statusMap: Record<string, { color: string; icon: React.ReactNode; text: string }> = {
SUCCEEDED: { color: 'success', icon: <CheckCircleOutlined />, text: '成功' },
FAILED: { color: 'error', icon: <CloseCircleOutlined />, text: '失败' },
RUNNING: { color: 'processing', icon: <SyncOutlined spin />, text: '运行中' },
PENDING: { color: 'warning', icon: <ClockCircleOutlined />, text: '等待中' },
TIMEOUT: { color: 'default', icon: <ExclamationCircleOutlined />, text: '超时' },
};
const TaskList: React.FC = () => {
const [data, setData] = useState<TaskItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>();
const [loading, setLoading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [detail, setDetail] = useState<TaskItem | null>(null);
const hasButton = useAuthStore((s) => s.hasButton);
useEffect(() => { loadList(); }, [page, search, filterStatus]);
// 自动刷新运行中任务
useEffect(() => {
const hasRunning = data.some((t) => t.status === 'RUNNING' || t.status === 'PENDING');
if (!hasRunning) return;
const timer = setInterval(loadList, 5000);
return () => clearInterval(timer);
}, [data]);
const loadList = async () => {
setLoading(true);
try {
const res: any = await taskApi.list({ page, page_size: 10, search, status: filterStatus });
setData(res.data || []);
setTotal(res.total || 0);
} finally { setLoading(false); }
};
const handleDetail = async (id: number) => {
const res: any = await taskApi.getDetail(id);
setDetail(res.data);
setDrawerOpen(true);
};
const handleRetry = async (id: number) => {
await taskApi.retry(id);
message.success('已重新提交执行');
loadList();
};
const columns = [
{
title: '任务编号', dataIndex: 'task_no', width: 200,
render: (v: string, r: TaskItem) => <Button type="link" size="small" onClick={() => handleDetail(r.id)}>{v}</Button>,
},
{ title: 'Skill', dataIndex: 'skill_name', width: 130, render: (v: string) => v || '-' },
{ title: '产品', dataIndex: 'product_name', width: 120, render: (v: string) => v || '-' },
{ title: '创建人', dataIndex: 'creator_name', width: 90 },
{
title: '执行器', dataIndex: 'executor_type', width: 80,
render: (v: string) => <Tag color={v === 'lobster' ? 'volcano' : 'geekblue'}>{v === 'lobster' ? '龙虾' : '脚本'}</Tag>,
},
{
title: '状态', dataIndex: 'status', width: 100,
render: (v: string) => {
const cfg = statusMap[v] || { color: 'default', icon: null, text: v };
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
},
},
{
title: '耗时', dataIndex: 'duration_ms', width: 90,
render: (v: number) => v ? `${(v / 1000).toFixed(2)}s` : '-',
},
{ title: '创建时间', dataIndex: 'created_at', width: 160, render: (v: string) => v?.slice(0, 19).replace('T', ' ') },
{
title: '操作', key: 'action', width: 120, fixed: 'right' as const,
render: (_: any, r: TaskItem) => (
<Space>
<Button size="small" type="link" onClick={() => handleDetail(r.id)}>详情</Button>
{hasButton('task:retry') && (r.status === 'FAILED' || r.status === 'TIMEOUT') && (
<Popconfirm title="确认重试?" onConfirm={() => handleRetry(r.id)}>
<Button size="small" type="link" icon={<RedoOutlined />}>重试</Button>
</Popconfirm>
)}
</Space>
),
},
];
return (
<Card
title={<><OrderedListOutlined /> 任务列表</>}
extra={
<Space>
<Input.Search placeholder="搜索任务编号" allowClear onSearch={(v) => { setSearch(v); setPage(1); }} style={{ width: 200 }} />
<Select allowClear placeholder="状态" onChange={(v) => { setFilterStatus(v); setPage(1); }} style={{ width: 110 }}
options={Object.entries(statusMap).map(([k, v]) => ({ value: k, label: v.text }))} />
<Button icon={<ReloadOutlined />} onClick={loadList} />
</Space>
}
>
<Table dataSource={data} columns={columns} rowKey="id" loading={loading}
pagination={{ current: page, total, pageSize: 10, onChange: setPage, showTotal: (t) => `共 ${t} 条` }}
size="small" scroll={{ x: 1300 }} />
{/* 详情抽屉 */}
<Drawer title={`任务详情 — ${detail?.task_no || ''}`} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={600}>
{detail && (
<>
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="任务编号" span={2}>{detail.task_no}</Descriptions.Item>
<Descriptions.Item label="Skill">{detail.skill_name}</Descriptions.Item>
<Descriptions.Item label="产品">{detail.product_name}</Descriptions.Item>
<Descriptions.Item label="创建人">{detail.creator_name}</Descriptions.Item>
<Descriptions.Item label="执行器">
<Tag color={detail.executor_type === 'lobster' ? 'volcano' : 'geekblue'}>{detail.executor_type}</Tag>
</Descriptions.Item>
<Descriptions.Item label="状态">
{(() => { const cfg = statusMap[detail.status]; return <Tag color={cfg?.color} icon={cfg?.icon}>{cfg?.text || detail.status}</Tag>; })()}
</Descriptions.Item>
<Descriptions.Item label="耗时">{detail.duration_ms ? `${(detail.duration_ms / 1000).toFixed(2)}s` : '-'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{detail.created_at?.slice(0, 19).replace('T', ' ')}</Descriptions.Item>
<Descriptions.Item label="开始时间">{detail.started_at?.slice(0, 19).replace('T', ' ') || '-'}</Descriptions.Item>
<Descriptions.Item label="完成时间" span={2}>{detail.completed_at?.slice(0, 19).replace('T', ' ') || '-'}</Descriptions.Item>
</Descriptions>
{detail.error_message && (
<>
<h4 style={{ marginTop: 16, color: '#ff4d4f' }}>错误信息</h4>
<Typography.Paragraph type="danger" style={{ background: '#fff2f0', padding: 12, borderRadius: 4 }}>
{detail.error_message}
</Typography.Paragraph>
</>
)}
{detail.input_params && (
<>
<h4 style={{ marginTop: 16 }}>输入参数</h4>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, maxHeight: 200, overflow: 'auto', fontSize: 12 }}>
{JSON.stringify(detail.input_params, null, 2)}
</pre>
</>
)}
{detail.output_data && (
<>
<h4 style={{ marginTop: 16 }}>输出数据</h4>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, maxHeight: 200, overflow: 'auto', fontSize: 12 }}>
{JSON.stringify(detail.output_data, null, 2)}
</pre>
</>
)}
{detail.execution_log && (
<>
<h4 style={{ marginTop: 16 }}>执行日志</h4>
<pre style={{ background: '#1a1a1a', color: '#0f0', padding: 12, borderRadius: 4, maxHeight: 300, overflow: 'auto', fontSize: 12 }}>
{detail.execution_log}
</pre>
</>
)}
</>
)}
</Drawer>
</Card>
);
};
export default TaskList;
+79
View File
@@ -0,0 +1,79 @@
/**
* 路由配置 — 动态菜单 + 权限守卫
*/
import React, { lazy, Suspense } from 'react';
import { Navigate, RouteObject } from 'react-router-dom';
import { Spin } from 'antd';
// 懒加载
const Login = lazy(() => import('@/pages/Login'));
const MainLayout = lazy(() => import('@/components/Layout/MainLayout'));
const Dashboard = lazy(() => import('@/pages/Dashboard'));
const Orgs = lazy(() => import('@/pages/System/Orgs'));
const Roles = lazy(() => import('@/pages/System/Roles'));
const Users = lazy(() => import('@/pages/System/Users'));
const Menus = lazy(() => import('@/pages/System/Menus'));
const Products = lazy(() => import('@/pages/Product/Products'));
const Channels = lazy(() => import('@/pages/Product/Channels'));
const PlatformSuite = lazy(() => import('@/pages/Product/PlatformSuite'));
const PlatformAccount = lazy(() => import('@/pages/PlatformAccount'));
const SkillList = lazy(() => import('@/pages/Skill/SkillList'));
const CallLogs = lazy(() => import('@/pages/Skill/CallLogs'));
const TaskCreate = lazy(() => import('@/pages/Task/Create'));
const TaskList = lazy(() => import('@/pages/Task/List'));
const Executor = lazy(() => import('@/pages/Settings/Executor'));
const ScriptTemplate = lazy(() => import('@/pages/Settings/ScriptTemplate'));
const Schedule = lazy(() => import('@/pages/Settings/Schedule'));
const OpLog = lazy(() => import('@/pages/Logs/OpLog'));
const Stats = lazy(() => import('@/pages/Logs/Stats'));
const Loading = () => (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', padding: 80 }}>
<Spin size="large" />
</div>
);
const Wrap = (C: React.LazyExoticComponent<any>) => (
<Suspense fallback={<Loading />}>
<C />
</Suspense>
);
export const routes: RouteObject[] = [
{
path: '/login',
element: Wrap(Login),
},
{
path: '/',
element: Wrap(MainLayout),
children: [
{ index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: Wrap(Dashboard) },
// 系统管理
{ path: 'system/organizations', element: Wrap(Orgs) },
{ path: 'system/roles', element: Wrap(Roles) },
{ path: 'system/users', element: Wrap(Users) },
{ path: 'system/menus', element: Wrap(Menus) },
// 产品管理
{ path: 'product/list', element: Wrap(Products) },
{ path: 'product/channels', element: Wrap(Channels) },
{ path: 'product/platforms', element: Wrap(PlatformSuite) },
{ path: 'product/accounts', element: Wrap(PlatformAccount) },
// Skill
{ path: 'skill/list', element: Wrap(SkillList) },
{ path: 'skill/call-logs', element: Wrap(CallLogs) },
// 任务
{ path: 'task/create', element: Wrap(TaskCreate) },
{ path: 'task/list', element: Wrap(TaskList) },
// 运维
{ path: 'settings/executor', element: Wrap(Executor) },
{ path: 'settings/script-templates', element: Wrap(ScriptTemplate) },
{ path: 'settings/schedules', element: Wrap(Schedule) },
// 日志
{ path: 'logs/operation', element: Wrap(OpLog) },
{ path: 'logs/stats', element: Wrap(Stats) },
],
},
{ path: '*', element: <Navigate to="/dashboard" replace /> },
];
+69
View File
@@ -0,0 +1,69 @@
/**
* Zustand Store — 用户状态 + 权限
*/
import { create } from 'zustand';
import { authApi } from '@/api';
import type { UserInfo, MenuItem } from '@/types';
interface AuthState {
token: string | null;
userInfo: UserInfo | null;
menuTree: MenuItem[];
buttons: string[];
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
fetchUserInfo: () => Promise<void>;
hasButton: (code: string) => boolean;
}
export const useAuthStore = create<AuthState>((set, get) => ({
token: localStorage.getItem('access_token'),
userInfo: null,
menuTree: [],
buttons: [],
loading: false,
login: async (username, password) => {
set({ loading: true });
try {
const res: any = await authApi.login({ username, password });
const { access_token, refresh_token } = res.data;
localStorage.setItem('access_token', access_token);
localStorage.setItem('refresh_token', refresh_token);
set({ token: access_token });
await get().fetchUserInfo();
} finally {
set({ loading: false });
}
},
logout: () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
set({ token: null, userInfo: null, menuTree: [], buttons: [] });
window.location.href = '/login';
},
fetchUserInfo: async () => {
try {
const res: any = await authApi.getUserInfo();
const info: UserInfo = res.data;
set({
userInfo: info,
menuTree: info.menus || [],
buttons: info.buttons || [],
});
} catch {
get().logout();
}
},
hasButton: (code: string) => {
const { buttons, userInfo } = get();
// 超级管理员跳过
if (userInfo?.roles.some((r) => r.code === 'super_admin')) return true;
return buttons.includes(code);
},
}));
+280
View File
@@ -0,0 +1,280 @@
/**
* 全局类型定义
*/
// API 统一响应
export interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
export interface PageData<T = any> {
code: number;
message: string;
data: T[];
total: number;
page: number;
page_size: number;
}
// 用户信息
export interface UserInfo {
id: number;
username: string;
email?: string;
phone?: string;
employee_no?: string;
org_id?: number;
org_name?: string;
status: string;
roles: Array<{ id: number; name: string; code: string }>;
menus: MenuItem[];
buttons: string[];
data_scope: number[];
skill_scope: number[];
}
// 菜单
export interface MenuItem {
id: number;
name: string;
menu_type: string;
path: string;
icon?: string;
component?: string;
sort_order: number;
visible: boolean;
status: string;
buttons?: MenuButton[];
children?: MenuItem[];
}
export interface MenuButton {
id: number;
name: string;
code: string;
type: string;
}
// 组织
export interface OrgNode {
id: number;
name: string;
org_type: string;
parent_id?: number;
sort_order: number;
status: string;
member_count: number;
children?: OrgNode[];
}
// 角色
export interface RoleItem {
id: number;
name: string;
code: string;
org_id?: number;
org_name?: string;
is_global: boolean;
description?: string;
status: string;
menu_count?: number;
product_count?: number;
skill_count?: number;
member_count?: number;
created_at: string;
}
// 用户
export interface UserItem {
id: number;
username: string;
email?: string;
phone?: string;
employee_no?: string;
org_id?: number;
org_name?: string;
status: string;
roles: Array<{ id: number; name: string; code: string }>;
platform_account_count?: number;
grant_count?: number;
created_at: string;
}
// 产品
export interface ProductItem {
id: number;
name: string;
code: string;
platform_id: number;
platform_name?: string;
org_id?: number;
org_name?: string;
team_id?: number;
leader_id?: number;
internal_channel_code: string;
natural_channel_code: string;
status: string;
created_at: string;
}
// 平台
export interface PlatformItem {
id: number;
name: string;
address: string;
icon?: string;
remark?: string;
status: string;
product_count: number;
account_count: number;
created_at: string;
}
// 渠道码
export interface ChannelCodeItem {
id: number;
code: string;
name: string;
channel_type: string;
status: string;
remark?: string;
product_count: number;
created_at: string;
}
// Skill
export interface SkillItem {
id: number;
name: string;
category?: string;
executor_type: string;
description?: string;
lobster_api_url?: string;
lobster_agent_id?: string;
script_template_id?: number;
script_timeout?: number;
input_schema?: Record<string, any>;
param_mapping?: Record<string, any>;
product_count: number;
call_count: number;
success_rate: number;
status: string;
created_at: string;
}
// 脚本模板
export interface ScriptTemplateItem {
id: number;
name: string;
script_type: string;
description?: string;
code: string;
env_vars?: Record<string, any>;
status: string;
skill_count: number;
created_at: string;
updated_at: string;
}
// 任务
export interface TaskItem {
id: number;
task_no: string;
skill_id: number;
skill_name?: string;
product_id: number;
product_name?: string;
creator_id: number;
creator_name?: string;
executor_type: string;
input_params?: Record<string, any>;
output_data?: Record<string, any>;
execution_log?: string;
status: string;
duration_ms?: number;
error_message?: string;
created_at: string;
started_at?: string;
completed_at?: string;
}
// 定时任务
export interface ScheduleTaskItem {
id: number;
name: string;
skill_id: number;
skill_name?: string;
product_id?: number;
product_name?: string;
cron_expression: string;
exec_params?: Record<string, any>;
retry_policy: string;
timeout: number;
status: string;
last_run_at?: string;
next_run_at?: string;
created_at: string;
}
// 平台账号
export interface PlatformAccountItem {
id: number;
platform_id: number;
platform_name?: string;
platform_address?: string;
product_id: number;
product_code?: string;
product_name?: string;
owner_id: number;
owner_name?: string;
login_account: string;
login_password_masked: string;
google_key_masked?: string;
status: string;
created_at: string;
}
// 操作日志
export interface OperationLogItem {
id: number;
log_id: string;
user_id?: number;
username: string;
module: string;
action: string;
description?: string;
request_params?: Record<string, any>;
before_data?: Record<string, any>;
after_data?: Record<string, any>;
ip_address?: string;
created_at: string;
}
// 执行器配置
export interface ExecutorConfigItem {
id: number;
executor_type: string;
name: string;
config_data?: Record<string, any>;
max_concurrency: number;
current_load: number;
status: string;
health_data?: Record<string, any>;
updated_at: string;
}
// 仪表盘
export interface DashboardData {
task_total: number;
task_success: number;
task_failed: number;
task_running: number;
task_pending: number;
task_success_rate: number;
skill_total: number;
call_total: number;
call_success_rate: number;
today_tasks: number;
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
});
+3454
View File
File diff suppressed because it is too large Load Diff
+176
View File
File diff suppressed because one or more lines are too long