Compare commits
9
Commits
1e539b1c25
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33f4ce42e4 | ||
|
|
4215137f26 | ||
|
|
78df16f770 | ||
|
|
4cdccf904a | ||
|
|
d82e1c9946 | ||
|
|
e8f931b4a1 | ||
|
|
c54cc82612 | ||
|
|
56dfa5b418 | ||
|
|
ad51078975 |
+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 一次性注册自己 + 所有定时任务,无需手动填写表单
|
### 🤖 AI 原生接入
|
||||||
- **统一状态看板** — 所有定时任务的运行状态、最近运行时间、结果一目了然
|
- **一次性批量注册**:`POST /api/agents/register-with-tasks` — Agent 在一分钟内完成注册 + 所有任务登记
|
||||||
- **执行汇报** — Agent 执行完任务后主动汇报结果,含详细日志
|
- **API Key 鉴权**:每个 Agent 独立密钥,汇报时自动校验
|
||||||
- **超时告警** — 任务未按时执行时自动告警
|
- **无缝重注册**:删除后可重新注册,获取新 Key 继续工作
|
||||||
- **多渠道通知** — 支持飞书 Webhook、邮件、通用 Webhook
|
- **Agent 头像**:名称首字 + 确定性色彩头像,多 Agent 一目了然
|
||||||
- **Base URL 可配置** — 系统设置页面可配置公网访问地址,所有接入指令自动更新
|
|
||||||
|
### 📊 统一状态看板
|
||||||
|
- **概览卡片**:Agent 数量、任务总数、24h 执行量、待处理告警
|
||||||
|
- **全任务视图**:所有 Agent 的任务集中展示,状态、最近运行、结果一目了然
|
||||||
|
- **任务标签**:可对任务打标签(如"数据同步"、"重要"),按标签筛选
|
||||||
|
- **Cron 人类可读**:`*/5 * * * *` 自动显示为 "每5分钟"
|
||||||
|
- **时间本地化**:所有时间自动从 UTC 转为浏览器本地时区
|
||||||
|
|
||||||
|
### ⏱ 执行汇报与追踪
|
||||||
|
- **实时汇报**:Agent 执行完任务后 POST 结果,系统记录执行日志
|
||||||
|
- **执行历史**:每个任务都有完整的时间线,支持分页查看
|
||||||
|
- **日志查看器**:暗色终端风格,方便排查问题
|
||||||
|
- **搜索过滤**:按任务名称、标签、状态快速筛选
|
||||||
|
|
||||||
|
### 🚨 超时告警与通知
|
||||||
|
- **自动检测**:后台调度器每 60 秒扫描未按时执行的任务
|
||||||
|
- **宽容窗口**:每个任务可单独配置容忍时间,避免误报
|
||||||
|
- **多渠道通知**:飞书 Webhook、邮件 SMTP、通用 Webhook
|
||||||
|
- **告警确认**:已处理的告警可标记确认,闭环管理
|
||||||
|
|
||||||
|
### 🎨 现代化界面
|
||||||
|
- **暗色主题**:深色渐变背景 + 霓虹色点缀,降低视觉疲劳
|
||||||
|
- **全宽布局**:充分利用屏幕空间
|
||||||
|
- **响应式**:适配不同分辨率
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
| 层 | 技术 |
|
| 层 | 技术 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 后端 | Python FastAPI (异步) + Gunicorn (生产) |
|
| 后端框架 | Python FastAPI (异步) |
|
||||||
| 前端 | Vue3 + Element Plus (暗色主题) |
|
| 生产服务 | Gunicorn + Uvicorn Worker |
|
||||||
|
| 前端框架 | Vue 3 + Element Plus |
|
||||||
|
| 构建工具 | Vite |
|
||||||
| 数据库 | MySQL 8.0 |
|
| 数据库 | MySQL 8.0 |
|
||||||
| 部署 | 单体应用(FastAPI serve Vue3 构建产物) |
|
| 数据校验 | Pydantic v2 |
|
||||||
|
| ORM | SQLAlchemy 2.0 (异步) |
|
||||||
|
| 认证 | JWT (python-jose) + bcrypt |
|
||||||
|
| 定时检测 | APScheduler (异步) |
|
||||||
|
| 部署形态 | 单体应用 (FastAPI serve 前端静态文件) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -30,88 +98,55 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 克隆项目
|
# 1. 克隆项目
|
||||||
git clone https://gitea.tatta.cn/Tatta/TaskPulse.git
|
git clone https://github.com/oventh/TaskPulse.git
|
||||||
cd TaskPulse
|
cd TaskPulse
|
||||||
|
|
||||||
# 2. 后端 - 创建虚拟环境并安装依赖
|
# 2. 后端
|
||||||
cd backend
|
cd backend
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
# Windows: .venv\Scripts\activate
|
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
# Linux: source .venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # 编辑 .env 配置数据库
|
||||||
# 3. 复制环境变量并修改数据库配置
|
|
||||||
cp .env.example .env
|
|
||||||
# 编辑 .env,配置 MySQL 连接信息
|
|
||||||
|
|
||||||
# 4. 启动后端开发服务器
|
|
||||||
uvicorn app.main:app --reload --port 8000
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
|
||||||
# 5. 前端(新开终端)
|
# 3. 前端(新终端)
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
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
|
```bash
|
||||||
# 1. 构建前端
|
# 1. 构建前端
|
||||||
cd frontend
|
cd frontend && npm install && npm run build
|
||||||
npm install && npm run build
|
|
||||||
|
|
||||||
# 2. 部署到服务器
|
# 2. 部署到服务器
|
||||||
# 将整个项目复制到服务器(排除 .venv 和 node_modules)
|
|
||||||
rsync -avz --exclude='.venv' --exclude='node_modules' --exclude='.git' \
|
rsync -avz --exclude='.venv' --exclude='node_modules' --exclude='.git' \
|
||||||
./ user@server:/home/openclaw/app/taskpulse/
|
./ user@server:/home/app/taskpulse/
|
||||||
|
|
||||||
# 3. 在服务器上创建虚拟环境并安装依赖
|
# 3. 安装依赖 + Gunicorn 启动
|
||||||
cd /home/openclaw/app/taskpulse/backend
|
cd /home/app/taskpulse/backend
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||||
.venv/bin/pip install -r requirements.txt
|
|
||||||
|
|
||||||
# 4. 使用 Gunicorn 启动
|
|
||||||
.venv/bin/gunicorn app.main:app \
|
.venv/bin/gunicorn app.main:app \
|
||||||
-k uvicorn.workers.UvicornWorker \
|
-k uvicorn.workers.UvicornWorker \
|
||||||
-b 0.0.0.0:8000 \
|
-b 0.0.0.0:8000 -w 2 --preload --daemon
|
||||||
-w 2 \
|
|
||||||
--access-logfile /var/log/taskpulse/access.log \
|
|
||||||
--error-logfile /var/log/taskpulse/error.log \
|
|
||||||
--daemon
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 环境变量
|
---
|
||||||
|
|
||||||
复制 `backend/.env.example` 为 `backend/.env` 并按需修改:
|
## Agent 接入指南
|
||||||
|
|
||||||
| 变量 | 默认值 | 说明 |
|
让 AI Agent 自动接入只需 3 步:
|
||||||
|------|--------|------|
|
|
||||||
| 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)。
|
|
||||||
|
|
||||||
### 最小接入示例
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import requests
|
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={
|
resp = requests.post(f"{BASE}/api/agents/register-with-tasks", json={
|
||||||
"name": "my-agent",
|
"name": "my-agent",
|
||||||
"description": "我的 AI Agent",
|
"description": "我的 AI Agent",
|
||||||
@@ -120,38 +155,81 @@ resp = requests.post(f"{BASE}/api/agents/register-with-tasks", json={
|
|||||||
{"name": "daily-report", "cron_expression": "0 9 * * *"}
|
{"name": "daily-report", "cron_expression": "0 9 * * *"}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
agent = resp.json()
|
data = resp.json()
|
||||||
API_KEY = agent["api_key"] # 保存此 Key
|
API_KEY = data["agent"]["api_key"] # ← 保存此 Key
|
||||||
AGENT_ID = agent["agent"]["id"]
|
AGENT_ID = data["agent"]["id"]
|
||||||
|
|
||||||
# 2. 每次执行后汇报结果
|
# 2. 每次执行完任务后,汇报结果
|
||||||
requests.post(f"{BASE}/api/tasks/{TASK_ID}/executions",
|
requests.post(f"{BASE}/api/tasks/{TASK_ID}/executions",
|
||||||
headers={"Authorization": f"Bearer {API_KEY}"},
|
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 概览
|
## API 概览
|
||||||
|
|
||||||
|
### Agent 管理
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| POST | /api/agents | 注册 Agent |
|
| POST | `/api/agents` | 注册 Agent(返回 API Key) |
|
||||||
| POST | /api/agents/register-with-tasks | **AI Agent 一次性注册自己 + 所有任务** |
|
| **POST** | **`/api/agents/register-with-tasks`** | **🌟 Agent + 任务一次性批量注册** |
|
||||||
| GET | /api/agents | 列出所有 Agent |
|
| GET | `/api/agents` | 列出所有 Agent |
|
||||||
| POST | /api/tasks?agent_id=X | 注册定时任务 |
|
| PUT | `/api/agents/{id}` | 修改 Agent 名称/描述 |
|
||||||
| GET | /api/tasks | 列出所有任务 |
|
| DELETE | `/api/agents/{id}` | 删除 Agent(关联任务一并删除) |
|
||||||
| 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 | 列出通知渠道 |
|
|
||||||
|
|
||||||
完整 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/
|
taskpulse/
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── main.py # FastAPI 入口 + SPA 静态文件服务
|
│ │ ├── main.py # FastAPI 入口 + SPA 静态文件 + 生命周期
|
||||||
│ │ ├── config.py # 环境变量配置
|
│ │ ├── config.py # 环境变量配置 (pydantic-settings)
|
||||||
│ │ ├── database.py # 异步数据库连接
|
│ │ ├── database.py # 异步 MySQL 连接
|
||||||
│ │ ├── models/ # SQLAlchemy 数据模型
|
│ │ ├── models/ # SQLAlchemy 数据模型(6 张表)
|
||||||
│ │ │ ├── agent.py # Agent(含 API Key 自动生成)
|
│ │ │ ├── agent.py # Agent + API Key
|
||||||
│ │ │ ├── task.py # 定时任务(cron、宽容窗口)
|
│ │ │ ├── task.py # 定时任务 + cron + tags
|
||||||
│ │ │ ├── execution.py # 执行记录(日志、结果、耗时)
|
│ │ │ ├── execution.py # 执行记录 + 日志
|
||||||
│ │ │ ├── notification.py # 通知渠道、告警规则、告警历史
|
│ │ │ ├── notification.py # 通知渠道 + 告警规则 + 告警
|
||||||
│ │ │ └── system_config.py # 系统配置(key-value)
|
│ │ │ ├── system_config.py # 系统配置 (key-value)
|
||||||
│ │ ├── schemas/ # Pydantic 数据校验
|
│ │ │ └── user.py # 看板登录用户
|
||||||
│ │ ├── api/ # API 路由
|
│ │ ├── schemas/ # Pydantic 请求/响应模型
|
||||||
│ │ │ ├── agents.py # Agent CRUD + 批量注册
|
│ │ ├── api/ # REST API 路由(7 个模块)
|
||||||
│ │ │ ├── tasks.py # 任务 CRUD
|
│ │ └── services/ # 业务逻辑层
|
||||||
│ │ │ ├── executions.py # 执行汇报 + 历史查询
|
│ │ ├── scheduler.py # ⏰ 后台调度器(超时检测)
|
||||||
│ │ │ ├── notifications.py # 通知渠道 + 告警规则 + 告警确认
|
│ │ └── notification.py # 📢 飞书/邮件/Webhook 发送
|
||||||
│ │ │ ├── dashboard.py # 看板概览统计
|
|
||||||
│ │ │ └── system.py # 系统配置
|
|
||||||
│ │ └── services/ # 业务逻辑
|
|
||||||
│ │ ├── agent.py / task.py / execution.py
|
|
||||||
│ │ ├── scheduler.py # 后台扫描:检测超时任务→生成告警→通知
|
|
||||||
│ │ └── notification.py # 飞书 / 邮件 / Webhook 通知发送
|
|
||||||
│ ├── alembic/ # 数据库迁移
|
│ ├── alembic/ # 数据库迁移
|
||||||
│ └── requirements.txt
|
│ └── requirements.txt
|
||||||
├── frontend/
|
├── frontend/
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── views/ # 页面组件(暗色主题)
|
│ │ ├── views/ # 6 个页面(暗色主题)
|
||||||
│ │ ├── router/ # 路由
|
│ │ ├── router/ # 路由守卫 + 登录跳转
|
||||||
│ │ └── api/ # Axios API 客户端
|
│ │ ├── api/ # Axios 客户端 + 401 拦截
|
||||||
|
│ │ └── assets/ # 全局样式 + Element Plus 覆盖
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── AGENT_GUIDE.md # Agent 接入指南
|
├── AGENT_GUIDE.md # Agent API 接入说明
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT License © 2026
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
"""API router — TaskExecution (reporting + query)."""
|
"""API router — TaskExecution (reporting + query)."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.schemas import ExecutionOut, ExecutionReport
|
from app.schemas import ExecutionOut, ExecutionReport
|
||||||
from app.services import ExecutionService, TaskService, AgentService
|
from app.services import ExecutionService, TaskService, AgentService
|
||||||
|
from app.models import TaskExecution
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/tasks/{task_id}/executions", tags=["executions"])
|
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)
|
@router.post("", response_model=ExecutionOut, status_code=201)
|
||||||
async def report_execution(task_id: int, body: ExecutionReport,
|
async def report_execution(task_id: int, body: ExecutionReport,
|
||||||
db: AsyncSession = Depends(get_db)):
|
db: AsyncSession = Depends(get_db)):
|
||||||
@@ -46,16 +56,27 @@ async def report_execution(task_id: int, body: ExecutionReport,
|
|||||||
return ExecutionOut(**record.__dict__)
|
return ExecutionOut(**record.__dict__)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ExecutionOut])
|
@router.get("", response_model=PaginatedExecutions)
|
||||||
async def list_executions(
|
async def list_executions(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
limit: int = Query(50, ge=1, le=200),
|
page: int = Query(1, ge=1),
|
||||||
offset: int = Query(0, ge=0),
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
exec_svc = ExecutionService(db)
|
exec_svc = ExecutionService(db)
|
||||||
records = await exec_svc.get_executions(task_id, limit=limit, offset=offset)
|
offset = (page - 1) * page_size
|
||||||
return [ExecutionOut(**r.__dict__) for r in records]
|
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])
|
@router.get("/recent", response_model=list[ExecutionOut])
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { beijing } from '../utils/time.js'
|
import dayjs from 'dayjs'
|
||||||
import { listAlerts, acknowledgeAlert } from '../api/index.js'
|
import { listAlerts, acknowledgeAlert } from '../api/index.js'
|
||||||
|
|
||||||
const alerts = ref([])
|
const alerts = ref([])
|
||||||
|
|||||||
@@ -152,7 +152,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { beijing } from '../utils/time.js'
|
import dayjs from 'dayjs'
|
||||||
import { getDashboardSummary, getDashboardTasks, getSystemConfig } from '../api/index.js'
|
import { getDashboardSummary, getDashboardTasks, getSystemConfig } from '../api/index.js'
|
||||||
|
|
||||||
const summary = ref({
|
const summary = ref({
|
||||||
|
|||||||
@@ -72,6 +72,20 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Log Dialog -->
|
<!-- Log Dialog -->
|
||||||
@@ -90,9 +104,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { beijing } from '../utils/time.js'
|
import dayjs from 'dayjs'
|
||||||
import { getTask, listExecutions } from '../api/index.js'
|
import { getTask, listExecutions } from '../api/index.js'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -103,14 +117,37 @@ const logVisible = ref(false)
|
|||||||
const currentLog = ref('')
|
const currentLog = ref('')
|
||||||
const logExecution = ref(null)
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const [taskRes, execRes] = await Promise.all([
|
const [taskRes] = await Promise.all([
|
||||||
getTask(taskId),
|
getTask(taskId),
|
||||||
listExecutions(taskId, { limit: 100 }),
|
|
||||||
])
|
])
|
||||||
task.value = taskRes.data
|
task.value = taskRes.data
|
||||||
executions.value = execRes.data
|
await loadExecutions(1)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
@@ -243,4 +280,40 @@ const showLog = (row) => {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
margin: 0;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user