Compare commits
11
Commits
6f64eecb43
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33f4ce42e4 | ||
|
|
4215137f26 | ||
|
|
78df16f770 | ||
|
|
4cdccf904a | ||
|
|
d82e1c9946 | ||
|
|
e8f931b4a1 | ||
|
|
c54cc82612 | ||
|
|
56dfa5b418 | ||
|
|
ad51078975 | ||
|
|
1e539b1c25 | ||
|
|
b04c0ce321 |
+266
@@ -0,0 +1,266 @@
|
||||
<picture src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python" /><picture src="https://img.shields.io/badge/vue-3.4%2B-4FC08D" alt="Vue" /><picture src="https://img.shields.io/badge/license-MIT-green" alt="License" />
|
||||
|
||||
# TaskPulse — AI Agent Scheduled Task Monitor
|
||||
|
||||
> **Let your AI agents self-report. You just glance at the dashboard.**
|
||||
|
||||
TaskPulse is a tracking and monitoring platform designed for AI agents' scheduled tasks. Unlike traditional CRUD-heavy admin panels, TaskPulse is **AI-native**: give your agents an API spec, and they autonomously register themselves, report execution results, and update their status — no manual form-filling required.
|
||||
|
||||
|
||||
[📖 中文文档](./README.md)
|
||||
|
||||
---
|
||||
|
||||
## Philosophy
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ TaskPulse Dashboard│
|
||||
│ Unified Status View│
|
||||
└──────┬──────────┬───┘
|
||||
│ │
|
||||
┌─────────────────┘ └─────────────────┐
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌──────────────────────┐
|
||||
│ AI Agent A │ register tasks → execute │ AI Agent B │
|
||||
│ ├─ data sync │ ← report result + log │ ├─ order fetch │
|
||||
│ └─ report gen │ │ └─ inventory sync │
|
||||
└─────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
**AI-Native by Design**
|
||||
|
||||
Traditional schedulers require humans to fill forms and manage cron expressions. But AI agents can read API docs and act autonomously:
|
||||
|
||||
1. **Self-Register** — One API call to register both the agent and all its tasks
|
||||
2. **Report Results** — After each execution, POST back the result with logs and duration
|
||||
3. **Auto-Alert** — If a task misses its window, the system alerts automatically
|
||||
|
||||
The only thing humans need to do: **open the dashboard and see the big picture.**
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### 🤖 AI-Native Onboarding
|
||||
- **Batch Registration**: `POST /api/agents/register-with-tasks` — agent + tasks in one shot
|
||||
- **API Key Auth**: Each agent gets its own key for reporting
|
||||
- **Re-registration**: Deleted agents can re-register anytime
|
||||
- **Agent Avatars**: First-letter + deterministic color avatars for quick visual identification
|
||||
|
||||
### 📊 Unified Dashboard
|
||||
- **Summary Cards**: agent count, task count, 24h executions, pending alerts
|
||||
- **Global Task View**: all tasks from all agents in one place
|
||||
- **Task Tags**: label tasks (e.g. "data-sync", "critical"), filter by tags
|
||||
- **Human-Readable Cron**: `*/5 * * * *` → "Every 5 minutes"
|
||||
- **Localized Time**: all timestamps auto-converted from UTC to browser timezone
|
||||
|
||||
### ⏱ Execution Tracking
|
||||
- **Live Reporting**: agents POST results immediately after execution
|
||||
- **Execution History**: full timeline per task, paginated
|
||||
- **Log Viewer**: dark terminal-style viewer for debugging
|
||||
- **Search & Filter**: by task name, tags, and status
|
||||
|
||||
### 🚨 Timeout Alerts & Notifications
|
||||
- **Auto-Scan**: background scheduler checks overdue tasks every 60s
|
||||
- **Grace Window**: per-task tolerance to avoid false alerts
|
||||
- **Multi-Channel**: Feishu (Lark) webhook, email SMTP, generic webhook
|
||||
- **Alert Acknowledgment**: mark alerts as handled for closed-loop management
|
||||
|
||||
### 🎨 Modern UI
|
||||
- **Dark Theme**: gradient backgrounds + neon accents
|
||||
- **Full-Width Layout**: maximizes screen usage
|
||||
- **Responsive**: adapts to different screen sizes
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Backend | Python FastAPI (async) |
|
||||
| Production Server | Gunicorn + Uvicorn Worker |
|
||||
| Frontend | Vue 3 + Element Plus |
|
||||
| Build Tool | Vite |
|
||||
| Database | MySQL 8.0 |
|
||||
| Validation | Pydantic v2 |
|
||||
| ORM | SQLAlchemy 2.0 (async) |
|
||||
| Auth | JWT (python-jose) + bcrypt |
|
||||
| Scheduler | APScheduler (async loop) |
|
||||
| Deployment | Monolithic (FastAPI serves SPA) |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
# 1. Clone
|
||||
git clone https://github.com/oventh/TaskPulse.git
|
||||
cd TaskPulse
|
||||
|
||||
# 2. Backend
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # edit .env with your DB config
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
|
||||
# 3. Frontend (new terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:3000
|
||||
```
|
||||
|
||||
> Frontend dev server proxies `/api` to `localhost:8000`.
|
||||
|
||||
### Production Deployment
|
||||
|
||||
```bash
|
||||
# 1. Build frontend
|
||||
cd frontend && npm install && npm run build
|
||||
|
||||
# 2. Deploy to server
|
||||
rsync -avz --exclude='.venv' --exclude='node_modules' --exclude='.git' \
|
||||
./ user@server:/home/app/taskpulse/
|
||||
|
||||
# 3. Install deps + start with Gunicorn
|
||||
cd /home/app/taskpulse/backend
|
||||
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/gunicorn app.main:app \
|
||||
-k uvicorn.workers.UvicornWorker \
|
||||
-b 0.0.0.0:8000 -w 2 --preload --daemon
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Agent Integration
|
||||
|
||||
Let your AI agent self-register in 3 steps:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE = "https://your-domain.com"
|
||||
|
||||
# 1. Register agent + all its tasks (one API call)
|
||||
resp = requests.post(f"{BASE}/api/agents/register-with-tasks", json={
|
||||
"name": "my-agent",
|
||||
"description": "My AI Agent",
|
||||
"tasks": [
|
||||
{"name": "sync-data", "cron_expression": "*/5 * * * *"},
|
||||
{"name": "daily-report", "cron_expression": "0 9 * * *"}
|
||||
]
|
||||
})
|
||||
data = resp.json()
|
||||
API_KEY = data["agent"]["api_key"] # ← save this
|
||||
AGENT_ID = data["agent"]["id"]
|
||||
|
||||
# 2. Report execution result after each run
|
||||
requests.post(f"{BASE}/api/tasks/{TASK_ID}/executions",
|
||||
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||
json={"status": "success", "duration_ms": 1500, "log": "Task completed"})
|
||||
```
|
||||
|
||||
> For curl, batch registration, and more examples, see the Chinese doc [AGENT_GUIDE.md](./AGENT_GUIDE.md).
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TASKPULSE_DB_HOST` | localhost | MySQL host |
|
||||
| `TASKPULSE_DB_PORT` | 3306 | MySQL port |
|
||||
| `TASKPULSE_DB_USER` | dbuser | Database user |
|
||||
| `TASKPULSE_DB_PASSWORD` | - | Database password |
|
||||
| `TASKPULSE_DB_NAME` | taskpulse | Database name |
|
||||
| `TASKPULSE_DEBUG` | true | Debug mode (false in production) |
|
||||
| `TASKPULSE_SECRET_KEY` | - | JWT signing key |
|
||||
| `TASKPULSE_SMTP_*` | - | Email notification config |
|
||||
|
||||
See `backend/.env.example` for the full list.
|
||||
|
||||
---
|
||||
|
||||
## API Overview
|
||||
|
||||
### Agents
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/agents` | Register an agent (returns API Key) |
|
||||
| **POST** | **`/api/agents/register-with-tasks`** | **🌟 Batch register agent + tasks** |
|
||||
| GET | `/api/agents` | List all agents |
|
||||
| PUT | `/api/agents/{id}` | Update agent name/description |
|
||||
| DELETE | `/api/agents/{id}` | Delete agent (cascades to tasks) |
|
||||
|
||||
### Tasks
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/tasks?agent_id=X` | Create a scheduled task |
|
||||
| GET | `/api/tasks` | List tasks (`?q=&tags=&status=` filters) |
|
||||
| PUT | `/api/tasks/{id}` | Update task name/tags/description |
|
||||
| DELETE | `/api/tasks/{id}` | Delete a task |
|
||||
|
||||
### Execution Reporting
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/tasks/{id}/executions` | **Agent reports execution result** |
|
||||
| GET | `/api/tasks/{id}/executions` | View execution history (paginated) |
|
||||
| GET | `/api/tasks/{id}/executions/{eid}` | View single execution detail |
|
||||
|
||||
### Dashboard & Config
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/dashboard/summary` | Dashboard summary stats |
|
||||
| GET | `/api/dashboard/tasks` | Dashboard full task view |
|
||||
| GET | `/api/system/config` | Get system config |
|
||||
| PUT | `/api/system/config` | Update system config (Base URL) |
|
||||
|
||||
### Alerts & Notifications
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/alerts` | List alerts |
|
||||
| POST | `/api/alerts/{id}/acknowledge` | Acknowledge an alert |
|
||||
| POST | `/api/notification-channels` | Add notification channel |
|
||||
|
||||
Full OpenAPI docs at `/docs` (Swagger UI).
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
taskpulse/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # FastAPI entry + SPA static files + lifecycle
|
||||
│ │ ├── config.py # Environment variables (pydantic-settings)
|
||||
│ │ ├── database.py # Async MySQL connection
|
||||
│ │ ├── models/ # SQLAlchemy models (6 tables)
|
||||
│ │ ├── schemas/ # Pydantic request/response models
|
||||
│ │ ├── api/ # REST API routes (7 modules)
|
||||
│ │ └── services/ # Business logic layer
|
||||
│ │ ├── scheduler.py # ⏰ Background scheduler (timeout detection)
|
||||
│ │ └── notification.py # 📢 Feishu/Email/Webhook sender
|
||||
│ ├── alembic/ # DB migrations
|
||||
│ └── requirements.txt
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── views/ # 6 pages (dark theme)
|
||||
│ │ ├── router/ # Route guards + login redirect
|
||||
│ │ ├── api/ # Axios client + 401 interceptor
|
||||
│ │ └── assets/ # Global styles + Element Plus overrides
|
||||
│ └── package.json
|
||||
├── AGENT_GUIDE.md # Agent API guide (Chinese)
|
||||
├── README.md # Documentation (Chinese)
|
||||
└── README.en.md # Documentation (English)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License © 2026
|
||||
@@ -1,28 +1,96 @@
|
||||
# TaskPulse — AI Agent Task Monitor
|
||||
<picture src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python" /><picture src="https://img.shields.io/badge/vue-3.4%2B-4FC08D" alt="Vue" /><picture src="https://img.shields.io/badge/license-MIT-green" alt="License" />
|
||||
|
||||
统一看板,监控 AI Agent 定时任务的运行状态、执行历史,并在任务超时未执行时发送告警通知。
|
||||
# TaskPulse — AI Agent 定时任务监控系统
|
||||
|
||||
**线上地址**: https://task.pags.cn
|
||||
> **让 AI Agent 自己汇报工作,你只需要打开看板看一眼。**
|
||||
|
||||
TaskPulse 是一个面向 AI Agent 的定时任务跟踪与监控平台。它不是传统的"你去填表创建任务"的后台管理系统,而是**给 AI Agent 一份 API 说明书,让 Agent 自主注册、自动汇报**——你只需要在统一的 Dashboard 上看全局状态。
|
||||
## 系统截图
|
||||
<img src="https://pub-44c5bd2a850e4bc7aab6f5f8701493fd.r2.dev/images/111.png"></img>
|
||||
|
||||
[🌐 English](./README.en.md)
|
||||
|
||||
---
|
||||
|
||||
## 设计理念
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ TaskPulse Dashboard│
|
||||
│ 统一状态看板 │
|
||||
└──────┬──────────┬───┘
|
||||
│ │
|
||||
┌─────────────────┘ └─────────────────┐
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌──────────────────────┐
|
||||
│ AI Agent A │ 注册任务 → 定时执行 │ AI Agent B │
|
||||
│ └─ 数据同步 │ ← 汇报结果 + 日志 │ └─ 订单采集 │
|
||||
│ └─ 报表生成 │ │ └─ 库存同步 │
|
||||
└─────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
**核心理念:AI 原生接入**
|
||||
|
||||
传统定时任务管理需要用户手动填写 cron 表达式、配置参数。但 AI Agent 本身就具备理解和执行能力——只需给 Agent 一份 API 文档,它就能:
|
||||
|
||||
1. **自主注册** — 通过一条 API 把自己和所有定时任务登记到系统
|
||||
2. **按时汇报** — 每次任务执行完毕,主动 POST 结果(含日志、耗时、状态)
|
||||
3. **自动纠错** — 如果某个任务到点没执行,系统自动告警
|
||||
|
||||
人类要做的只有一件事:**打开看板,看一眼全局状态。**
|
||||
|
||||
---
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **AI 原生接入** — AI Agent 通过 API 一次性注册自己 + 所有定时任务,无需手动填写表单
|
||||
- **统一状态看板** — 所有定时任务的运行状态、最近运行时间、结果一目了然
|
||||
- **执行汇报** — Agent 执行完任务后主动汇报结果,含详细日志
|
||||
- **超时告警** — 任务未按时执行时自动告警
|
||||
- **多渠道通知** — 支持飞书 Webhook、邮件、通用 Webhook
|
||||
- **Base URL 可配置** — 系统设置页面可配置公网访问地址,所有接入指令自动更新
|
||||
### 🤖 AI 原生接入
|
||||
- **一次性批量注册**:`POST /api/agents/register-with-tasks` — Agent 在一分钟内完成注册 + 所有任务登记
|
||||
- **API Key 鉴权**:每个 Agent 独立密钥,汇报时自动校验
|
||||
- **无缝重注册**:删除后可重新注册,获取新 Key 继续工作
|
||||
- **Agent 头像**:名称首字 + 确定性色彩头像,多 Agent 一目了然
|
||||
|
||||
### 📊 统一状态看板
|
||||
- **概览卡片**:Agent 数量、任务总数、24h 执行量、待处理告警
|
||||
- **全任务视图**:所有 Agent 的任务集中展示,状态、最近运行、结果一目了然
|
||||
- **任务标签**:可对任务打标签(如"数据同步"、"重要"),按标签筛选
|
||||
- **Cron 人类可读**:`*/5 * * * *` 自动显示为 "每5分钟"
|
||||
- **时间本地化**:所有时间自动从 UTC 转为浏览器本地时区
|
||||
|
||||
### ⏱ 执行汇报与追踪
|
||||
- **实时汇报**:Agent 执行完任务后 POST 结果,系统记录执行日志
|
||||
- **执行历史**:每个任务都有完整的时间线,支持分页查看
|
||||
- **日志查看器**:暗色终端风格,方便排查问题
|
||||
- **搜索过滤**:按任务名称、标签、状态快速筛选
|
||||
|
||||
### 🚨 超时告警与通知
|
||||
- **自动检测**:后台调度器每 60 秒扫描未按时执行的任务
|
||||
- **宽容窗口**:每个任务可单独配置容忍时间,避免误报
|
||||
- **多渠道通知**:飞书 Webhook、邮件 SMTP、通用 Webhook
|
||||
- **告警确认**:已处理的告警可标记确认,闭环管理
|
||||
|
||||
### 🎨 现代化界面
|
||||
- **暗色主题**:深色渐变背景 + 霓虹色点缀,降低视觉疲劳
|
||||
- **全宽布局**:充分利用屏幕空间
|
||||
- **响应式**:适配不同分辨率
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层 | 技术 |
|
||||
|---|---|
|
||||
| 后端 | Python FastAPI (异步) + Gunicorn (生产) |
|
||||
| 前端 | Vue3 + Element Plus (暗色主题) |
|
||||
| 后端框架 | Python FastAPI (异步) |
|
||||
| 生产服务 | Gunicorn + Uvicorn Worker |
|
||||
| 前端框架 | Vue 3 + Element Plus |
|
||||
| 构建工具 | Vite |
|
||||
| 数据库 | MySQL 8.0 |
|
||||
| 部署 | 单体应用(FastAPI serve Vue3 构建产物) |
|
||||
| 数据校验 | Pydantic v2 |
|
||||
| ORM | SQLAlchemy 2.0 (异步) |
|
||||
| 认证 | JWT (python-jose) + bcrypt |
|
||||
| 定时检测 | APScheduler (异步) |
|
||||
| 部署形态 | 单体应用 (FastAPI serve 前端静态文件) |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -30,128 +98,138 @@
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone https://gitea.tatta.cn/Tatta/TaskPulse.git
|
||||
git clone https://github.com/oventh/TaskPulse.git
|
||||
cd TaskPulse
|
||||
|
||||
# 2. 后端 - 创建虚拟环境并安装依赖
|
||||
# 2. 后端
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
# Windows: .venv\Scripts\activate
|
||||
# Linux: source .venv/bin/activate
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 3. 复制环境变量并修改数据库配置
|
||||
cp .env.example .env
|
||||
# 编辑 .env,配置 MySQL 连接信息
|
||||
|
||||
# 4. 启动后端开发服务器
|
||||
cp .env.example .env # 编辑 .env 配置数据库
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
|
||||
# 5. 前端(新开终端)
|
||||
# 3. 前端(新终端)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # 访问 http://localhost:3000
|
||||
npm run dev # http://localhost:3000
|
||||
```
|
||||
|
||||
> 前端 dev server 自动代理 `/api` 请求到 `localhost:8000`
|
||||
> 前端 `npm run dev` 会代理 `/api` 到 `localhost:8000`
|
||||
|
||||
### 生产部署
|
||||
|
||||
```bash
|
||||
# 1. 构建前端
|
||||
cd frontend
|
||||
npm install && npm run build
|
||||
cd frontend && npm install && npm run build
|
||||
|
||||
# 2. 部署到服务器
|
||||
# 将整个项目复制到服务器(排除 .venv 和 node_modules)
|
||||
rsync -avz --exclude='.venv' --exclude='node_modules' --exclude='.git' \
|
||||
./ user@server:/home/openclaw/app/taskpulse/
|
||||
./ user@server:/home/app/taskpulse/
|
||||
|
||||
# 3. 在服务器上创建虚拟环境并安装依赖
|
||||
cd /home/openclaw/app/taskpulse/backend
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
|
||||
# 4. 使用 Gunicorn 启动
|
||||
# 3. 安装依赖 + Gunicorn 启动
|
||||
cd /home/app/taskpulse/backend
|
||||
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/gunicorn app.main:app \
|
||||
-k uvicorn.workers.UvicornWorker \
|
||||
-b 0.0.0.0:8000 \
|
||||
-w 2 \
|
||||
--access-logfile /var/log/taskpulse/access.log \
|
||||
--error-logfile /var/log/taskpulse/error.log \
|
||||
--daemon
|
||||
-b 0.0.0.0:8000 -w 2 --preload --daemon
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
---
|
||||
|
||||
复制 `backend/.env.example` 为 `backend/.env` 并按需修改:
|
||||
## Agent 接入指南
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| TASKPULSE_DB_HOST | localhost | MySQL 主机 |
|
||||
| TASKPULSE_DB_PORT | 3306 | MySQL 端口 |
|
||||
| TASKPULSE_DB_USER | dbuser | 数据库用户 |
|
||||
| TASKPULSE_DB_PASSWORD | - | 数据库密码 |
|
||||
| TASKPULSE_DB_NAME | taskpulse | 数据库名 |
|
||||
| TASKPULSE_DEBUG | true | 调试模式(生产环境设为 false) |
|
||||
| TASKPULSE_FEISHU_WEBHOOK_URL | (空) | 飞书 Webhook 地址 |
|
||||
| TASKPULSE_SMTP_HOST | (空) | SMTP 服务器 |
|
||||
| TASKPULSE_SMTP_PORT | 587 | SMTP 端口 |
|
||||
| TASKPULSE_SMTP_USER | (空) | SMTP 用户 |
|
||||
| TASKPULSE_SMTP_PASSWORD | (空) | SMTP 密码 |
|
||||
|
||||
## Agent 接入
|
||||
|
||||
详细接入说明见 [AGENT_GUIDE.md](./AGENT_GUIDE.md)。
|
||||
|
||||
### 最小接入示例
|
||||
让 AI Agent 自动接入只需 3 步:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE = "https://task.pags.cn" # 替换为你的实际地址
|
||||
BASE = "https://your-domain.com" # 替换为你的实际地址
|
||||
|
||||
# 1. AI Agent 注册自己 + 所有定时任务(一次性)
|
||||
# 1. Agent 一次性注册自己 + 所有定时任务
|
||||
resp = requests.post(f"{BASE}/api/agents/register-with-tasks", json={
|
||||
"name": "my-agent",
|
||||
"description": "我的 AI Agent",
|
||||
"tasks": [
|
||||
{"name": "sync-data", "cron_expression": "*/5 * * * *"},
|
||||
{"name": "daily-report", "cron_expression": "0 9 * * *"}
|
||||
{"name": "sync-data", "cron_expression": "*/5 * * * *"},
|
||||
{"name": "daily-report", "cron_expression": "0 9 * * *"}
|
||||
]
|
||||
})
|
||||
agent = resp.json()
|
||||
API_KEY = agent["api_key"] # 保存此 Key
|
||||
AGENT_ID = agent["agent"]["id"]
|
||||
data = resp.json()
|
||||
API_KEY = data["agent"]["api_key"] # ← 保存此 Key
|
||||
AGENT_ID = data["agent"]["id"]
|
||||
|
||||
# 2. 每次执行后汇报结果
|
||||
# 2. 每次执行完任务后,汇报结果
|
||||
requests.post(f"{BASE}/api/tasks/{TASK_ID}/executions",
|
||||
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||
json={"status": "success", "duration_ms": 1200, "log": "done"})
|
||||
json={"status": "success", "duration_ms": 1500, "log": "执行完毕"})
|
||||
```
|
||||
|
||||
> 完整接入说明见 [AGENT_GUIDE.md](./AGENT_GUIDE.md),含 curl / Python / 批量注册示例。
|
||||
|
||||
---
|
||||
|
||||
## 系统配置
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `TASKPULSE_DB_HOST` | localhost | MySQL 主机 |
|
||||
| `TASKPULSE_DB_PORT` | 3306 | MySQL 端口 |
|
||||
| `TASKPULSE_DB_USER` | dbuser | 数据库用户 |
|
||||
| `TASKPULSE_DB_PASSWORD` | - | 数据库密码 |
|
||||
| `TASKPULSE_DB_NAME` | taskpulse | 数据库名 |
|
||||
| `TASKPULSE_DEBUG` | true | 调试模式(生产 false) |
|
||||
| `TASKPULSE_SECRET_KEY` | - | JWT 签名密钥 |
|
||||
| `TASKPULSE_SMTP_*` | - | 邮件通知配置 |
|
||||
|
||||
完整变量列表见 `backend/.env.example`。
|
||||
|
||||
---
|
||||
|
||||
## API 概览
|
||||
|
||||
### Agent 管理
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /api/agents | 注册 Agent |
|
||||
| POST | /api/agents/register-with-tasks | **AI Agent 一次性注册自己 + 所有任务** |
|
||||
| GET | /api/agents | 列出所有 Agent |
|
||||
| POST | /api/tasks?agent_id=X | 注册定时任务 |
|
||||
| GET | /api/tasks | 列出所有任务 |
|
||||
| DELETE | /api/tasks/{id} | 删除任务 |
|
||||
| POST | /api/tasks/{id}/executions | 汇报执行结果 |
|
||||
| GET | /api/tasks/{id}/executions | 查看执行历史 |
|
||||
| GET | /api/dashboard/summary | 看板概览 |
|
||||
| GET | /api/dashboard/tasks | 看板任务视图 |
|
||||
| GET | /api/system/config | 读取系统配置(Base URL) |
|
||||
| PUT | /api/system/config | 更新系统配置 |
|
||||
| GET | /api/alerts | 告警列表 |
|
||||
| POST | /api/alerts/{id}/acknowledge | 确认告警 |
|
||||
| POST | /api/notification-channels | 添加通知渠道 |
|
||||
| GET | /api/notification-channels | 列出通知渠道 |
|
||||
| POST | `/api/agents` | 注册 Agent(返回 API Key) |
|
||||
| **POST** | **`/api/agents/register-with-tasks`** | **🌟 Agent + 任务一次性批量注册** |
|
||||
| GET | `/api/agents` | 列出所有 Agent |
|
||||
| PUT | `/api/agents/{id}` | 修改 Agent 名称/描述 |
|
||||
| DELETE | `/api/agents/{id}` | 删除 Agent(关联任务一并删除) |
|
||||
|
||||
完整 API 文档见 `/docs`(Swagger UI)。
|
||||
### 定时任务
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/tasks?agent_id=X` | 注册定时任务 |
|
||||
| GET | `/api/tasks` | 任务列表(支持 `?q=&tags=&status=` 搜索) |
|
||||
| PUT | `/api/tasks/{id}` | 修改任务名称/标签/描述 |
|
||||
| DELETE | `/api/tasks/{id}` | 删除任务 |
|
||||
|
||||
### 执行汇报
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/tasks/{id}/executions` | Agent 汇报执行结果 |
|
||||
| GET | `/api/tasks/{id}/executions` | 查看执行历史(分页) |
|
||||
| GET | `/api/tasks/{id}/executions/{eid}` | 查看单次执行详情 |
|
||||
|
||||
### 看板与配置
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/dashboard/summary` | 看板概览统计数据 |
|
||||
| GET | `/api/dashboard/tasks` | 看板全任务视图 |
|
||||
| GET | `/api/system/config` | 获取系统配置 |
|
||||
| PUT | `/api/system/config` | 更新系统配置(Base URL) |
|
||||
|
||||
### 告警与通知
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/alerts` | 告警列表 |
|
||||
| POST | `/api/alerts/{id}/acknowledge` | 确认告警 |
|
||||
| POST | `/api/notification-channels` | 添加通知渠道(飞书/邮件/Webhook) |
|
||||
|
||||
完整 OpenAPI 文档在 `/docs`(Swagger UI)查看。
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
@@ -159,35 +237,36 @@ requests.post(f"{BASE}/api/tasks/{TASK_ID}/executions",
|
||||
taskpulse/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # FastAPI 入口 + SPA 静态文件服务
|
||||
│ │ ├── config.py # 环境变量配置
|
||||
│ │ ├── database.py # 异步数据库连接
|
||||
│ │ ├── models/ # SQLAlchemy 数据模型
|
||||
│ │ │ ├── agent.py # Agent(含 API Key 自动生成)
|
||||
│ │ │ ├── task.py # 定时任务(cron、宽容窗口)
|
||||
│ │ │ ├── execution.py # 执行记录(日志、结果、耗时)
|
||||
│ │ │ ├── notification.py # 通知渠道、告警规则、告警历史
|
||||
│ │ │ └── system_config.py # 系统配置(key-value)
|
||||
│ │ ├── schemas/ # Pydantic 数据校验
|
||||
│ │ ├── api/ # API 路由
|
||||
│ │ │ ├── agents.py # Agent CRUD + 批量注册
|
||||
│ │ │ ├── tasks.py # 任务 CRUD
|
||||
│ │ │ ├── executions.py # 执行汇报 + 历史查询
|
||||
│ │ │ ├── notifications.py # 通知渠道 + 告警规则 + 告警确认
|
||||
│ │ │ ├── dashboard.py # 看板概览统计
|
||||
│ │ │ └── system.py # 系统配置
|
||||
│ │ └── services/ # 业务逻辑
|
||||
│ │ ├── agent.py / task.py / execution.py
|
||||
│ │ ├── scheduler.py # 后台扫描:检测超时任务→生成告警→通知
|
||||
│ │ └── notification.py # 飞书 / 邮件 / Webhook 通知发送
|
||||
│ │ ├── main.py # FastAPI 入口 + SPA 静态文件 + 生命周期
|
||||
│ │ ├── config.py # 环境变量配置 (pydantic-settings)
|
||||
│ │ ├── database.py # 异步 MySQL 连接
|
||||
│ │ ├── models/ # SQLAlchemy 数据模型(6 张表)
|
||||
│ │ │ ├── agent.py # Agent + API Key
|
||||
│ │ │ ├── task.py # 定时任务 + cron + tags
|
||||
│ │ │ ├── execution.py # 执行记录 + 日志
|
||||
│ │ │ ├── notification.py # 通知渠道 + 告警规则 + 告警
|
||||
│ │ │ ├── system_config.py # 系统配置 (key-value)
|
||||
│ │ │ └── user.py # 看板登录用户
|
||||
│ │ ├── schemas/ # Pydantic 请求/响应模型
|
||||
│ │ ├── api/ # REST API 路由(7 个模块)
|
||||
│ │ └── services/ # 业务逻辑层
|
||||
│ │ ├── scheduler.py # ⏰ 后台调度器(超时检测)
|
||||
│ │ └── notification.py # 📢 飞书/邮件/Webhook 发送
|
||||
│ ├── alembic/ # 数据库迁移
|
||||
│ └── requirements.txt
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── views/ # 页面组件(暗色主题)
|
||||
│ │ ├── router/ # 路由
|
||||
│ │ └── api/ # Axios API 客户端
|
||||
│ │ ├── views/ # 6 个页面(暗色主题)
|
||||
│ │ ├── router/ # 路由守卫 + 登录跳转
|
||||
│ │ ├── api/ # Axios 客户端 + 401 拦截
|
||||
│ │ └── assets/ # 全局样式 + Element Plus 覆盖
|
||||
│ └── package.json
|
||||
├── AGENT_GUIDE.md # Agent 接入指南
|
||||
├── AGENT_GUIDE.md # Agent API 接入说明
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License © 2026
|
||||
|
||||
@@ -48,6 +48,7 @@ def upgrade() -> None:
|
||||
sa.Column("cron_expression", sa.String(64), nullable=False),
|
||||
sa.Column("grace_period", sa.Integer, default=300),
|
||||
sa.Column("status", sa.String(32), default="active"),
|
||||
sa.Column("tags", sa.Text, nullable=True),
|
||||
sa.Column("last_run_at", sa.DateTime, nullable=True),
|
||||
sa.Column("last_run_result", sa.String(32), nullable=True),
|
||||
sa.Column("next_run_at", sa.DateTime, nullable=True),
|
||||
|
||||
@@ -59,6 +59,17 @@ async def heartbeat(agent_id: int, db: AsyncSession = Depends(get_db)):
|
||||
return AgentOut(**{**agent.__dict__, "task_count": cnt})
|
||||
|
||||
|
||||
@router.delete("/{agent_id}", status_code=204)
|
||||
async def delete_agent(agent_id: int, db: AsyncSession = Depends(get_db)):
|
||||
svc = AgentService(db)
|
||||
agent = await svc.get(agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
# Delete all tasks first, then the agent (cascade)
|
||||
await svc.delete(agent_id)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Batch registration: AI agent self-registers with all its tasks ─────
|
||||
|
||||
@router.post("/register-with-tasks", response_model=AgentBatchRegisterResult, status_code=201)
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
"""API router — TaskExecution (reporting + query)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas import ExecutionOut, ExecutionReport
|
||||
from app.services import ExecutionService, TaskService, AgentService
|
||||
from app.models import TaskExecution
|
||||
|
||||
router = APIRouter(prefix="/api/tasks/{task_id}/executions", tags=["executions"])
|
||||
|
||||
|
||||
class PaginatedExecutions(BaseModel):
|
||||
items: list[ExecutionOut]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
@router.post("", response_model=ExecutionOut, status_code=201)
|
||||
async def report_execution(task_id: int, body: ExecutionReport,
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
@@ -20,6 +30,14 @@ async def report_execution(task_id: int, body: ExecutionReport,
|
||||
if not task:
|
||||
raise HTTPException(404, "Task not found")
|
||||
|
||||
# Verify agent exists and is active
|
||||
agent_svc = AgentService(db)
|
||||
agent = await agent_svc.get(task.agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(410, "Agent已被删除,请重新注册")
|
||||
if agent.status != "active":
|
||||
raise HTTPException(403, "Agent已停用,无法汇报")
|
||||
|
||||
exec_svc = ExecutionService(db)
|
||||
record = await exec_svc.report_result(
|
||||
task_id=task_id,
|
||||
@@ -38,16 +56,27 @@ async def report_execution(task_id: int, body: ExecutionReport,
|
||||
return ExecutionOut(**record.__dict__)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ExecutionOut])
|
||||
@router.get("", response_model=PaginatedExecutions)
|
||||
async def list_executions(
|
||||
task_id: int,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
exec_svc = ExecutionService(db)
|
||||
records = await exec_svc.get_executions(task_id, limit=limit, offset=offset)
|
||||
return [ExecutionOut(**r.__dict__) for r in records]
|
||||
offset = (page - 1) * page_size
|
||||
records = await exec_svc.get_executions(task_id, limit=page_size, offset=offset)
|
||||
|
||||
# Get total count
|
||||
count_stmt = select(func.count()).select_from(TaskExecution).where(TaskExecution.task_id == task_id)
|
||||
total = (await db.execute(count_stmt)).scalar() or 0
|
||||
|
||||
return PaginatedExecutions(
|
||||
items=[ExecutionOut(**r.__dict__) for r in records],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/recent", response_model=list[ExecutionOut])
|
||||
|
||||
+65
-14
@@ -1,13 +1,28 @@
|
||||
"""API router — Task management."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas import TaskCreate, TaskOut, TaskUpdate
|
||||
from app.services import TaskService, AgentService
|
||||
from app.models import ScheduledTask
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tags"])
|
||||
|
||||
|
||||
def _task_to_out(task, agent_name: str = "") -> dict:
|
||||
"""Convert task model to TaskOut dict, handling tags JSON deserialization."""
|
||||
data = {**task.__dict__}
|
||||
try:
|
||||
data["tags"] = json.loads(data.get("tags", "[]"))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
data["tags"] = []
|
||||
data["agent_name"] = agent_name
|
||||
return data
|
||||
|
||||
|
||||
@router.post("", response_model=TaskOut, status_code=201)
|
||||
@@ -25,23 +40,48 @@ async def create_task(body: TaskCreate, agent_id: int, db: AsyncSession = Depend
|
||||
description=body.description,
|
||||
grace_period=body.grace_period,
|
||||
)
|
||||
return TaskOut(**{**task.__dict__, "agent_name": agent.name})
|
||||
# Store tags
|
||||
if body.tags:
|
||||
task.tags = json.dumps(body.tags, ensure_ascii=False)
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
return TaskOut(**_task_to_out(task, agent.name))
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskOut])
|
||||
async def list_tasks(agent_id: int | None = None, db: AsyncSession = Depends(get_db)):
|
||||
"""List all tasks, optionally filtered by agent_id."""
|
||||
async def list_tasks(
|
||||
agent_id: int | None = Query(None),
|
||||
q: str | None = Query(None, description="关键词搜索(任务名称)"),
|
||||
tags: str | None = Query(None, description="标签筛选,逗号分隔"),
|
||||
status: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all tasks, with optional search and filters."""
|
||||
svc = TaskService(db)
|
||||
agent_svc = AgentService(db)
|
||||
|
||||
# Build query
|
||||
stmt = select(ScheduledTask)
|
||||
if agent_id:
|
||||
tasks = await svc.list_by_agent(agent_id)
|
||||
else:
|
||||
tasks = await svc.list_all()
|
||||
result = []
|
||||
stmt = stmt.where(ScheduledTask.agent_id == agent_id)
|
||||
if status:
|
||||
stmt = stmt.where(ScheduledTask.status == status)
|
||||
if q:
|
||||
stmt = stmt.where(ScheduledTask.name.like(f"%{q}%"))
|
||||
if tags:
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
for tag in tag_list:
|
||||
stmt = stmt.where(ScheduledTask.tags.like(f'%"{tag}"%'))
|
||||
|
||||
stmt = stmt.order_by(ScheduledTask.id)
|
||||
result = await db.execute(stmt)
|
||||
tasks = list(result.scalars().all())
|
||||
|
||||
output = []
|
||||
for t in tasks:
|
||||
agent = await agent_svc.get(t.agent_id)
|
||||
result.append(TaskOut(**{**t.__dict__, "agent_name": agent.name if agent else ""}))
|
||||
return result
|
||||
output.append(TaskOut(**_task_to_out(t, agent.name if agent else "")))
|
||||
return output
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskOut)
|
||||
@@ -52,18 +92,29 @@ async def get_task(task_id: int, db: AsyncSession = Depends(get_db)):
|
||||
if not task:
|
||||
raise HTTPException(404, "Task not found")
|
||||
agent = await agent_svc.get(task.agent_id)
|
||||
return TaskOut(**{**task.__dict__, "agent_name": agent.name if agent else ""})
|
||||
return TaskOut(**_task_to_out(task, agent.name if agent else ""))
|
||||
|
||||
|
||||
@router.put("/{task_id}", response_model=TaskOut)
|
||||
async def update_task(task_id: int, body: TaskUpdate, db: AsyncSession = Depends(get_db)):
|
||||
svc = TaskService(db)
|
||||
agent_svc = AgentService(db)
|
||||
task = await svc.update(task_id, **body.model_dump(exclude_none=True))
|
||||
|
||||
# Handle tags separately (JSON serialization)
|
||||
update_data = body.model_dump(exclude_none=True)
|
||||
tags_list = update_data.pop("tags", None)
|
||||
|
||||
task = await svc.update(task_id, **update_data)
|
||||
if not task:
|
||||
raise HTTPException(404, "Task not found")
|
||||
|
||||
if tags_list is not None:
|
||||
task.tags = json.dumps(tags_list, ensure_ascii=False)
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
|
||||
agent = await agent_svc.get(task.agent_id)
|
||||
return TaskOut(**{**task.__dict__, "agent_name": agent.name if agent else ""})
|
||||
return TaskOut(**_task_to_out(task, agent.name if agent else ""))
|
||||
|
||||
|
||||
@router.delete("/{task_id}", status_code=204)
|
||||
|
||||
@@ -18,6 +18,7 @@ class ScheduledTask(Base):
|
||||
cron_expression: Mapped[str] = mapped_column(String(64), nullable=False, comment="Cron 表达式, e.g. */5 * * * *")
|
||||
grace_period: Mapped[int] = mapped_column(Integer, default=300, comment="容忍秒数, 超时未执行则告警")
|
||||
status: Mapped[str] = mapped_column(String(32), default="active", comment="active / paused / stopped")
|
||||
tags: Mapped[str | None] = mapped_column(Text, nullable=True, comment="标签 JSON 数组, e.g. [\"数据同步\",\"重要\"]")
|
||||
|
||||
# 冗余字段方便查询
|
||||
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="最近一次执行时间")
|
||||
|
||||
@@ -10,6 +10,7 @@ class TaskCreate(BaseModel):
|
||||
description: str = Field(default="")
|
||||
cron_expression: str = Field(..., max_length=64)
|
||||
grace_period: int = Field(default=300, ge=0)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
@@ -18,6 +19,7 @@ class TaskUpdate(BaseModel):
|
||||
cron_expression: str | None = None
|
||||
grace_period: int | None = None
|
||||
status: str | None = None # active / paused / stopped
|
||||
tags: list[str] | None = None
|
||||
|
||||
|
||||
class TaskOut(BaseModel):
|
||||
@@ -29,6 +31,7 @@ class TaskOut(BaseModel):
|
||||
cron_expression: str
|
||||
grace_period: int
|
||||
status: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
last_run_at: datetime | None = None
|
||||
last_run_result: str | None = None
|
||||
next_run_at: datetime | None = None
|
||||
|
||||
@@ -54,3 +54,11 @@ class AgentService:
|
||||
stmt = select(func.count(ScheduledTask.id)).where(ScheduledTask.agent_id == agent_id)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def delete(self, agent_id: int) -> bool:
|
||||
agent = await self.get(agent_id)
|
||||
if agent is None:
|
||||
return False
|
||||
await self.db.delete(agent)
|
||||
await self.db.flush()
|
||||
return True
|
||||
|
||||
Generated
+10
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.7.0",
|
||||
"cronstrue": "^3.14.0",
|
||||
"dayjs": "^1.11.0",
|
||||
"echarts": "^5.5.0",
|
||||
"element-plus": "^2.7.0",
|
||||
@@ -1079,6 +1080,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cronstrue": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.14.0.tgz",
|
||||
"integrity": "sha512-XnW4vuK/jPJjmTyDWiej1Zq36Od7ITwxaV2O1pzHZuyMVvdy7NAvyvIBzybt+idqSpfqYuoDG7uf/ocGtJVWxA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cronstrue": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"license": "MIT"
|
||||
|
||||
@@ -9,14 +9,15 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"element-plus": "^2.7.0",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.7.0",
|
||||
"cronstrue": "^3.14.0",
|
||||
"dayjs": "^1.11.0",
|
||||
"echarts": "^5.5.0",
|
||||
"vue-echarts": "^6.7.0"
|
||||
"element-plus": "^2.7.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-echarts": "^6.7.0",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
|
||||
@@ -42,9 +42,10 @@ export const listAgents = () => api.get('/agents')
|
||||
export const getAgent = (id) => api.get(`/agents/${id}`)
|
||||
export const createAgent = (data) => api.post('/agents', data)
|
||||
export const updateAgent = (id, data) => api.put(`/agents/${id}`, data)
|
||||
export const deleteAgent = (id) => api.delete(`/agents/${id}`)
|
||||
|
||||
// Tasks
|
||||
export const listTasks = (agentId) => api.get('/tasks', { params: { agent_id: agentId } })
|
||||
export const listTasks = (params) => api.get('/tasks', { params })
|
||||
export const getTask = (id) => api.get(`/tasks/${id}`)
|
||||
export const createTask = (agentId, data) => api.post('/tasks', data, { params: { agent_id: agentId } })
|
||||
export const updateTask = (id, data) => api.put(`/tasks/${id}`, data)
|
||||
|
||||
@@ -230,3 +230,33 @@ html, body, #app {
|
||||
border: 1px solid var(--border-color) !important;
|
||||
backdrop-filter: blur(12px) !important;
|
||||
}
|
||||
|
||||
/* Agent Avatar */
|
||||
.agent-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.agent-avatar-sm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/style.css'
|
||||
|
||||
// Extend dayjs with UTC plugin for timezone conversion
|
||||
dayjs.extend(utc)
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Register all Element Plus icons
|
||||
@@ -14,6 +19,118 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
// Global helper: cron expression to readable Chinese
|
||||
app.config.globalProperties.$cron = (expr) => {
|
||||
if (!expr) return ''
|
||||
try {
|
||||
const parts = expr.trim().split(/\s+/)
|
||||
if (parts.length !== 5) return expr
|
||||
const [min, hour, dom, month, dow] = parts
|
||||
|
||||
// Helper: describe a cron field
|
||||
const descField = (val, unit) => {
|
||||
if (val === '*') return `每${unit}`
|
||||
if (val.startsWith('*/')) return `每${val.slice(2)}${unit}`
|
||||
if (val.includes(',')) {
|
||||
const items = val.split(',').map(v => v.padStart(2, '0')).join('分、')
|
||||
return `第 ${items}分`
|
||||
}
|
||||
if (val.includes('-')) {
|
||||
const [from, to] = val.split('-')
|
||||
return `${from.padStart(2,'0')}分到${to.padStart(2,'0')}分`
|
||||
}
|
||||
return `${val.padStart(2,'0')}${unit}`
|
||||
}
|
||||
|
||||
const allStar = (...args) => args.every(v => v === '*')
|
||||
|
||||
// Every minute
|
||||
if (min === '*' && hour === '*' && allStar(dom, month, dow)) return '每分钟'
|
||||
|
||||
// Every N minutes (always)
|
||||
if (min.startsWith('*/') && hour === '*' && allStar(dom, month, dow)) {
|
||||
return `每${min.slice(2)}分钟`
|
||||
}
|
||||
|
||||
// Every N hours (at min 0)
|
||||
if (min === '0' && hour.startsWith('*/') && allStar(dom, month, dow)) {
|
||||
return `每${hour.slice(2)}小时`
|
||||
}
|
||||
|
||||
// Specific minutes every hour
|
||||
if (min !== '*' && !min.startsWith('*/') && hour === '*' && allStar(dom, month, dow)) {
|
||||
if (min.includes(',')) {
|
||||
const items = min.split(',').map(v => v.padStart(2, '0'))
|
||||
return `每小时 ${items.join('、')}分`
|
||||
}
|
||||
return `每小时 第${min.padStart(2,'0')}分钟`
|
||||
}
|
||||
|
||||
// Daily at specific time(s)
|
||||
if (allStar(dom, month, dow)) {
|
||||
if (min.startsWith('*/')) {
|
||||
const interval = min.slice(2)
|
||||
if (hour.includes('-')) {
|
||||
const [hFrom, hTo] = hour.split('-').map(h => h.padStart(2,'0'))
|
||||
return `每${interval}分钟(${hFrom}:00-${hTo}:00)`
|
||||
}
|
||||
if (hour !== '*') return `每${interval}分钟(${hour.padStart(2,'0')}点)`
|
||||
return `每${interval}分钟`
|
||||
}
|
||||
if (hour.includes(',')) {
|
||||
const times = hour.split(',').map(h => `${h.padStart(2,'0')}:${min.padStart(2,'0')}`)
|
||||
return `每天 ${times.join('、')}`
|
||||
}
|
||||
if (hour.includes('-')) {
|
||||
return `每天 ${hour.split('-')[0].padStart(2,'0')}:${min.padStart(2,'0')} 到 ${hour.split('-')[1].padStart(2,'0')}:${min.padStart(2,'0')}`
|
||||
}
|
||||
return `每天 ${hour.padStart(2,'0')}:${min.padStart(2,'0')}`
|
||||
}
|
||||
|
||||
// Weekly
|
||||
const dowNames = ['日', '一', '二', '三', '四', '五', '六', '日']
|
||||
if (allStar(dom, month) && dow !== '*') {
|
||||
if (dow.includes(',')) {
|
||||
const days = dow.split(',').map(d => `周${dowNames[parseInt(d)]}`)
|
||||
return `每${days.join('、')} ${hour.padStart(2,'0')}:${min.padStart(2,'0')}`
|
||||
}
|
||||
if (dow.includes('-')) {
|
||||
const [from, to] = dow.split('-')
|
||||
return `每周${dowNames[parseInt(from)]}到${dowNames[parseInt(to)]} ${hour.padStart(2,'0')}:${min.padStart(2,'0')}`
|
||||
}
|
||||
return `每周${dowNames[parseInt(dow)]} ${hour.padStart(2,'0')}:${min.padStart(2,'0')}`
|
||||
}
|
||||
|
||||
// Every N minutes within specific hours (e.g., */15 9-18 * * *)
|
||||
if (min.startsWith('*/') && !allStar(dom, month, dow)) {
|
||||
const interval = min.slice(2)
|
||||
let hourDesc = ''
|
||||
if (hour !== '*') hourDesc = descField(hour, '点')
|
||||
return `每${interval}分钟${hourDesc ? ` (${hourDesc})` : ''}`
|
||||
}
|
||||
|
||||
return expr
|
||||
} catch {
|
||||
return expr
|
||||
}
|
||||
}
|
||||
|
||||
// Global helper: agent name → avatar { letter, bg color }
|
||||
const AVATAR_COLORS = [
|
||||
'#667eea', '#22c55e', '#f59e0b', '#ef4444', '#ec4899',
|
||||
'#8b5cf6', '#06b6d4', '#f97316', '#14b8a6', '#a855f7',
|
||||
'#6366f1', '#84cc16', '#e11d48', '#0ea5e9',
|
||||
]
|
||||
app.config.globalProperties.$avatar = (name) => {
|
||||
if (!name) return { letter: '?', bg: AVATAR_COLORS[0] }
|
||||
const letter = name.trim()[0]
|
||||
// Deterministic color from name
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) hash = ((hash << 5) - hash) + name.charCodeAt(i)
|
||||
const idx = Math.abs(hash) % AVATAR_COLORS.length
|
||||
return { letter, bg: AVATAR_COLORS[idx] }
|
||||
}
|
||||
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -43,7 +43,10 @@
|
||||
<tbody>
|
||||
<tr v-for="a in agents" :key="a.id">
|
||||
<td class="cell-mono">#{{ a.id }}</td>
|
||||
<td class="cell-name">{{ a.name }}</td>
|
||||
<td>
|
||||
<span class="agent-avatar" :style="{background: $avatar(a.name).bg}">{{ $avatar(a.name).letter }}</span>
|
||||
<span class="cell-name">{{ a.name }}</span>
|
||||
</td>
|
||||
<td class="cell-muted">{{ a.description || '—' }}</td>
|
||||
<td>
|
||||
<span class="tag" :class="a.status === 'active' ? 'tag-green' : 'tag-gray'">
|
||||
@@ -51,7 +54,7 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="cell-mono">{{ a.task_count }}</td>
|
||||
<td class="cell-mono">{{ a.last_heartbeat_at ? beijing(a.last_heartbeat_at) : '—' }}</td>
|
||||
<td class="cell-mono">{{ a.last_heartbeat_at ? dayjs.utc(a.last_heartbeat_at).local().format('MM-DD HH:mm') : '—' }}</td>
|
||||
<td>
|
||||
<code class="cell-key">{{ a.api_key.substring(0, 16) }}...</code>
|
||||
<button class="btn-icon" @click="copyKey(a.api_key)" title="复制 API Key">
|
||||
@@ -60,6 +63,8 @@
|
||||
</td>
|
||||
<td>
|
||||
<router-link to="/tasks" class="cell-link">任务</router-link>
|
||||
<button class="cell-link-btn" style="color:var(--accent-1)" @click="showEdit(a)">编辑</button>
|
||||
<button class="cell-link-btn" style="color:var(--danger)" @click="confirmDelete(a)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="agents.length === 0">
|
||||
@@ -70,12 +75,65 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Agent Modal -->
|
||||
<div v-if="editAgent" class="modal-overlay" @click.self="editAgent = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h4>编辑 Agent</h4>
|
||||
<button class="modal-close" @click="editAgent = null">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label>Agent 名称</label>
|
||||
<div class="field-avatar-row">
|
||||
<span class="agent-avatar-lg" :style="{background: $avatar(editForm.name).bg}">{{ $avatar(editForm.name).letter }}</span>
|
||||
<input v-model="editForm.name" class="input" placeholder="Agent 名称" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>描述</label>
|
||||
<input v-model="editForm.description" class="input" placeholder="描述" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="editAgent = null">取消</button>
|
||||
<button class="btn-primary" @click="handleEdit">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirm Modal -->
|
||||
<div v-if="deleteTarget" class="modal-overlay" @click.self="deleteTarget = null">
|
||||
<div class="modal" style="width:400px">
|
||||
<div class="modal-header">
|
||||
<h4>删除 Agent</h4>
|
||||
<button class="modal-close" @click="deleteTarget = null">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="color:var(--text-secondary);font-size:14px;line-height:1.6">
|
||||
确定要删除 Agent <strong style="color:var(--text-primary)">{{ deleteTarget.name }}</strong> 吗?
|
||||
</p>
|
||||
<p style="color:var(--text-muted);font-size:12px;margin-top:8px">
|
||||
其关联的定时任务也将一并删除。Agent 后续仍可重新注册。
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="deleteTarget = null">取消</button>
|
||||
<button class="btn-danger" @click="handleDelete">确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { beijing } from '../utils/time.js'
|
||||
import { listAgents, getSystemConfig } from '../api/index.js'
|
||||
import dayjs from 'dayjs'
|
||||
import { listAgents, updateAgent, deleteAgent, getSystemConfig } from '../api/index.js'
|
||||
|
||||
const agents = ref([])
|
||||
const tab = ref('curl')
|
||||
@@ -90,6 +148,40 @@ const loadData = async () => {
|
||||
baseUrl.value = cfgRes.data.base_url
|
||||
}
|
||||
|
||||
// Edit
|
||||
const editAgent = ref(null)
|
||||
const editForm = ref({ name: '', description: '' })
|
||||
const showEdit = (agent) => {
|
||||
editForm.value = { name: agent.name, description: agent.description }
|
||||
editAgent.value = agent
|
||||
}
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
await updateAgent(editAgent.value.id, editForm.value)
|
||||
editAgent.value = null
|
||||
await loadData()
|
||||
const { ElMessage } = await import('element-plus')
|
||||
ElMessage.success('已更新')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete
|
||||
const deleteTarget = ref(null)
|
||||
const confirmDelete = (agent) => { deleteTarget.value = agent }
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteAgent(deleteTarget.value.id)
|
||||
deleteTarget.value = null
|
||||
await loadData()
|
||||
const { ElMessage } = await import('element-plus')
|
||||
ElMessage.success('已删除')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const copyKey = async (key) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(key)
|
||||
@@ -122,7 +214,7 @@ onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.agents-page { max-width: 1200px; }
|
||||
.agents-page {}
|
||||
.mb-24 { margin-bottom: 24px; }
|
||||
|
||||
.section {
|
||||
@@ -234,4 +326,91 @@ onMounted(loadData)
|
||||
vertical-align: middle;
|
||||
}
|
||||
.btn-icon:hover { color: var(--accent-1); }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 1000;
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal {
|
||||
width: 480px; max-width: 90vw;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.modal-header h4 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
|
||||
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; }
|
||||
.modal-close:hover { color: #fff; }
|
||||
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.modal-footer {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 16px 20px; border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field label { font-size: 12px; color: var(--text-secondary); }
|
||||
.field-avatar-row { display: flex; align-items: center; gap: 12px; }
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.input:focus { border-color: var(--accent-1); }
|
||||
|
||||
.agent-avatar-lg {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 7px 16px; border-radius: 8px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: #fff; font-size: 13px; font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
.btn-secondary {
|
||||
padding: 7px 16px; border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: transparent;
|
||||
color: var(--text-secondary); font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover { color: #fff; border-color: rgba(255,255,255,0.15); }
|
||||
.btn-danger {
|
||||
padding: 7px 16px; border-radius: 8px;
|
||||
border: none;
|
||||
background: rgba(239,68,68,0.8);
|
||||
color: #fff; font-size: 13px; font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger:hover { background: #ef4444; }
|
||||
|
||||
.cell-link-btn {
|
||||
background: none; border: none;
|
||||
font-size: 13px; cursor: pointer;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.cell-link-btn:hover { text-decoration: underline; }
|
||||
</style>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
{{ a.alert_type === 'missed_run' ? '未按时执行' : '执行失败' }}
|
||||
</span>
|
||||
<span class="alert-task">{{ a.task_name || '未知任务' }}</span>
|
||||
<span class="alert-time">{{ beijing(a.created_at) }}</span>
|
||||
<span class="alert-time">{{ dayjs.utc(a.created_at).local().format('MM-DD HH:mm') }}</span>
|
||||
</div>
|
||||
<div class="alert-msg">{{ a.message }}</div>
|
||||
<div class="alert-bottom">
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { beijing } from '../utils/time.js'
|
||||
import dayjs from 'dayjs'
|
||||
import { listAlerts, acknowledgeAlert } from '../api/index.js'
|
||||
|
||||
const alerts = ref([])
|
||||
@@ -68,7 +68,7 @@ onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alerts-page { max-width: 1200px; }
|
||||
.alerts-page {}
|
||||
|
||||
.section {
|
||||
background: var(--bg-card);
|
||||
|
||||
@@ -117,15 +117,18 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in tasks" :key="t.id">
|
||||
<td><span class="cell-badge">{{ t.agent_name }}</span></td>
|
||||
<td>
|
||||
<span class="agent-avatar" :style="{background: $avatar(t.agent_name).bg}">{{ $avatar(t.agent_name).letter }}</span>
|
||||
<span class="cell-badge">{{ t.agent_name }}</span>
|
||||
</td>
|
||||
<td class="cell-name">{{ t.name }}</td>
|
||||
<td><code class="cell-code">{{ t.cron_expression }}</code></td>
|
||||
<td><code class="cell-code" :title="t.cron_expression">{{ $cron(t.cron_expression) }}</code></td>
|
||||
<td>
|
||||
<span class="tag" :class="t.status === 'active' ? 'tag-green' : 'tag-gray'">
|
||||
{{ t.status === 'active' ? '运行中' : t.status === 'paused' ? '已暂停' : '已停止' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="cell-mono">{{ t.last_run_at ? beijing(t.last_run_at) : '—' }}</td>
|
||||
<td class="cell-mono">{{ t.last_run_at ? dayjs.utc(t.last_run_at).local().format('MM-DD HH:mm') : '—' }}</td>
|
||||
<td>
|
||||
<span v-if="t.last_run_result" class="tag" :class="t.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
|
||||
{{ t.last_run_result === 'success' ? '成功' : '失败' }}
|
||||
@@ -149,7 +152,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { beijing } from '../utils/time.js'
|
||||
import dayjs from 'dayjs'
|
||||
import { getDashboardSummary, getDashboardTasks, getSystemConfig } from '../api/index.js'
|
||||
|
||||
const summary = ref({
|
||||
@@ -343,7 +346,6 @@ const copyScript = async () => {
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
/* ── Onboarding Panel ─────────────────────────── */
|
||||
|
||||
@@ -246,7 +246,7 @@ onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page { max-width: 1200px; }
|
||||
.settings-page {}
|
||||
.mb-24 { margin-bottom: 24px; }
|
||||
|
||||
.section {
|
||||
|
||||
@@ -13,10 +13,16 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item"><span class="detail-label">Agent</span><span class="detail-value">{{ task.agent_name }}</span></div>
|
||||
<div class="detail-item"><span class="detail-label">Cron</span><code class="detail-code">{{ task.cron_expression }}</code></div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Agent</span>
|
||||
<span class="detail-value">
|
||||
<span class="agent-avatar-sm" :style="{background: $avatar(task.agent_name).bg}">{{ $avatar(task.agent_name).letter }}</span>
|
||||
{{ task.agent_name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item"><span class="detail-label">Cron</span><code class="detail-code" :title="task.cron_expression">{{ $cron(task.cron_expression) }}</code></div>
|
||||
<div class="detail-item"><span class="detail-label">容忍窗口</span><span class="detail-value">{{ task.grace_period }}s</span></div>
|
||||
<div class="detail-item"><span class="detail-label">最近运行</span><span class="detail-value mono">{{ task.last_run_at ? beijing(task.last_run_at, 'YYYY-MM-DD HH:mm:ss') : '—' }}</span></div>
|
||||
<div class="detail-item"><span class="detail-label">最近运行</span><span class="detail-value mono">{{ task.last_run_at ? dayjs.utc(task.last_run_at).local().format('YYYY-MM-DD HH:mm:ss') : '—' }}</span></div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">最近结果</span>
|
||||
<span v-if="task.last_run_result" class="tag" :class="task.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
|
||||
@@ -25,7 +31,7 @@
|
||||
<span v-else class="detail-value">—</span>
|
||||
</div>
|
||||
<div class="detail-item"><span class="detail-label">执行次数</span><span class="detail-value mono">{{ task.total_run_count }}</span></div>
|
||||
<div class="detail-item"><span class="detail-label">预计下次</span><span class="detail-value mono">{{ task.next_run_at ? beijing(task.next_run_at, 'YYYY-MM-DD HH:mm:ss') : '待计算' }}</span></div>
|
||||
<div class="detail-item"><span class="detail-label">预计下次</span><span class="detail-value mono">{{ task.next_run_at ? dayjs.utc(task.next_run_at).local().format('YYYY-MM-DD HH:mm:ss') : '待计算' }}</span></div>
|
||||
<div class="detail-item"><span class="detail-label">描述</span><span class="detail-value">{{ task.description || '无' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +61,7 @@
|
||||
{{ e.status === 'success' ? '成功' : e.status === 'failed' ? '失败' : '运行中' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="cell-mono">{{ beijing(e.started_at, 'MM-DD HH:mm:ss') }}</td>
|
||||
<td class="cell-mono">{{ dayjs.utc(e.started_at).local().format('MM-DD HH:mm:ss') }}</td>
|
||||
<td class="cell-mono">{{ e.duration_ms ?? '—' }}</td>
|
||||
<td class="cell-muted cell-ellipsis">{{ e.result || '—' }}</td>
|
||||
<td><button class="cell-link-btn" @click="showLog(e)">日志</button></td>
|
||||
@@ -66,6 +72,20 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="total > pageSize && totalPages > 1">
|
||||
<span class="page-info">共 {{ total }} 条</span>
|
||||
<div class="page-btns">
|
||||
<button class="page-btn" :disabled="currentPage <= 1" @click="goPage(currentPage - 1)">上一页</button>
|
||||
<template v-for="p in (totalPages || 1)" :key="p">
|
||||
<button v-if="Math.abs(p - currentPage) <= 2 || p === 1 || p === totalPages"
|
||||
class="page-btn" :class="{ active: p === currentPage }"
|
||||
@click="goPage(p)">{{ p }}</button>
|
||||
<span v-else-if="p === currentPage - 3 || p === currentPage + 3" class="page-ellipsis">…</span>
|
||||
</template>
|
||||
<button class="page-btn" :disabled="currentPage >= totalPages" @click="goPage(currentPage + 1)">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Dialog -->
|
||||
@@ -84,9 +104,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { beijing } from '../utils/time.js'
|
||||
import dayjs from 'dayjs'
|
||||
import { getTask, listExecutions } from '../api/index.js'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -97,14 +117,37 @@ const logVisible = ref(false)
|
||||
const currentLog = ref('')
|
||||
const logExecution = ref(null)
|
||||
|
||||
// Pagination
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value) || 1)
|
||||
|
||||
const loadExecutions = async (page) => {
|
||||
try {
|
||||
const res = await listExecutions(taskId, { page, page_size: pageSize.value })
|
||||
const data = res.data || {}
|
||||
executions.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
currentPage.value = data.page || page
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
executions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const goPage = (p) => {
|
||||
if (p < 1 || p > totalPages.value) return
|
||||
loadExecutions(p)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [taskRes, execRes] = await Promise.all([
|
||||
const [taskRes] = await Promise.all([
|
||||
getTask(taskId),
|
||||
listExecutions(taskId, { limit: 100 }),
|
||||
])
|
||||
task.value = taskRes.data
|
||||
executions.value = execRes.data
|
||||
await loadExecutions(1)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
@@ -118,8 +161,6 @@ const showLog = (row) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-detail { max-width: 1200px; }
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -239,4 +280,40 @@ const showLog = (row) => {
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Pagination ──────────────────────────── */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
.page-info {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.page-btns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.page-btn {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.page-btn:hover { border-color: var(--accent-1); color: var(--accent-1); }
|
||||
.page-btn.active {
|
||||
background: var(--accent-1);
|
||||
border-color: var(--accent-1);
|
||||
color: #fff;
|
||||
}
|
||||
.page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.page-ellipsis { color: var(--text-muted); font-size: 12px; padding: 0 2px; }
|
||||
</style>
|
||||
|
||||
+218
-208
@@ -1,36 +1,29 @@
|
||||
<template>
|
||||
<div class="tasks-page">
|
||||
<!-- Search & Filter Bar -->
|
||||
<div class="section mb-24">
|
||||
<div class="section-header collapsible" @click="showGuide = !showGuide">
|
||||
<h3>定时任务 — 由 AI Agent 自动管理</h3>
|
||||
<div class="section-toggle">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
:style="{ transform: showGuide ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
<span class="toggle-label">{{ showGuide ? '收起指令' : '展开指令' }}</span>
|
||||
</div>
|
||||
<div class="section-header">
|
||||
<h3>所有任务</h3>
|
||||
</div>
|
||||
<div v-if="showGuide" class="onboard-body">
|
||||
<p>将下方指令发送给您的 AI Agent,它会自动汇报执行结果并管理定时任务。</p>
|
||||
<div class="code-block">
|
||||
<div class="code-tabs">
|
||||
<button :class="{ active: tab === 'report' }" @click="tab = 'report'">汇报执行</button>
|
||||
<button :class="{ active: tab === 'register' }" @click="tab = 'register'">注册任务</button>
|
||||
<button :class="{ active: tab === 'batch' }" @click="tab = 'batch'">批量注册</button>
|
||||
</div>
|
||||
<div class="code-body">
|
||||
<pre>{{ codes[tab] }}</pre>
|
||||
</div>
|
||||
<button class="btn-copy" @click="copyCode">复制</button>
|
||||
<div class="search-bar">
|
||||
<div class="search-input-wrap">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input v-model="searchQ" class="search-input" placeholder="搜索任务名称..." @input="loadData" />
|
||||
</div>
|
||||
<input v-model="searchTags" class="search-input" style="width:200px" placeholder="标签筛选(逗号分隔)" @input="loadData" />
|
||||
<select v-model="searchStatus" class="search-input" style="width:130px" @change="loadData">
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">运行中</option>
|
||||
<option value="paused">已暂停</option>
|
||||
<option value="stopped">已停止</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task list -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3>所有任务</h3>
|
||||
<h3>任务列表({{ tasks.length }})</h3>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
@@ -40,6 +33,7 @@
|
||||
<th>Agent</th>
|
||||
<th>任务名称</th>
|
||||
<th>Cron</th>
|
||||
<th>标签</th>
|
||||
<th>状态</th>
|
||||
<th>最近运行</th>
|
||||
<th>结果</th>
|
||||
@@ -50,15 +44,22 @@
|
||||
<tbody>
|
||||
<tr v-for="t in tasks" :key="t.id">
|
||||
<td class="cell-mono">#{{ t.id }}</td>
|
||||
<td><span class="cell-badge">{{ t.agent_name }}</span></td>
|
||||
<td>
|
||||
<span class="agent-avatar" :style="{background: $avatar(t.agent_name).bg}">{{ $avatar(t.agent_name).letter }}</span>
|
||||
<span class="cell-badge">{{ t.agent_name }}</span>
|
||||
</td>
|
||||
<td class="cell-name">{{ t.name }}</td>
|
||||
<td><code class="cell-code">{{ t.cron_expression }}</code></td>
|
||||
<td><code class="cell-code" :title="t.cron_expression">{{ $cron(t.cron_expression) }}</code></td>
|
||||
<td>
|
||||
<span v-for="tag in (t.tags || [])" :key="tag" class="tag-pill">{{ tag }}</span>
|
||||
<span v-if="!t.tags || t.tags.length === 0" class="cell-muted">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="tag" :class="t.status === 'active' ? 'tag-green' : 'tag-gray'">
|
||||
{{ t.status === 'active' ? '运行中' : t.status === 'paused' ? '已暂停' : '已停止' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="cell-mono">{{ t.last_run_at ? beijing(t.last_run_at) : '—' }}</td>
|
||||
<td class="cell-mono">{{ t.last_run_at ? dayjs.utc(t.last_run_at).local().format('MM-DD HH:mm') : '—' }}</td>
|
||||
<td>
|
||||
<span v-if="t.last_run_result" class="tag" :class="t.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
|
||||
{{ t.last_run_result === 'success' ? '成功' : '失败' }}
|
||||
@@ -68,182 +69,121 @@
|
||||
<td class="cell-mono">{{ t.total_run_count }}</td>
|
||||
<td>
|
||||
<router-link :to="`/tasks/${t.id}`" class="cell-link">详情</router-link>
|
||||
<button class="btn-icon" @click="handleDelete(t)" title="删除">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
</button>
|
||||
<button class="cell-link-btn" style="color:var(--accent-1);margin-left:6px" @click="showEdit(t)">编辑</button>
|
||||
<button class="cell-link-btn" style="color:var(--danger);margin-left:4px" @click="handleDelete(t)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="tasks.length === 0">
|
||||
<td colspan="9" class="cell-empty">暂无任务 — Agent 注册后会自动出现在这里</td>
|
||||
<td colspan="10" class="cell-empty">暂无任务 — Agent 注册后会自动出现在这里</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Task Modal -->
|
||||
<div v-if="editTask" class="modal-overlay" @click.self="editTask = null">
|
||||
<div class="modal modal-wide">
|
||||
<div class="modal-header">
|
||||
<h4>编辑任务</h4>
|
||||
<button class="modal-close" @click="editTask = null">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-fields">
|
||||
<div class="field">
|
||||
<label>任务名称</label>
|
||||
<input v-model="editForm.name" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>标签(回车添加)</label>
|
||||
<div class="tags-edit">
|
||||
<span v-for="(tag, i) in editForm.tags" :key="i" class="tag-pill">
|
||||
{{ tag }}
|
||||
<button class="tag-remove" @click="editForm.tags.splice(i, 1)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</span>
|
||||
<input v-model="tagInput" class="tag-input" placeholder="输入标签后回车" @keydown.enter.prevent="addTag" @keydown.,.prevent="addTag" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>描述</label>
|
||||
<input v-model="editForm.description" class="input" placeholder="任务描述(选填)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="editTask = null">取消</button>
|
||||
<button class="btn-primary" @click="handleEdit">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { beijing } from '../utils/time.js'
|
||||
import { listTasks, deleteTask, getSystemConfig } from '../api/index.js'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { listTasks, deleteTask, updateTask } from '../api/index.js'
|
||||
|
||||
const tasks = ref([])
|
||||
const tab = ref('report')
|
||||
const showGuide = ref(false)
|
||||
const baseUrl = ref(window.location.origin)
|
||||
const searchQ = ref('')
|
||||
const searchTags = ref('')
|
||||
const searchStatus = ref('')
|
||||
|
||||
const loadData = async () => {
|
||||
const [taskRes, cfgRes] = await Promise.all([
|
||||
listTasks(),
|
||||
getSystemConfig(),
|
||||
])
|
||||
tasks.value = taskRes.data
|
||||
baseUrl.value = cfgRes.data.base_url
|
||||
const params = {}
|
||||
if (searchQ.value) params.q = searchQ.value
|
||||
if (searchTags.value) params.tags = searchTags.value
|
||||
if (searchStatus.value) params.status = searchStatus.value
|
||||
const res = await listTasks(params)
|
||||
tasks.value = res.data
|
||||
}
|
||||
|
||||
// Edit
|
||||
const editTask = ref(null)
|
||||
const editForm = ref({ name: '', description: '', tags: [] })
|
||||
const tagInput = ref('')
|
||||
|
||||
const addTag = () => {
|
||||
const val = tagInput.value.replace(/,/g, '').trim()
|
||||
if (val && !editForm.value.tags.includes(val)) editForm.value.tags.push(val)
|
||||
tagInput.value = ''
|
||||
}
|
||||
|
||||
const showEdit = (t) => {
|
||||
editForm.value = {
|
||||
name: t.name,
|
||||
description: t.description || '',
|
||||
tags: [...(t.tags || [])],
|
||||
}
|
||||
editTask.value = t
|
||||
}
|
||||
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
await updateTask(editTask.value.id, editForm.value)
|
||||
editTask.value = null
|
||||
await loadData()
|
||||
const { ElMessage } = await import('element-plus')
|
||||
ElMessage.success('已保存')
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await deleteTask(row.id)
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const codes = computed(() => {
|
||||
const b = baseUrl.value
|
||||
return {
|
||||
report: `# TaskPulse — 汇报执行结果
|
||||
|
||||
你已注册到 TaskPulse。每次任务运行后(无论成功失败),主动汇报执行结果。
|
||||
|
||||
## 汇报成功
|
||||
|
||||
curl -X POST ${b}/api/tasks/<TASK_ID>/executions \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer <API_KEY>" \\
|
||||
-d '{"status":"success","duration_ms":2500,"log":"[INFO] 任务执行完成,同步了100条记录"}'
|
||||
|
||||
## 汇报失败
|
||||
|
||||
curl -X POST ${b}/api/tasks/<TASK_ID>/executions \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer <API_KEY>" \\
|
||||
-d '{"status":"failed","duration_ms":1200,"log":"[ERROR] 连接超时","error_message":"TimeoutError: connection to DB timed out"}'
|
||||
|
||||
## 修改你的代码自动汇报
|
||||
|
||||
在你的每个定时任务函数末尾,加上汇报逻辑。参考以下实现:
|
||||
|
||||
import time
|
||||
import requests
|
||||
|
||||
def run_task(task_func, task_id, api_key):
|
||||
"""包裹你的任务函数,自动汇报执行结果"""
|
||||
start = time.time()
|
||||
try:
|
||||
result = task_func()
|
||||
elapsed = int((time.time() - start) * 1000)
|
||||
requests.post(f"${b}/api/tasks/{task_id}/executions",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
json={"status": "success", "duration_ms": elapsed, "log": str(result)})
|
||||
except Exception as e:
|
||||
elapsed = int((time.time() - start) * 1000)
|
||||
requests.post(f"${b}/api/tasks/{task_id}/executions",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
json={"status": "failed", "duration_ms": elapsed, "error_message": str(e)})
|
||||
|
||||
# 在你的循环/调度中这样使用:
|
||||
run_task(my_task_func, TASK_ID, API_KEY)`,
|
||||
register: `# TaskPulse — 注册新任务
|
||||
|
||||
你需要注册自己管理的每一个定时任务,系统才知道你的执行计划。
|
||||
|
||||
## 单个注册
|
||||
|
||||
curl -X POST "${b}/api/tasks?agent_id=<AGENT_ID>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer <API_KEY>" \\
|
||||
-d '{"name":"数据同步","cron_expression":"*/5 * * * *","description":"每5分钟同步一次数据","grace_period":300}'
|
||||
|
||||
参数说明:
|
||||
- name: 任务名称(必填)
|
||||
- cron_expression: Cron 表达式,格式:分 时 日 月 周(必填)
|
||||
- description: 任务描述(可选)
|
||||
- grace_period: 容忍窗口(秒),任务超时N秒未执行则告警(默认300)
|
||||
|
||||
## 查看已注册的任务
|
||||
|
||||
curl -X GET "${b}/api/tasks" \\
|
||||
-H "Authorization: Bearer <API_KEY>"
|
||||
|
||||
## 更新现有任务(修改 cron 等)
|
||||
|
||||
curl -X PUT "${b}/api/tasks/<TASK_ID>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer <API_KEY>" \\
|
||||
-d '{"cron_expression":"0 * * * *","status":"active"}'
|
||||
|
||||
## 删除任务
|
||||
|
||||
curl -X DELETE "${b}/api/tasks/<TASK_ID>" \\
|
||||
-H "Authorization: Bearer <API_KEY>"`,
|
||||
batch: `# TaskPulse — 批量注册(推荐)
|
||||
|
||||
如果你有多个定时任务,推荐使用批量注册接口,一次完成 Agent 注册 + 所有任务登记。
|
||||
|
||||
## 批量注册 Agent + 全部任务
|
||||
|
||||
curl -X POST ${b}/api/agents/register-with-tasks \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"name": "<你的Agent名称>",
|
||||
"description": "<你的职责描述>",
|
||||
"tasks": [
|
||||
{"name": "数据同步", "cron_expression": "*/5 * * * *", "description": "定时同步数据", "grace_period": 300},
|
||||
{"name": "日报通知", "cron_expression": "0 9 * * *", "description": "每天早上9点生成报告", "grace_period": 600},
|
||||
{"name": "周报汇总", "cron_expression": "0 10 * * 1", "description": "每周一早10点汇总", "grace_period": 900}
|
||||
]
|
||||
}'
|
||||
|
||||
响应中包含 agent 信息和任务创建数量。返回的 api_key 请保存好。
|
||||
|
||||
## Python 示例
|
||||
|
||||
import requests
|
||||
|
||||
resp = requests.post(f"${b}/api/agents/register-with-tasks", json={
|
||||
"name": "data-agent",
|
||||
"description": "数据相关的全部定时任务",
|
||||
"tasks": [
|
||||
{"name": "sync-data", "cron_expression": "*/5 * * * *"},
|
||||
{"name": "daily-report", "cron_expression": "0 9 * * *"}
|
||||
]
|
||||
})
|
||||
result = resp.json()
|
||||
print(f"Agent ID: {result['agent']['id']}, 任务数: {result['tasks_created']}")
|
||||
API_KEY = result["agent"]["api_key"]
|
||||
|
||||
---
|
||||
|
||||
注册完成后,回到仪表盘页面,将「汇报执行结果」的指令一并发送给 AI Agent,它就知道如何自动向你汇报运行状态。`,
|
||||
}
|
||||
})
|
||||
|
||||
const copyCode = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(codes.value[tab.value])
|
||||
const { ElMessage } = await import('element-plus')
|
||||
ElMessage.success('已复制')
|
||||
} catch {}
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tasks-page { max-width: 1200px; }
|
||||
.tasks-page {}
|
||||
.mb-24 { margin-bottom: 24px; }
|
||||
|
||||
.section {
|
||||
@@ -265,37 +205,32 @@ onMounted(loadData)
|
||||
}
|
||||
.section-toggle:hover { color: var(--text-secondary); }
|
||||
|
||||
.onboard-body { padding: 20px; }
|
||||
.onboard-body > p { font-size: 13px; color: var(--text-secondary); margin-bottom: 16px; }
|
||||
|
||||
.code-block {
|
||||
position: relative;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.code-tabs { display: flex; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||
.code-tabs button {
|
||||
padding: 8px 16px; background: none; border: none;
|
||||
color: rgba(255,255,255,0.4); font-size: 12px; cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.code-tabs button.active { color: #a8b4ff; border-bottom-color: #667eea; }
|
||||
.code-body { padding: 16px; overflow-x: auto; }
|
||||
.code-body pre {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 12px; line-height: 1.7; color: rgba(255,255,255,0.75);
|
||||
white-space: pre; margin: 0;
|
||||
}
|
||||
.btn-copy {
|
||||
position: absolute; top: 44px; right: 12px;
|
||||
padding: 4px 12px; border-radius: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: rgba(255,255,255,0.5); font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.btn-copy:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
||||
/* Search */
|
||||
.search-bar {
|
||||
display: flex; gap: 10px; padding: 12px 20px;
|
||||
align-items: center;
|
||||
}
|
||||
.search-input-wrap {
|
||||
display: flex; align-items: center; gap: 8px; flex: 1;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.search-input-wrap svg { flex-shrink: 0; }
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.search-input::placeholder { color: var(--text-muted); }
|
||||
|
||||
/* Table */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th {
|
||||
@@ -322,17 +257,92 @@ onMounted(loadData)
|
||||
.cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
|
||||
.cell-muted { color: var(--text-muted); }
|
||||
.cell-empty { text-align: center; color: var(--text-muted); padding: 40px 16px !important; }
|
||||
.cell-link { color: var(--accent-1); text-decoration: none; font-size: 13px; margin-right: 8px; }
|
||||
.cell-link { color: var(--accent-1); text-decoration: none; font-size: 13px; }
|
||||
.cell-link:hover { text-decoration: underline; }
|
||||
.cell-link-btn { background: none; border: none; font-size: 12px; cursor: pointer; }
|
||||
.cell-link-btn:hover { text-decoration: underline; }
|
||||
|
||||
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
|
||||
.tag-green { background: rgba(34,197,94,0.12); color: #4ade80; }
|
||||
.tag-red { background: rgba(239,68,68,0.12); color: #f87171; }
|
||||
.tag-gray { background: rgba(255,255,255,0.05); color: var(--text-secondary); }
|
||||
|
||||
.btn-icon {
|
||||
background: none; border: none; color: var(--text-muted);
|
||||
cursor: pointer; padding: 4px; vertical-align: middle;
|
||||
/* Tag Pills */
|
||||
.tag-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
background: rgba(102,126,234,0.12);
|
||||
color: #a8b4ff;
|
||||
margin-right: 4px;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-icon:hover { color: var(--danger); }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 1000;
|
||||
background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal {
|
||||
width: 480px; max-width: 90vw;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-wide { width: 560px; }
|
||||
.modal-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.modal-header h4 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
|
||||
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; }
|
||||
.modal-close:hover { color: #fff; }
|
||||
.modal-body { padding: 20px; }
|
||||
.modal-footer {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 16px 20px; border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.form-fields { display: flex; flex-direction: column; gap: 16px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field label { font-size: 12px; color: var(--text-secondary); }
|
||||
.input {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.input:focus { border-color: var(--accent-1); }
|
||||
select.input { cursor: pointer; }
|
||||
|
||||
.tags-edit {
|
||||
display: flex; flex-wrap: wrap; gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgba(255,255,255,0.04);
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
}
|
||||
.tag-remove { background: none; border: none; color: inherit; cursor: pointer; padding: 0; margin-left: 2px; opacity: 0.6; }
|
||||
.tag-remove:hover { opacity: 1; }
|
||||
.tag-input {
|
||||
flex: 1; min-width: 100px;
|
||||
background: none; border: none; outline: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.tag-input::placeholder { color: var(--text-muted); }
|
||||
|
||||
.btn-primary { padding: 7px 16px; border-radius: 8px; border: none; background: linear-gradient(135deg,#667eea,#764ba2); color: #fff; font-size: 13px; font-weight: 500; cursor: pointer; }
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
.btn-secondary { padding: 7px 16px; border-radius: 8px; border: 1px solid var(--border-color); background: transparent; color: var(--text-secondary); font-size: 13px; cursor: pointer; }
|
||||
.btn-secondary:hover { color: #fff; border-color: rgba(255,255,255,0.15); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user