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:
@@ -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** 执行器类型互斥
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
# app package
|
||||
@@ -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
|
||||
@@ -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),
|
||||
))
|
||||
@@ -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)
|
||||
@@ -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="✅ 已删除")
|
||||
@@ -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="已删除")
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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="✅ 绑定成功")
|
||||
@@ -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="已删除")
|
||||
@@ -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)
|
||||
@@ -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="✅ 已重新提交执行")
|
||||
@@ -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="✅ 密码已重置")
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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:]
|
||||
@@ -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任务系统"}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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健康数据")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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列表
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"] = []
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# scripts package
|
||||
@@ -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)")
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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;"]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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) 获取 import os, json params = json.loads(os.environ.get('TASK_INPUT', '{}')) 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 /> },
|
||||
];
|
||||
@@ -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);
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
+176
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user