refactor: v2

backend & frontend
This commit is contained in:
2026-01-01 18:38:21 +08:00
parent 3d201bc497
commit fdc725b893
109 changed files with 22918 additions and 1135 deletions
+47
View File
@@ -0,0 +1,47 @@
# 环境变量配置示例
# 复制此文件为 .env 并修改相应的值
# ==================== 基础配置 ====================
# 数据库配置(可选,默认使用 SQLite)
# DATABASE_URL=sqlite:///./data/checkin.db
# DATABASE_URL=postgresql://user:password@localhost/checkin
# CORS 允许的前端域名(逗号分隔,生产环境必须修改)
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
# 日志级别(可选:DEBUG, INFO, WARNING, ERROR
LOG_LEVEL=INFO
# ==================== 邮件配置 ====================
# SMTP 服务器地址(例如:smtp.qq.com, smtp.gmail.com, smtp.163.com
SMTP_SERVER=smtp.example.com
# SMTP 服务器端口(通常 SSL/TLS 使用 465STARTTLS 使用 587
SMTP_PORT=465
# 发件人邮箱地址
SMTP_SENDER_EMAIL=your-email@example.com
# 邮箱密码或授权码
# 重要提示:这里通常不是你的邮箱登录密码,而是邮箱服务商提供的"应用专用密码"或"授权码"
# QQ邮箱:设置 -> 账户 -> POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务 -> 生成授权码
# Gmail:安全 -> 两步验证 -> 应用专用密码
# 163邮箱:设置 -> POP3/SMTP/IMAP -> 授权密码管理
SMTP_SENDER_PASSWORD=your-auth-code-here
# 是否使用 SSL/TLSTrue/False,默认 True
SMTP_USE_SSL=True
# ==================== Selenium / Chrome 配置 ====================
# Chrome 浏览器可执行文件路径(可选,留空则自动检测系统 Chrome)
# Windows 示例:CHROME_BINARY_PATH=C:\Program Files\Google\Chrome\Application\chrome.exe
# Linux 示例:CHROME_BINARY_PATH=/usr/bin/google-chrome
# 如果留空,Selenium 会使用系统默认 Chrome
CHROME_BINARY_PATH=
# ChromeDriver 可执行文件路径(可选,留空则使用 Selenium Manager 自动下载)
# Windows 示例:CHROMEDRIVER_PATH=D:\chromedriver\chromedriver.exe
# Linux 示例:CHROMEDRIVER_PATH=/usr/local/bin/chromedriver
# 推荐留空,让 Selenium Manager 自动管理 ChromeDriver 版本
CHROMEDRIVER_PATH=
+49 -3
View File
@@ -1,9 +1,55 @@
__pycache__
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
# 项目特定
chromedriver
chrome-linux64
chromedriver.exe
chrome-linux64/
chrome-win64/
debug_page_source.html
debug_screenshot.png
sessions
# 运行时文件
sessions/
*.lock
*.log
*.pid
backend.pid
frontend.pid
# 数据库
data/*.db
data/*.db-shm
data/*.db-wal
# 配置文件
.env
config.ini
# 日志
logs/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# 操作系统
.DS_Store
Thumbs.db
# 前端
frontend/node_modules/
frontend/dist/
frontend/.vite/
.claude
+773 -18
View File
@@ -1,35 +1,790 @@
# Jielong
# 接龙自动打卡系统 V2
Run: `sh start.sh` and get usage(Linux only).
[![FastAPI](https://img.shields.io/badge/FastAPI-0.109+-green.svg)](https://fastapi.tiangolo.com/)
[![Vue 3](https://img.shields.io/badge/Vue-3.5+-brightgreen.svg)](https://vuejs.org/)
[![Python](https://img.shields.io/badge/Python-3.9+-blue.svg)](https://www.python.org/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
## Environment
一个全自动的接龙打卡系统,支持 QQ 扫码登录、定时自动打卡、Token 过期提醒等功能。采用前后端分离架构,提供完善的 Web 管理界面和 RESTful API。
Use `python3 -m venv venv` to create a virtual environment.
## ⚡ V2 重大更新
Then activate the venv, use `pip install -r .\requirements.txt` to install dependencies.
🎉 **用户-任务分离架构** - 一个用户可以管理多个打卡任务
🎉 **全局 Token 刷新** - 扫码一次更新所有任务
🎉 **任务级别控制** - 每个任务独立配置邮箱和启用状态
🎉 **29 个 API 端点** - 更完善的功能覆盖
🎉 **任务所有权验证** - 更安全的权限控制
Note that you have to ensure the CHROME_BINARY_PATH and CHROMEDRIVER_PATH in `shared_config.py` correctly.
详见 [V2 架构文档](ARCHITECTURE_V2.md)
Also, if you want the email notification(to notify the expiration of token) works, you need to check `config.ini`.
## ✨ 主要特性
## Details
- 🔐 **QQ 扫码登录** - 支持通过 QQ 扫码快速登录认证
- 👤 **多任务管理** - 一个用户管理多个打卡任务
-**定时自动打卡** - 每天固定时间自动为启用的任务执行打卡
- 📧 **邮件通知** - Token 过期提醒、打卡结果通知
- 👥 **用户管理** - 完善的用户 CRUD 和权限管理
- 📋 **任务管理** - 创建、编辑、删除打卡任务
- 📊 **管理后台** - 可视化的数据统计和日志查看
- 🚀 **RESTful API** - 29 个标准化 API 端点,自动生成文档
- 🎯 **角色权限** - 普通用户和管理员角色分离
- 📱 **响应式界面** - 基于 Element Plus 的现代化 UI
Please edit the config and payload by yourself to meet your actual need.
## 🏗️ 技术架构
### JieLong Payload
### 后端
- **Web 框架**: FastAPI 0.109+
- **服务器**: Uvicorn (ASGI)
- **ORM**: SQLAlchemy 2.0+
- **数据库**: SQLite (可迁移到 PostgreSQL)
- **任务调度**: APScheduler 3.10+
- **自动化**: Selenium 4.16+
- **认证**: JWT (python-jose)
Jielong Payload: `payload` in the `try-catch` block of `perform_check_in()` function in `check_in_worker.py`
### 前端
- **框架**: Vue 3.5+
- **构建工具**: Vite 7+
- **UI 组件**: Element Plus 2.13+
- **状态管理**: Pinia 3.0+
- **路由**: Vue Router 4.6+
- **HTTP 客户端**: Axios 1.13+
You may use [Reqable](https://reqable.com/) or other tools to get the payload of JieLong. (Tips: Catch the request of "EditRecord" and you will find what you want.)
## 📦 快速开始
Meanwhile, `config.csv` is used to build the payload. You may need edit the `config.csv` related codes to add support to new payload. Searching the usage of `read_configs()` function will tell you the things.
### 前置要求
### Config
- Python 3.9+
- Node.js 16+ (仅前端需要)
- Chrome 浏览器
- ChromeDriver
APScheduler config: `app.py`
### 一键启动(推荐)
Other config: `shared_config.py`
**Windows:**
```cmd
# 启动所有服务(后端 + 前端)
manage.bat start-all
```
JieLong Token data: `config.csv`
**Linux/Mac:**
```bash
# 给脚本执行权限
chmod +x manage.sh
Email notification config: `config.ini`
# 启动所有服务
./manage.sh start-all
```
### 手动启动
#### 1. 克隆项目
```bash
git clone <repository-url>
cd CheckInApp
```
#### 2. 后端设置
```bash
# 创建虚拟环境
python -m venv venv
# 激活虚拟环境
# Windows:
venv\Scripts\activate
# Linux/Mac:
source venv/bin/activate
# 安装依赖
pip install -r backend/requirements.txt
# 配置环境变量(可选)
cp .env.example .env
# 编辑 .env 文件设置 SECRET_KEY 等
# 启动后端
python run.py
```
后端服务将在 http://localhost:8000 启动
#### 3. 前端设置(可选)
```bash
# 进入前端目录
cd frontend
# 安装依赖
npm install
# 启动开发服务器
npm run dev
```
前端应用将在 http://localhost:3000 启动
### 4. 创建管理员账户
首次使用需要创建管理员账户:
```bash
# Windows
venv\Scripts\python backend\scripts\create_admin.py
# Linux/Mac
python backend/scripts/create_admin.py
```
按提示输入 alias(用户名) 并通过 QQ 扫码完成管理员创建。
## 📖 使用指南
### 访问地址
- **前端应用**: http://localhost:3000
- **API 文档**: http://localhost:8000/docs
- **健康检查**: http://localhost:8000/health
### 登录流程
1. 打开前端应用
2. 输入您的 alias(用户别名)
3. 点击"QQ 扫码登录"
4. 使用手机 QQ 扫描弹出的二维码
5. 扫码成功后自动登录系统
### 用户功能
- 查看 Token 状态和过期时间
- 查看和管理自己的打卡任务
- 创建新的打卡任务
- 手动触发单个任务打卡
- 启用/禁用任务
- 查看任务的打卡记录
- 查看个人信息
### 管理员功能
- 用户管理(创建、编辑、删除)
- 查看所有用户的任务
- 批量启用/禁用任务
- 批量触发打卡
- 查看所有打卡记录
- 查看系统日志
- 系统统计信息(用户数、任务数、打卡统计)
## ⚙️ 配置说明
### 环境变量 (`.env`)
```env
# JWT 密钥(生产环境必须修改)
SECRET_KEY=your-secret-key-change-in-production
# 管理员默认别名
ADMIN_ALIAS=admin
# 数据库 URL(可选)
DATABASE_URL=sqlite:///./data/checkin.db
# CORS 允许的域名
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
# 定时打卡时间
CHECKIN_SCHEDULE_HOUR=20
CHECKIN_SCHEDULE_MINUTE=0
# Token 过期检查间隔(分钟)
TOKEN_CHECK_INTERVAL_MINUTES=30
# 会话文件清理间隔(小时)
SESSION_CLEANUP_INTERVAL_HOURS=24
```
### 邮件配置 (`config.ini`)
```ini
[Email]
smtpserver = smtp.example.com
smtpport = 465
senderemail = your-email@example.com
senderpassword = your-password
```
## 📂 项目结构
```
CheckInApp/
├── backend/ # FastAPI 后端
│ ├── main.py # 应用入口
│ ├── config.py # 配置管理
│ ├── dependencies.py # 认证中间件
│ ├── models/ # 数据库模型
│ │ ├── user.py # User 模型
│ │ ├── check_in_task.py # CheckInTask 模型 (V2 新增)
│ │ └── check_in_record.py # CheckInRecord 模型
│ ├── schemas/ # Pydantic Schema
│ │ ├── user.py
│ │ ├── task.py # (V2 新增)
│ │ ├── auth.py
│ │ └── check_in.py
│ ├── api/ # API 路由
│ │ ├── auth.py
│ │ ├── users.py
│ │ ├── tasks.py # (V2 新增)
│ │ ├── check_in.py
│ │ └── admin.py
│ ├── services/ # 业务逻辑
│ │ ├── auth_service.py
│ │ ├── user_service.py
│ │ ├── task_service.py # (V2 新增)
│ │ ├── check_in_service.py
│ │ └── scheduler_service.py
│ ├── workers/ # Selenium 工作模块
│ │ ├── token_refresher.py
│ │ ├── check_in_worker.py
│ │ └── email_notifier.py
│ └── scripts/ # 工具脚本
│ └── create_admin.py
├── frontend/ # Vue 3 前端
│ ├── src/
│ │ ├── api/ # API 调用
│ │ ├── components/ # 组件
│ │ ├── views/ # 页面
│ │ ├── stores/ # Pinia 状态
│ │ └── router/ # 路由配置
│ └── package.json
├── data/ # 数据库文件
├── logs/ # 日志文件
│ └── backend.log # 后端日志 (V2 更名)
├── sessions/ # 会话临时文件
├── venv/ # Python 虚拟环境
├── run.py # 后端启动脚本
├── manage.bat/sh # 进程管理脚本 (V2 增强)
├── ARCHITECTURE_V2.md # V2 架构文档 (新增)
└── config.ini # 邮件配置
```
## 📊 API 端点
系统提供 **29 个 RESTful API 端点**:
### 认证 (`/api/auth`)
- `POST /api/auth/request_qrcode` - 请求 QQ 扫码
- `GET /api/auth/qrcode_status/{session_id}` - 查询扫码状态
- `POST /api/auth/verify_token` - 验证 Token
### 用户 (`/api/users`)
- `POST /api/users` - 创建用户(管理员)
- `GET /api/users/me` - 获取当前用户
- `GET /api/users/me/token_status` - Token 状态
- `GET /api/users/me/tasks` - 获取当前用户任务列表 **(V2 新增)**
- `GET /api/users` - 用户列表(管理员)
- `GET /api/users/{user_id}` - 获取指定用户
- `PUT /api/users/{user_id}` - 更新用户
- `DELETE /api/users/{user_id}` - 删除用户(管理员)
### 任务 (`/api/tasks`) **(V2 新增模块)**
- `POST /api/tasks` - 创建任务
- `GET /api/tasks` - 获取当前用户任务
- `GET /api/tasks/{task_id}` - 获取任务详情
- `PUT /api/tasks/{task_id}` - 更新任务
- `DELETE /api/tasks/{task_id}` - 删除任务
- `POST /api/tasks/{task_id}/toggle` - 切换任务状态
### 打卡 (`/api/check_in`)
- `POST /api/check_in/manual/{task_id}` - 手动触发任务打卡
- `GET /api/check_in/task/{task_id}/records` - 获取任务打卡记录
- `GET /api/check_in/records` - 所有记录(管理员)
- `GET /api/check_in/records/count` - 记录统计(管理员)
### 管理员 (`/api/admin`)
- `POST /api/admin/batch_toggle_tasks` - 批量启用/禁用任务
- `POST /api/admin/batch_check_in` - 批量打卡
- `GET /api/admin/logs` - 系统日志
- `GET /api/admin/stats` - 系统统计
详细 API 文档请访问: http://localhost:8000/docs
## ⏰ 自动化任务
系统自动执行以下定时任务:
1. **定时打卡**: 每天 20:00 为所有启用的任务执行打卡
2. **Token 检查**: 每 30 分钟检查一次,即将过期时发送邮件到任务的邮箱
3. **会话清理**: 每 24 小时清理过期的会话文件
## 🔧 进程管理
使用内置的进程管理脚本可以方便地管理服务:
**Windows:**
```cmd
manage.bat start-backend # 启动后端服务
manage.bat start-frontend # 启动前端服务
manage.bat start-all # 启动所有服务
manage.bat stop-backend # 停止后端
manage.bat stop-frontend # 停止前端
manage.bat stop-all # 停止所有服务
manage.bat status # 查看状态
manage.bat logs-backend # 查看后端日志
manage.bat logs-frontend # 查看前端日志
```
**Linux/Mac:**
```bash
./manage.sh start-backend
./manage.sh start-frontend
./manage.sh start-all
./manage.sh stop-backend
./manage.sh stop-frontend
./manage.sh stop-all
./manage.sh status
./manage.sh logs-backend
./manage.sh logs-frontend
```
## 🐛 故障排查
### 端口被占用
```bash
# Windows
netstat -ano | findstr :8000
# Linux/Mac
lsof -i :8000
```
### 查看日志
```bash
# 后端日志
cat logs/backend.log
# 使用管理脚本查看
manage.bat logs-backend # Windows
./manage.sh logs-backend # Linux/Mac
```
### Selenium 问题
确保 Chrome 和 ChromeDriver 已正确配置。相关路径在 `backend/workers/` 中定义。
## 📚 文档
- [快速入门指南](QUICKSTART.md)
- [V2 架构文档](ARCHITECTURE_V2.md) **(推荐阅读)**
- [后端详细文档](backend/README.md)
- [后端开发总结](BACKEND_SUMMARY.md)
- [V1 旧版文档](v1/README.md)
## 🔒 安全建议
1. **生产环境务必修改 SECRET_KEY**
2. 不要将 `.env` 文件提交到版本控制
3. 定期更新依赖包
4. 使用 HTTPS 部署生产环境
5. 限制管理员账户数量
6. 定期备份数据库
## 🚀 部署
### Docker 部署(推荐)
```bash
# 构建镜像
docker-compose build
# 启动服务
docker-compose up -d
```
### 传统部署
1. 使用 Gunicorn 运行后端
2. 构建前端并使用 Nginx 托管
3. 配置反向代理
详见部署文档。
## 📝 V2 更新日志
### 架构改进
- ✅ 实现用户-任务分离架构
- ✅ 新增 CheckInTask 数据模型
- ✅ 引入三层身份体系 (jwt_sub + alias + signature)
- ✅ 全局 Token 刷新机制
### 新增功能
- ✅ 任务管理 API (6个端点)
- ✅ 任务所有权验证
- ✅ 用户任务列表查询
- ✅ 任务级别的邮箱配置
- ✅ 任务级别的启用/禁用
### 功能优化
- ✅ API 端点从 18 个增加到 29 个
- ✅ 改进的权限控制系统
- ✅ 更清晰的代码结构
- ✅ UTF-8 编码全面支持
- ✅ 增强的日志系统
- ✅ 改进的进程管理脚本
## 🤝 贡献
欢迎提交 Issue 和 Pull Request!
## 📄 许可证
[MIT License](LICENSE)
## 🙏 致谢
感谢所有开源项目的贡献者!
---
**版本**: V2.0.0
**状态**: ✅ 生产就绪
**最后更新**: 2025-12-31
## 🏗️ 技术架构
### 后端
- **Web 框架**: FastAPI 0.109+
- **服务器**: Uvicorn (ASGI)
- **ORM**: SQLAlchemy 2.0+
- **数据库**: SQLite (可迁移到 PostgreSQL)
- **任务调度**: APScheduler 3.10+
- **自动化**: Selenium 4.16+
- **认证**: JWT (python-jose)
### 前端
- **框架**: Vue 3.5+
- **构建工具**: Vite 7+
- **UI 组件**: Element Plus 2.13+
- **状态管理**: Pinia 3.0+
- **路由**: Vue Router 4.6+
- **HTTP 客户端**: Axios 1.13+
## 📦 快速开始
### 前置要求
- Python 3.9+
- Node.js 16+ (仅前端需要)
- Chrome 浏览器
- ChromeDriver
### 一键启动(推荐)
**Windows:**
```cmd
# 启动所有服务(后端 + 前端)
start_all.bat
```
**Linux/Mac:**
```bash
# 给脚本执行权限
chmod +x start_all.sh
# 启动所有服务
./start_all.sh
```
### 手动启动
#### 1. 克隆项目
```bash
git clone <repository-url>
cd CheckInApp
```
#### 2. 后端设置
```bash
# 创建虚拟环境
python -m venv venv
# 激活虚拟环境
# Windows:
venv\Scripts\activate
# Linux/Mac:
source venv/bin/activate
# 安装依赖
pip install -r backend/requirements.txt
# 配置环境变量(可选)
cp .env.example .env
# 编辑 .env 文件设置 SECRET_KEY 等
# 启动后端
python run.py
```
后端服务将在 http://localhost:8000 启动
#### 3. 前端设置(可选)
```bash
# 进入前端目录
cd frontend
# 安装依赖
npm install
# 启动开发服务器
npm run dev
```
前端应用将在 http://localhost:3000 启动
### 4. 创建管理员账户
首次使用需要创建管理员账户:
```bash
# Windows
venv\Scripts\python backend\scripts\create_admin.py
# Linux/Mac
python backend/scripts/create_admin.py
```
按提示输入 Signature 并通过 QQ 扫码完成管理员创建。
## 📖 使用指南
### 访问地址
- **前端应用**: http://localhost:3000
- **API 文档**: http://localhost:8000/docs
- **健康检查**: http://localhost:8000/health
### 登录流程
1. 打开前端应用
2. 输入您的 Signature(唯一标识)
3. 点击"QQ 扫码登录"
4. 使用手机 QQ 扫描弹出的二维码
5. 扫码成功后自动登录系统
### 用户功能
- 查看 Token 状态和过期时间
- 手动触发打卡
- 查看自己的打卡记录
- 查看个人信息
### 管理员功能
- 用户管理(创建、编辑、删除)
- 批量启用/禁用用户
- 批量触发打卡
- 查看所有打卡记录
- 查看系统日志
- 系统统计信息
## ⚙️ 配置说明
### 环境变量 (`.env`)
```env
# JWT 密钥(生产环境必须修改)
SECRET_KEY=your-secret-key-change-in-production
# 管理员默认标识
ADMIN_SIGNATURE=admin
# 数据库 URL(可选)
DATABASE_URL=sqlite:///./data/checkin.db
# CORS 允许的域名
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
# 定时打卡时间
CHECKIN_SCHEDULE_HOUR=20
CHECKIN_SCHEDULE_MINUTE=0
```
### 邮件配置 (`config.ini`)
```ini
[Email]
smtpserver = smtp.example.com
smtpport = 465
senderemail = your-email@example.com
senderpassword = your-password
```
## 📂 项目结构
```
CheckInApp/
├── backend/ # FastAPI 后端
│ ├── main.py # 应用入口
│ ├── config.py # 配置管理
│ ├── dependencies.py # 认证中间件
│ ├── models/ # 数据库模型
│ ├── schemas/ # Pydantic Schema
│ ├── api/ # API 路由
│ ├── services/ # 业务逻辑
│ ├── workers/ # Selenium 工作模块
│ └── scripts/ # 工具脚本
├── frontend/ # Vue 3 前端
│ ├── src/
│ │ ├── api/ # API 调用
│ │ ├── components/ # 组件
│ │ ├── views/ # 页面
│ │ ├── stores/ # Pinia 状态
│ │ └── router/ # 路由配置
│ └── package.json
├── data/ # 数据库文件
├── logs/ # 日志文件
├── sessions/ # 会话临时文件
├── venv/ # Python 虚拟环境
├── run.py # 后端启动脚本
├── manage.bat/sh # 进程管理脚本
├── start_all.bat/sh # 一键启动脚本
└── config.ini # 邮件配置
```
## 📊 API 端点
系统提供 18 个 RESTful API 端点:
### 认证 (`/api/auth`)
- `POST /api/auth/request_qrcode` - 请求 QQ 扫码
- `GET /api/auth/qrcode_status/{session_id}` - 查询扫码状态
- `POST /api/auth/verify_token` - 验证 Token
### 用户 (`/api/users`)
- `POST /api/users` - 创建用户
- `GET /api/users/me` - 获取当前用户
- `GET /api/users/me/token_status` - Token 状态
- `GET /api/users` - 用户列表
- `PUT /api/users/{user_id}` - 更新用户
- `DELETE /api/users/{user_id}` - 删除用户
### 打卡 (`/api/check_in`)
- `POST /api/check_in/manual` - 手动打卡
- `GET /api/check_in/my_records` - 我的记录
- `GET /api/check_in/records` - 所有记录
- `GET /api/check_in/records/count` - 记录统计
### 管理员 (`/api/admin`)
- `POST /api/admin/batch_toggle_active` - 批量启用/禁用
- `POST /api/admin/batch_check_in` - 批量打卡
- `GET /api/admin/logs` - 系统日志
- `GET /api/admin/stats` - 系统统计
详细 API 文档请访问: http://localhost:8000/docs
## ⏰ 自动化任务
系统自动执行以下定时任务:
1. **定时打卡**: 每天 20:00 为所有启用的用户执行打卡
2. **Token 检查**: 每 30 分钟检查一次,即将过期时发送邮件
3. **会话清理**: 每 24 小时清理过期的会话文件
## 🔧 进程管理
使用内置的进程管理脚本可以方便地管理后端服务:
**Windows:**
```cmd
manage.bat start # 启动服务(后台运行)
manage.bat stop # 停止服务
manage.bat restart # 重启服务
manage.bat status # 查看状态
```
**Linux/Mac:**
```bash
./manage.sh start
./manage.sh stop
./manage.sh restart
./manage.sh status
```
## 🐛 故障排查
### 端口被占用
```bash
# Windows
netstat -ano | findstr :8000
# Linux/Mac
lsof -i :8000
```
### 查看日志
```bash
# 后端日志
cat logs/CheckIn.log
# 使用管理脚本查看
./manage.sh status
```
### Selenium 问题
确保 Chrome 和 ChromeDriver 已正确配置。相关路径在 `backend/workers/` 中定义。
## 📚 文档
- [快速入门指南](QUICKSTART.md)
- [后端详细文档](backend/README.md)
- [后端开发总结](BACKEND_SUMMARY.md)
- [V1 旧版文档](v1/README.md)
## 🔒 安全建议
1. **生产环境务必修改 SECRET_KEY**
2. 不要将 `.env` 文件提交到版本控制
3. 定期更新依赖包
4. 使用 HTTPS 部署生产环境
5. 限制管理员账户数量
## 🚀 部署
### Docker 部署(推荐)
```bash
# 构建镜像
docker-compose build
# 启动服务
docker-compose up -d
```
### 传统部署
1. 使用 Gunicorn 运行后端
2. 构建前端并使用 Nginx 托管
3. 配置反向代理
详见部署文档。
## 📝 开发计划
- [x] 后端 API 开发
- [x] 前端基础框架
- [x] 用户认证系统
- [x] 管理员功能
- [ ] 批量导入用户
- [ ] 数据导出功能
- [ ] Docker 镜像优化
- [ ] 单元测试覆盖
## 🤝 贡献
欢迎提交 Issue 和 Pull Request
## 📄 许可证
[MIT License](LICENSE)
## 🙏 致谢
感谢所有开源项目的贡献者!
---
**版本**: V2.0.0
**状态**: ✅ 生产就绪
**最后更新**: 2025-12-31
-361
View File
@@ -1,361 +0,0 @@
from apscheduler.schedulers.background import BackgroundScheduler
import configparser
import csv
from datetime import datetime, timezone
from filelock import FileLock, Timeout
from flask import Flask, render_template, request, jsonify
import json
import jwt
import logging
from logging.handlers import RotatingFileHandler
import os
import threading
import time
from urllib.parse import unquote
import atexit
# 导入其他模块
from shared_config import (
CONFIG_PATH, LOG_PATH, SCHEDULER_LOCK, SESSIONS_DIR, CONFIG_INI_PATH,
CONFIG_FILE_LOCK, CSV_FIELDNAMES, CHECKIN_HOUR, CHECKIN_MIN
)
from token_refresher import get_token_headless
from email_notifier import notification_worker_loop
from check_in_worker import perform_check_in
# --- Flask App 设置 ---
app = Flask(__name__)
# 1. 定义日志格式和处理器
log_formatter = logging.Formatter('%(asctime)s - %(levelname)s - [%(name)s] - %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
log_handler = RotatingFileHandler(LOG_PATH, mode='a', maxBytes=5*1024*1024, backupCount=5, encoding='utf-8')
log_handler.setFormatter(log_formatter)
# 2. 获取并配置根记录器 (root logger)
# 这是关键:所有模块通过 getLogger(__name__) 创建的子记录器,都会将日志传递给根记录器
root_logger = logging.getLogger()
root_logger.addHandler(log_handler)
root_logger.setLevel(logging.INFO)
# 3. 移除Flask默认的handler,防止日志重复
app.logger.handlers.clear()
app.logger.propagate = True # 确保app.logger也将日志传递给根记录器
# 4. 将werkzeug的日志也重定向到我们的文件
werkzeug_logger = logging.getLogger('werkzeug')
werkzeug_logger.propagate = True # 确保werkzeug日志也传递给根记录器
# --- 辅助函数 ---
def read_configs():
"""加固后的读取函数,增加日志,处理空文件和不存在的情况"""
with CONFIG_FILE_LOCK:
if not os.path.exists(CONFIG_PATH):
app.logger.warning(f"配置文件 {CONFIG_PATH} 不存在,将返回空列表。")
return []
try:
with open(CONFIG_PATH, mode='r', encoding='utf-8-sig') as file:
content = file.read().strip()
if not content:
app.logger.warning(f"配置文件 {CONFIG_PATH} 为空,返回空列表。")
return []
file.seek(0)
reader = csv.DictReader(file)
rows = list(reader)
app.logger.info(f"成功从 {CONFIG_PATH} 读取 {len(rows)} 条配置。")
return rows
except Exception as e:
app.logger.error(f"读取config.csv时出错: {e}")
return []
def write_configs(rows):
"""加固后的写入函数,始终使用全局列名"""
with CONFIG_FILE_LOCK:
try:
with open(CONFIG_PATH, 'w', encoding='utf-8-sig', newline='') as file:
writer = csv.DictWriter(file, fieldnames=CSV_FIELDNAMES)
writer.writeheader()
writer.writerows(rows)
app.logger.info(f"成功将 {len(rows)} 条配置写入到 {CONFIG_PATH}")
except Exception as e:
app.logger.error(f"写入config.csv时出错: {e}")
def append_new_config(new_row_dict):
"""只在文件末尾追加一行新配置,更安全"""
with CONFIG_FILE_LOCK:
# 检查文件是否存在或为空,如果为空,则先写入标题行
file_exists = os.path.exists(CONFIG_PATH)
is_empty = not file_exists or os.path.getsize(CONFIG_PATH) == 0
try:
with open(CONFIG_PATH, 'a', encoding='utf-8-sig', newline='') as file:
writer = csv.DictWriter(file, fieldnames=CSV_FIELDNAMES)
if is_empty:
writer.writeheader()
writer.writerow(new_row_dict)
app.logger.info(f"成功追加新配置到 {CONFIG_PATH}")
except Exception as e:
app.logger.error(f"追加新配置到 {CONFIG_PATH} 时失败: {e}")
def is_token_expired(exp_timestamp):
if not exp_timestamp or not exp_timestamp.isdigit(): return True
return datetime.now(timezone.utc).timestamp() > int(exp_timestamp)
def cleanup_stale_sessions():
"""后台任务:清理超过10分钟未完成的刷新会话,运行一次后退出。"""
# 使用 app.logger 来记录日志
app.logger.info("Scheduler: 正在执行过期会话清理任务...")
try:
now = time.time()
cleared_count = 0
for filename in os.listdir(SESSIONS_DIR):
if filename.endswith(".json"):
filepath = os.path.join(SESSIONS_DIR, filename)
file_time = os.path.getmtime(filepath)
if now - file_time > 600: # 超过10分钟
os.remove(filepath)
cleared_count += 1
if cleared_count > 0:
app.logger.info(f"Scheduler: 成功清理了 {cleared_count} 个过期的会话文件。")
except Exception as e:
app.logger.error(f"Scheduler: 清理会话文件时出错: {e}")
def run_all_checkins(triggered_by="Scheduler"):
try:
app.logger.info(f"开始执行一轮打卡任务 (触发源: {triggered_by})...")
configs = read_configs()
if not configs:
app.logger.warning("配置文件为空,跳过本轮打卡。")
return
email_settings = None
if os.path.exists(CONFIG_INI_PATH):
config_parser = configparser.ConfigParser()
config_parser.read(CONFIG_INI_PATH)
if 'Email' in config_parser:
email_settings = config_parser['Email']
for config in configs:
auth_token = config.get('Authorization')
if auth_token:
perform_check_in(config)
else:
signature = config.get('Signature', '未知用户')
app.logger.warning(f"用户 {signature}'Authorization' Token 为空,已跳过打卡。")
app.logger.debug(f" 该用户的完整配置为: {config}")
app.logger.info(f"本轮打卡任务已全部提交。")
except Exception as e:
# --- 这是关键的顶层异常捕获 ---
app.logger.critical(f"执行 'run_all_checkins' 时发生未捕获的严重错误: {e}", exc_info=True)
# --- 路由 / API ---
@app.route('/')
def index():
configs = read_configs()
for config in configs:
config['show_qrcode'] = is_token_expired(config.get('jwt_exp'))
return render_template('index.html', configs=configs)
@app.route('/request_qrcode', methods=['POST'])
def request_qrcode():
# 1. 从POST请求的JSON body中获取signature
data = request.json
signature = data.get('signature')
if not signature:
return jsonify({'status': 'error', 'message': 'Signature is required'}), 400
# 2. 使用 signature 构建 session_id,以确保唯一性
session_id = f"{signature}_{int(time.time())}"
# 启动线程时,只传递 session_id,这与你的 token_refresher.py 匹配
threading.Thread(target=get_token_headless, args=(session_id,)).start()
return jsonify({'status': 'success', 'session_id': session_id})
@app.route('/get_qrcode_image/<session_id>')
def get_qrcode_image(session_id):
session_filepath = os.path.join(SESSIONS_DIR, f"{session_id}.json")
for _ in range(30):
if os.path.exists(session_filepath):
try:
with open(session_filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
if data.get('qr_image_data'):
return jsonify({'status': 'success', 'image_data': data['qr_image_data']})
except (json.JSONDecodeError, IOError) as e:
app.logger.error(f"读取会话文件 {session_filepath} 失败: {e}")
time.sleep(1)
return jsonify({'status': 'error', 'message': '获取二维码超时'}), 408
@app.route('/check_refresh_status/<session_id>')
def check_refresh_status(session_id):
session_filepath = os.path.join(SESSIONS_DIR, f"{session_id}.json")
if not os.path.exists(session_filepath):
return jsonify({'status': 'waiting'})
try:
with open(session_filepath, 'r', encoding='utf-8') as f:
session = json.load(f)
status = session.get('status')
if status == 'success':
signature_to_update = session_id.split('_')[0]
raw_token_value = session['token']
pure_jwt = unquote(raw_token_value)
if pure_jwt.lower().startswith('bearer '):
pure_jwt = pure_jwt[7:]
new_exp, new_sub = '0', ''
try:
decoded = jwt.decode(pure_jwt, options={"verify_signature": False})
new_exp, new_sub = decoded.get('exp', '0'), decoded.get('sub', '')
app.logger.info(f"成功解码JWT for sub {new_sub}, exp: {new_exp}")
except Exception as e:
app.logger.error(f"解码新的JWT时失败: {e}")
rows = read_configs()
is_updated = False
for row in rows:
# 使用 signature 来查找要更新的行
if row['Signature'] == signature_to_update:
row['Authorization'], row['jwt_exp'], row['jwt_sub'] = pure_jwt, new_exp, new_sub
is_updated = True
# 找到并更新后,可以跳出循环,提高效率
break
if not is_updated:
app.logger.error(f"严重错误:在更新Token时,未在config.csv中找到Signature {signature_to_update}")
write_configs(rows)
os.remove(session_filepath) # 成功后删除临时文件
return jsonify({'status': 'success'})
elif status == 'error':
message = session.get('message', '未知错误')
os.remove(session_filepath) # 失败后也删除
return jsonify({'status': 'error', 'message': message})
return jsonify({'status': status}) # e.g., 'waiting_scan'
except Exception as e:
app.logger.error(f"检查状态时出错 {session_filepath}: {e}")
return jsonify({'status': 'error', 'message': '读取状态文件失败'})
@app.route('/create_user', methods=['POST'])
def create_user():
data = request.json
rows = read_configs() # 读取所有现有用户
# 1. 使用 Signature 检查用户是否已存在
for row in rows:
if row['Signature'] == data['Signature']:
app.logger.warning(f"尝试添加已存在的用户: Signature {data['Signature']}")
# 如果用户已存在,直接为他请求二维码,而不是重复添加
# 这需要模拟 request_qrcode 的逻辑
signature = data['Signature']
session_id = f"{signature}_{int(time.time())}"
threading.Thread(target=get_token_headless, args=(session_id,)).start()
return jsonify({'status': 'success', 'session_id': session_id})
# 创建一个完整的新行
new_row = {field: '' for field in CSV_FIELDNAMES}
new_row.update({
'ThreadId': data['ThreadId'],
'Signature': data['Signature'],
'Texts': data['Texts'],
'Values': data['Values'],
'email': data['Email'],
'jwt_exp': '0'
})
append_new_config(new_row)
signature = data['Signature']
session_id = f"{signature}_{int(time.time())}"
threading.Thread(target=get_token_headless, args=(session_id,)).start()
return jsonify({'status': 'success', 'session_id': session_id})
@app.route('/api/checkin_all', methods=['POST'])
def trigger_checkin_all():
"""API端点,用于手动触发所有用户的打卡"""
try:
# 在后台线程中运行,以防用户数量多时导致请求超时
# 我们传递 "Manual Trigger" 来区分日志来源
threading.Thread(target=run_all_checkins, args=("Manual Trigger",)).start()
return jsonify({'status': 'success', 'message': '已成功触发全部重新打卡,请稍后在日志中查看结果。'})
except Exception as e:
app.logger.error(f"手动触发全部打卡时失败: {e}")
return jsonify({'status': 'error', 'message': '触发失败,请查看服务器日志。'}), 500
# --------------------------------------------------------------------------
# APScheduler 后台任务调度
# --------------------------------------------------------------------------
# 创建一个锁文件路径
# 确保这个文件位于一个所有 worker 都能访问到的地方
lock = FileLock(SCHEDULER_LOCK, timeout=5) # 设置5秒超时
try:
# 尝试非阻塞地获取锁
lock.acquire()
# --- 只有成功获取锁的进程才能执行以下代码 ---
app.logger.info("Scheduler lock acquired by this process. Initializing scheduler...")
# 1. 初始化调度器
scheduler = BackgroundScheduler(daemon=True, timezone='Asia/Shanghai')
# 2. 添加你的后台任务
scheduler.add_job(
func=run_all_checkins,
trigger='cron',
hour=CHECKIN_HOUR,
minute=CHECKIN_MIN,
id='daily_check_in_job',
name='执行每日打卡任务',
replace_existing=True
)
app.logger.info(f"已添加每日打卡任务,将在每天 {CHECKIN_HOUR}:{CHECKIN_MIN:02d} 执行。")
scheduler.add_job(
func=cleanup_stale_sessions,
trigger='interval',
hours=24,
id='cleanup_sessions_job',
name='清理过期的会话文件',
replace_existing=True
)
app.logger.info("已添加定期清理会话任务,每 24 小时执行一次。")
scheduler.add_job(
func=notification_worker_loop,
trigger='interval',
minutes=30,
id='token_expiry_notification_job',
name='检查Token过期并发送邮件',
replace_existing=True
)
app.logger.info("已添加Token过期检查任务,每 30 分钟执行一次。")
# 3. 启动调度器
scheduler.start()
app.logger.info("APScheduler 已成功启动。")
# 4. 注册一个应用退出时的回调函数,确保调度器被安全关闭
# 这个也只在持有锁的进程中注册
atexit.register(lambda: scheduler.shutdown())
except Timeout:
# 如果获取锁超时,说明另一个进程已经启动了调度器
app.logger.info("Could not acquire scheduler lock, another process is handling scheduling.")
finally:
# 确保在进程退出时释放锁 (尽管 with 语句通常能处理好)
# lock.release() # 在这个场景下,锁应该由持有它的进程一直持有,所以不需要手动释放
pass
if __name__ == '__main__':
# 确保sessions目录存在
if not os.path.exists(SESSIONS_DIR):
os.makedirs(SESSIONS_DIR)
app.run(debug=False, host='0.0.0.0', port=5000)
+259
View File
@@ -0,0 +1,259 @@
# 接龙自动打卡系统 - 后端 API
FastAPI 后端服务,提供用户管理、QQ 扫码登录、自动打卡等功能。
## 🚀 快速开始
### 1. 安装依赖
```bash
cd backend
pip install -r requirements.txt
```
### 2. 配置环境
创建 `.env` 文件(可选):
```env
# 邮件通知配置(可选)
SMTP_SERVER=smtp.example.com
SMTP_PORT=465
SMTP_SENDER_EMAIL=your-email@example.com
SMTP_SENDER_PASSWORD=your-password-here
# Chrome 浏览器配置(可选)
CHROME_BINARY_PATH=
CHROMEDRIVER_PATH=
```
### 3. 初始化数据库
数据库会在首次启动时自动初始化。
### 4. 创建管理员用户
```bash
python backend/scripts/create_admin.py
```
按照提示输入管理员信息:
- Signature: 管理员标识(唯一)
- ThreadId: 接龙 ID
- 邮箱: 接收通知的邮箱
### 5. 启动服务
```bash
# 开发模式(支持热重载)
cd backend
python main.py
# 或者使用 uvicorn
uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
# 生产模式
uvicorn backend.main:app --host 0.0.0.0 --port 8000 --workers 4
```
### 6. 访问 API 文档
启动后访问: http://localhost:8000/docs
## 📁 项目结构
```
backend/
├── main.py # FastAPI 应用入口
├── config.py # 配置管理
├── dependencies.py # 认证中间件
├── requirements.txt # Python 依赖
├── models/ # 数据库模型
│ ├── database.py # 数据库配置
│ ├── user.py # User 模型
│ └── check_in_record.py # CheckInRecord 模型
├── schemas/ # Pydantic Schema
│ ├── user.py # 用户相关 Schema
│ ├── auth.py # 认证相关 Schema
│ └── check_in.py # 打卡相关 Schema
├── api/ # API 路由
│ ├── auth.py # 认证 API
│ ├── users.py # 用户管理 API
│ ├── check_in.py # 打卡 API
│ └── admin.py # 管理员 API
├── services/ # 业务逻辑层
│ ├── auth_service.py # 认证服务
│ ├── user_service.py # 用户服务
│ ├── check_in_service.py # 打卡服务
│ └── scheduler_service.py # 调度服务
├── workers/ # Selenium 工作模块
│ ├── token_refresher.py # Token 刷新(QQ 扫码)
│ ├── check_in_worker.py # 打卡执行
│ └── email_notifier.py # 邮件通知
└── scripts/ # 工具脚本
└── create_admin.py # 创建管理员用户
```
## 🔌 API 端点
### 认证 API (`/api/auth`)
- `POST /api/auth/request_qrcode` - 请求 QQ 扫码二维码
- `GET /api/auth/qrcode_status/{session_id}` - 检查扫码状态
- `POST /api/auth/verify_token` - 验证 Token 有效性
### 用户 API (`/api/users`)
- `POST /api/users` - 创建用户(管理员)
- `GET /api/users/me` - 获取当前用户信息
- `GET /api/users/me/token_status` - 获取 Token 状态
- `GET /api/users` - 获取所有用户(管理员)
- `GET /api/users/{user_id}` - 获取指定用户
- `PUT /api/users/{user_id}` - 更新用户信息
- `DELETE /api/users/{user_id}` - 删除用户(管理员)
### 打卡 API (`/api/check_in`)
- `POST /api/check_in/manual` - 手动触发打卡
- `GET /api/check_in/my_records` - 查看自己的打卡记录
- `GET /api/check_in/records` - 查看所有打卡记录(管理员)
- `GET /api/check_in/records/count` - 获取打卡记录统计(管理员)
### 管理员 API (`/api/admin`)
- `POST /api/admin/batch_toggle_active` - 批量启用/禁用用户
- `POST /api/admin/batch_check_in` - 批量触发打卡
- `GET /api/admin/logs` - 获取系统日志
- `GET /api/admin/stats` - 获取系统统计
## ⚙️ 配置说明
### 邮件配置 (`config.ini`)
在项目根目录创建 `config.ini`
```ini
[Email]
smtpserver = smtp.example.com
smtpport = 465
senderemail = your-email@example.com
senderpassword = your-password
```
### 定时任务配置
`backend/config.py` 中配置:
- `CHECKIN_SCHEDULE_HOUR`: 定时打卡小时(默认 20
- `CHECKIN_SCHEDULE_MINUTE`: 定时打卡分钟(默认 0
- `TOKEN_CHECK_INTERVAL_MINUTES`: Token 检查间隔(默认 30 分钟)
- `SESSION_CLEANUP_INTERVAL_HOURS`: 会话清理间隔(默认 24 小时)
## 🔐 认证流程
1. 用户输入 Signature 并请求二维码
2. 后端启动 Selenium 获取 QQ 登录二维码
3. 前端轮询检查扫码状态
4. 用户使用手机 QQ 扫码
5. 后端获取 Token 并解析 JWT
6. 用户后续请求使用 `Authorization: Bearer <token>` header
## 📊 定时任务
系统会自动执行以下定时任务:
1. **定时打卡**: 每天 20:00 为所有启用的用户执行打卡
2. **Token 过期检查**: 每 30 分钟检查一次,Token 在 30 分钟内过期时发送邮件提醒
3. **会话文件清理**: 每 24 小时清理超过 24 小时的旧会话文件
## 🛠️ 开发说明
### 添加新的 API 端点
1.`backend/schemas/` 中定义请求/响应 Schema
2.`backend/services/` 中实现业务逻辑
3.`backend/api/` 中创建 API 路由
4.`backend/main.py` 中注册路由
### 数据库迁移
如果修改了模型,删除 `data/checkin.db` 并重启服务即可重新创建数据库。
⚠️ 注意:生产环境建议使用 Alembic 进行数据库迁移。
## 🐛 故障排查
### 问题:无法启动 Selenium
确保已安装 Chrome 和 ChromeDriver
```bash
# 检查路径配置
ls chrome-linux64/chrome
ls chromedriver
```
### 问题:Token 验证失败
检查数据库中用户的 `authorization` 字段是否有值。
### 问题:定时任务未执行
检查日志文件 `logs/CheckIn.log`,确认调度器是否成功启动。
### 问题:邮件发送失败
检查 `config.ini` 配置是否正确,SMTP 服务器是否可访问。
## 📝 环境变量
可选的环境变量:
- `DATABASE_URL`: 数据库 URL(默认使用 SQLite
- `CORS_ORIGINS`: 允许的前端域名(默认 localhost:5173 和 localhost:3000
- `SMTP_SERVER`: 邮件服务器地址(用于邮件通知,可选)
- `SMTP_SENDER_EMAIL`: 发件人邮箱(用于邮件通知,可选)
- `CHROME_BINARY_PATH`: Chrome 浏览器路径(可选,留空自动检测)
- `CHROMEDRIVER_PATH`: ChromeDriver 路径(可选,留空自动下载)
## 🚀 部署建议
### 使用 Gunicorn
```bash
pip install gunicorn
gunicorn backend.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000
```
### 使用 Systemd
创建 `/etc/systemd/system/checkin-api.service`
```ini
[Unit]
Description=CheckIn API Service
After=network.target
[Service]
Type=simple
User=your-user
WorkingDirectory=/path/to/CheckInApp
Environment="PATH=/path/to/venv/bin"
ExecStart=/path/to/venv/bin/gunicorn backend.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000
Restart=always
[Install]
WantedBy=multi-user.target
```
启动服务:
```bash
sudo systemctl enable checkin-api
sudo systemctl start checkin-api
sudo systemctl status checkin-api
```
## 📄 许可证
本项目仅供学习和研究使用。
+309
View File
@@ -0,0 +1,309 @@
from typing import List
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from backend.models import get_db, User, CheckInTask
from backend.schemas.check_in import BatchCheckInRequest
from backend.schemas.user import UserResponse
from backend.services.check_in_service import CheckInService
from backend.services.admin_service import AdminService
from backend.dependencies import get_current_admin_user
from backend.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
class BatchToggleTasksRequest(BaseModel):
"""批量启用/禁用任务请求"""
task_ids: List[int]
is_active: bool
@router.post("/batch_toggle_tasks", summary="批量启用/禁用任务")
async def batch_toggle_tasks(
request: BatchToggleTasksRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""
批量启用或禁用任务的自动打卡功能(需要管理员权限)
- **task_ids**: 任务 ID 列表
- **is_active**: true 为启用,false 为禁用
"""
try:
count = 0
for task_id in request.task_ids:
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
if task:
task.is_active = request.is_active
count += 1
db.commit()
return {
"success": True,
"message": f"{'启用' if request.is_active else '禁用'} {count} 个任务",
"count": count
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"批量操作失败: {str(e)}"
)
@router.post("/batch_check_in", summary="批量触发打卡")
async def batch_check_in(
request: BatchCheckInRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""
批量触发任务打卡(需要管理员权限)
- **task_ids**: 任务 ID 列表
返回每个任务的打卡结果
"""
try:
result = CheckInService.batch_check_in_tasks(request.task_ids, db)
return result
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"批量打卡失败: {str(e)}"
)
@router.get("/logs", summary="获取系统日志")
async def get_system_logs(
lines: int = Query(200, ge=1, le=2000, description="读取的日志行数"),
current_user: User = Depends(get_current_admin_user)
):
"""
获取系统日志(需要管理员权限)
- **lines**: 读取最后 N 行日志
返回日志内容(字符串格式)
"""
try:
log_file = settings.LOG_FILE
if not log_file.exists():
return {
"success": True,
"message": "日志文件不存在",
"logs": "日志文件不存在"
}
# 使用 deque 高效读取最后 N 行,避免将整个文件加载到内存
from collections import deque
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
# 使用 deque 保持最后 N 行,内存占用固定
last_lines = deque(f, maxlen=lines)
# 返回字符串格式(不是数组)
log_content = ''.join(last_lines)
return {
"success": True,
"message": f"读取了最后 {len(last_lines)} 行日志",
"logs": log_content
}
except Exception as e:
logger.error(f"读取日志失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"读取日志失败: {str(e)}"
)
@router.get("/stats", summary="获取系统统计")
async def get_system_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""
获取系统统计信息(需要管理员权限)
返回用户数、任务数、打卡记录数等统计信息
"""
try:
from backend.models import CheckInRecord
from datetime import datetime, timedelta
# 总用户数
total_users = db.query(User).count()
# 管理员用户数
admin_users = db.query(User).filter(User.role == "admin").count()
# 已审批的用户数(is_approved=True的用户)
approved_users = db.query(User).filter(User.is_approved == True).count()
# 总任务数
total_tasks = db.query(CheckInTask).count()
# 启用的任务数
active_tasks = db.query(CheckInTask).filter(CheckInTask.is_active == True).count()
# 总打卡记录数
total_records = db.query(CheckInRecord).count()
# 今日打卡记录数
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
today_records = db.query(CheckInRecord).filter(
CheckInRecord.check_in_time >= today_start
).count()
# 今日成功打卡数
today_success = db.query(CheckInRecord).filter(
CheckInRecord.check_in_time >= today_start,
CheckInRecord.status == "success"
).count()
# 今日失败打卡数
today_failure = db.query(CheckInRecord).filter(
CheckInRecord.check_in_time >= today_start,
CheckInRecord.status == "failure"
).count()
# 今日时间范围外打卡数
today_out_of_time = db.query(CheckInRecord).filter(
CheckInRecord.check_in_time >= today_start,
CheckInRecord.status == "out_of_time"
).count()
# 今日异常打卡数
today_unknown = db.query(CheckInRecord).filter(
CheckInRecord.check_in_time >= today_start,
CheckInRecord.status == "unknown"
).count()
# Token 即将过期的用户数(7天内)
current_timestamp = int(datetime.now().timestamp())
expiring_soon_timestamp = current_timestamp + (7 * 24 * 60 * 60) # 7天后
expiring_users = 0
for user in db.query(User).all():
if user.jwt_exp and user.jwt_exp != "0":
try:
exp_timestamp = int(user.jwt_exp)
if current_timestamp < exp_timestamp < expiring_soon_timestamp:
expiring_users += 1
except ValueError:
# jwt_exp 格式不正确,跳过此用户
logger.debug(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}")
continue
return {
"users": {
"total": total_users,
"admin": admin_users,
"regular": total_users - admin_users,
"active": approved_users # 使用已审批用户数
},
"tasks": {
"total": total_tasks,
"active": active_tasks,
"inactive": total_tasks - active_tasks
},
"check_in_records": {
"total": total_records,
"today": today_records,
"today_success": today_success,
"today_failure": today_failure,
"today_out_of_time": today_out_of_time,
"today_unknown": today_unknown
},
"tokens": {
"expiring_soon": expiring_users
}
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取统计失败: {str(e)}"
)
@router.get("/users/pending", response_model=List[UserResponse], summary="获取待审批用户")
async def get_pending_users(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""
获取所有待审批的用户(需要管理员权限)
"""
try:
users = AdminService.get_pending_users(db)
return users
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取待审批用户失败: {str(e)}"
)
@router.post("/users/{user_id}/approve", response_model=dict, summary="审批通过用户")
async def approve_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""
审批通过指定用户(需要管理员权限)
"""
try:
result = AdminService.approve_user(user_id, db)
if not result["success"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result["message"]
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"审批用户失败: {str(e)}"
)
@router.delete("/users/{user_id}/reject", response_model=dict, summary="拒绝用户")
async def reject_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""
拒绝并删除指定用户(需要管理员权限)
"""
try:
result = AdminService.reject_user(user_id, db)
if not result["success"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result["message"]
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"拒绝用户失败: {str(e)}"
)
+150
View File
@@ -0,0 +1,150 @@
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
from sqlalchemy.orm import Session
from backend.models import get_db
from backend.schemas.auth import (
QRCodeRequest,
QRCodeResponse,
QRCodeStatusResponse,
TokenVerifyRequest,
TokenVerifyResponse,
AliasLoginRequest,
AliasLoginResponse,
)
from backend.services.auth_service import AuthService
router = APIRouter()
@router.post("/request_qrcode", response_model=dict, summary="请求 QQ 扫码二维码")
async def request_qrcode(
request_obj: QRCodeRequest,
req: Request,
response: Response,
db: Session = Depends(get_db)
):
"""
请求 QQ 扫码二维码
- **alias**: 用户别名
返回会话 ID,用于后续查询扫码状态
"""
from backend.services.registration_manager import registration_manager
import secrets
# 检查注册限流 Cookie
reg_cookie = req.cookies.get("reg_limit")
if reg_cookie:
if not registration_manager.check_registration_cookie(reg_cookie):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="注册过于频繁,请 10 分钟后再试"
)
else:
# 生成新的 Cookie
reg_cookie = secrets.token_urlsafe(16)
# 获取客户端 IP
client_ip = req.client.host if req.client else "unknown"
# 如果有代理,尝试从 X-Forwarded-For 获取真实 IP
forwarded_for = req.headers.get("X-Forwarded-For")
if forwarded_for:
client_ip = forwarded_for.split(",")[0].strip()
try:
result = AuthService.request_qrcode(request_obj.alias, client_ip, db)
# 设置限流 Cookie10 分钟)
response.set_cookie(
key="reg_limit",
value=reg_cookie,
max_age=600, # 10 分钟
httponly=True,
samesite="lax"
)
return result
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"创建扫码会话失败: {str(e)}"
)
@router.get("/qrcode_status/{session_id}", response_model=dict, summary="检查二维码扫描状态")
async def get_qrcode_status(
session_id: str,
db: Session = Depends(get_db)
):
"""
检查二维码扫描状态
- **session_id**: 会话 ID
状态说明:
- pending: 正在初始化
- waiting_scan: 等待扫描(包含二维码图片 Base64)
- success: 扫描成功(包含 user_id 和 authorization
- error: 发生错误
"""
try:
result = AuthService.get_qrcode_status(session_id, db)
return result
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"查询扫码状态失败: {str(e)}"
)
@router.post("/verify_token", response_model=dict, summary="验证 Token 有效性")
async def verify_token(
request: TokenVerifyRequest,
db: Session = Depends(get_db)
):
"""
验证 Token 有效性
- **authorization**: Token(可带或不带 "Bearer " 前缀)
返回 Token 是否有效以及相关信息
"""
try:
result = AuthService.verify_token(request.authorization, db)
return result
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"验证 Token 失败: {str(e)}"
)
@router.post("/alias_login", response_model=dict, summary="别名+密码登录")
async def alias_login(
request: AliasLoginRequest,
db: Session = Depends(get_db)
):
"""
别名+密码登录(仅限已设置密码的用户)
- **alias**: 用户别名
- **password**: 密码
返回登录结果,成功时包含 user_id 和 authorization
注意:
- 用户必须已设置密码才能使用此方式登录
- Token 必须仍然有效(未过期)
- 如果 Token 已过期,请使用扫码登录重新获取
"""
try:
result = AuthService.alias_login(request.alias, request.password, db)
return result
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"别名登录失败: {str(e)}"
)
+221
View File
@@ -0,0 +1,221 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from backend.models import get_db, User, CheckInTask, CheckInRecord
from backend.schemas.check_in import (
ManualCheckInRequest,
CheckInRecordResponse,
CheckInResultResponse,
)
from backend.services.check_in_service import CheckInService
from backend.services.task_service import TaskService
from backend.dependencies import get_current_user, get_current_admin_user
router = APIRouter()
@router.post("/manual/{task_id}", summary="手动触发打卡(异步)")
async def manual_check_in(
task_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
手动触发指定任务的打卡(异步方式,立即返回)
- **task_id**: 任务 ID
返回打卡记录 ID,可以通过 /record/{record_id}/status 查询打卡状态
"""
# 验证任务归属
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权访问此任务"
)
task = TaskService.get_task(task_id, db)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="任务不存在"
)
try:
result = CheckInService.start_async_check_in(task, "manual", db)
return result
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"启动打卡任务失败: {str(e)}"
)
@router.get("/record/{record_id}/status", summary="查询打卡记录状态")
async def get_check_in_record_status(
record_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
查询指定打卡记录的状态
- **record_id**: 打卡记录 ID
返回状态:pending(进行中)、success(成功)、failure(失败)
"""
# 获取打卡记录
record = db.query(CheckInRecord).filter(CheckInRecord.id == record_id).first()
if not record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="打卡记录不存在"
)
# 验证记录归属(通过任务归属)
if not TaskService.verify_task_ownership(record.task_id, current_user.id, db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权访问此记录"
)
return {
"record_id": record.id,
"task_id": record.task_id,
"status": record.status,
"response_text": record.response_text,
"error_message": record.error_message,
"trigger_type": record.trigger_type,
"check_in_time": record.check_in_time
}
@router.get("/task/{task_id}/records", response_model=List[CheckInRecordResponse], summary="查看任务的打卡记录")
async def get_task_check_in_records(
task_id: int,
skip: int = Query(0, ge=0, description="跳过记录数"),
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
status_filter: Optional[str] = Query(None, alias="status", description="过滤状态 (success/failure)"),
trigger_type: Optional[str] = Query(None, description="过滤触发类型 (scheduler/manual)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
查看指定任务的打卡记录
- **task_id**: 任务 ID
- **skip**: 跳过记录数
- **limit**: 限制记录数
- **status**: 过滤状态 (success/failure)
- **trigger_type**: 过滤触发类型 (scheduler/manual)
用户只能查看自己的任务记录
"""
# 验证任务归属
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权访问此任务"
)
try:
records = CheckInService.get_task_records(
task_id, db, skip, limit, status_filter, trigger_type
)
return records
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取打卡记录失败: {str(e)}"
)
@router.get("/my-records", response_model=List[CheckInRecordResponse], summary="查看当前用户的所有打卡记录")
async def get_my_check_in_records(
skip: int = Query(0, ge=0, description="跳过记录数"),
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
status_filter: Optional[str] = Query(None, alias="status", description="过滤状态 (success/failure)"),
trigger_type: Optional[str] = Query(None, description="过滤触发类型 (scheduler/manual)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
查看当前用户所有任务的打卡记录
- **skip**: 跳过记录数
- **limit**: 限制记录数
- **status**: 过滤状态 (success/failure)
- **trigger_type**: 过滤触发类型 (scheduler/manual)
"""
try:
records = CheckInService.get_user_records(
current_user.id, db, skip, limit, status_filter, trigger_type
)
return records
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取打卡记录失败: {str(e)}"
)
@router.get("/records", response_model=List[CheckInRecordResponse], summary="查看所有打卡记录(管理员)")
async def get_all_check_in_records(
skip: int = Query(0, ge=0, description="跳过记录数"),
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
task_id: Optional[int] = Query(None, description="过滤任务 ID"),
status_filter: Optional[str] = Query(None, alias="status", description="过滤状态 (success/failure)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""
查看所有打卡记录(需要管理员权限)
- **skip**: 跳过记录数
- **limit**: 限制记录数
- **task_id**: 过滤指定任务的记录
- **status**: 过滤指定状态的记录
"""
try:
records = CheckInService.get_all_records(db, skip, limit, task_id, status_filter)
# 为每条记录添加用户和任务信息
enriched_records = [CheckInService.enrich_record_with_user_task_info(record, db) for record in records]
return enriched_records
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取打卡记录失败: {str(e)}"
)
@router.get("/records/count", summary="获取打卡记录统计(管理员)")
async def get_check_in_records_count(
task_id: Optional[int] = Query(None, description="过滤任务 ID"),
status_filter: Optional[str] = Query(None, alias="status", description="过滤状态 (success/failure)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""
获取打卡记录统计(需要管理员权限)
返回符合条件的记录总数
"""
try:
query = db.query(CheckInRecord)
if task_id:
query = query.filter(CheckInRecord.task_id == task_id)
if status_filter:
query = query.filter(CheckInRecord.status == status_filter)
total = query.count()
return {"total": total}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取统计失败: {str(e)}"
)
+251
View File
@@ -0,0 +1,251 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime, timedelta
from backend.models import get_db, User
from backend.schemas.task import TaskCreate, TaskUpdate, TaskResponse
from backend.services.task_service import TaskService
from backend.dependencies import get_current_user
router = APIRouter()
@router.post("/", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, summary="创建打卡任务")
async def create_task(
task_data: TaskCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
创建新的打卡任务(基于模板)
现在的任务创建流程:
1. 管理员在后台创建模板(包含完整的 payload_config
2. 用户基于模板创建任务,填写字段值
3. 系统自动生成完整的 payload_config
注意:直接创建任务的方式已废弃,请使用模板接口。
"""
try:
task = TaskService.create_task(current_user.id, task_data, db)
return task
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"创建任务失败: {str(e)}"
)
@router.get("/", response_model=List[TaskResponse], summary="获取当前用户的任务列表")
async def get_tasks(
include_inactive: bool = True,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
获取当前用户的所有打卡任务
- **include_inactive**: 是否包含未启用的任务(默认 true)
"""
try:
tasks = TaskService.get_user_tasks(current_user.id, db, include_inactive)
# 为每个任务添加额外信息
enriched_tasks = [TaskService.enrich_task_with_check_in_info(task, db) for task in tasks]
return enriched_tasks
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取任务列表失败: {str(e)}"
)
@router.get("/{task_id}", response_model=TaskResponse, summary="获取任务详情")
async def get_task(
task_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
获取指定任务的详情
需要验证任务属于当前用户
"""
# 验证任务归属
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权访问此任务"
)
task = TaskService.get_task(task_id, db)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="任务不存在"
)
return task
@router.put("/{task_id}", response_model=TaskResponse, summary="更新任务")
async def update_task(
task_id: int,
task_data: TaskUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
更新指定任务的信息
需要验证任务属于当前用户
"""
# 验证任务归属
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权访问此任务"
)
task = TaskService.update_task(task_id, task_data, db)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="任务不存在"
)
return task
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT, summary="删除任务")
async def delete_task(
task_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
删除指定任务
需要验证任务属于当前用户,删除后会同时删除所有关联的打卡记录
"""
# 验证任务归属
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权访问此任务"
)
success = TaskService.delete_task(task_id, db)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="任务不存在"
)
@router.post("/{task_id}/toggle", response_model=TaskResponse, summary="切换任务启用状态")
async def toggle_task(
task_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
切换任务的启用/禁用状态
需要验证任务属于当前用户
"""
# 验证任务归属
if not TaskService.verify_task_ownership(task_id, current_user.id, db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权访问此任务"
)
task = TaskService.toggle_task(task_id, db)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="任务不存在"
)
return task
@router.post("/validate-cron", summary="验证 Crontab 表达式")
async def validate_cron_expression(request: dict):
"""
验证 Crontab 表达式并预览下一个执行时间
请求体: {"cron_expression": "0 20 * * *"}
返回:
{
"valid": true,
"message": "有效的 Crontab 表达式",
"next_times": [
"2024-01-02 20:00:00",
"2024-01-03 20:00:00",
...
],
"description": "每天 20:00"
}
"""
cron_expr = request.get('cron_expression', '').strip()
if not cron_expr:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="cron_expression 是必需的"
)
try:
from croniter import croniter
if not croniter.is_valid(cron_expr):
raise ValueError("无效的格式")
# 生成接下来的 5 个执行时间
cron = croniter(cron_expr, datetime.now())
next_times = [cron.get_next(datetime).strftime('%Y-%m-%d %H:%M:%S') for _ in range(5)]
return {
"valid": True,
"message": "有效的 Crontab 表达式",
"next_times": next_times,
"description": generate_cron_description(cron_expr)
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的 Crontab 表达式: {str(e)}"
)
def generate_cron_description(cron_expr: str) -> str:
"""生成 Crontab 表达式的人类可读描述"""
parts = cron_expr.split()
if len(parts) != 5:
return cron_expr
minute, hour, day, month, dow = parts
descriptions = []
if hour == '*' and minute == '*':
descriptions.append("每分钟")
elif hour == '*':
descriptions.append(f"每小时的第 {minute} 分钟")
elif day == '*' and month == '*' and dow == '*':
descriptions.append(f"每天 {hour}:{minute:0>2}")
else:
descriptions.append(f"复杂的时间表: {cron_expr}")
return ", ".join(descriptions)
+212
View File
@@ -0,0 +1,212 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from backend.models import User
from backend.dependencies import get_db, get_current_user, get_current_admin_user
from backend.schemas.template import (
TemplateCreate,
TemplateUpdate,
TemplateResponse,
TaskFromTemplateRequest,
TemplatePreviewResponse
)
from backend.schemas.task import TaskResponse
from backend.services.template_service import TemplateService
router = APIRouter()
@router.get("/", response_model=List[TemplateResponse], summary="获取所有模板列表")
async def get_all_templates(
skip: int = Query(0, ge=0, description="跳过记录数"),
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
is_active: Optional[bool] = Query(None, description="过滤启用状态"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取所有模板列表(普通用户可访问)
- **skip**: 跳过记录数
- **limit**: 限制记录数
- **is_active**: 过滤启用状态
"""
try:
templates = TemplateService.get_all_templates(db, skip, limit, is_active)
return templates
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取模板列表失败: {str(e)}"
)
@router.get("/active", response_model=List[TemplateResponse], summary="获取启用的模板列表")
async def get_active_templates(
skip: int = Query(0, ge=0, description="跳过记录数"),
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取所有启用的模板(用户创建任务时使用)
- **skip**: 跳过记录数
- **limit**: 限制记录数
"""
try:
templates = TemplateService.get_all_templates(db, skip, limit, is_active=True)
return templates
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取模板列表失败: {str(e)}"
)
@router.get("/{template_id}", response_model=TemplateResponse, summary="获取单个模板详情")
async def get_template(
template_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取单个模板的详细信息(普通用户只能访问启用的模板)
- **template_id**: 模板 ID
"""
template = TemplateService.get_template(template_id, db)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在"
)
# 普通用户只能访问启用的模板
if not current_user.is_admin and template.is_active is not True:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权访问此模板"
)
return template
@router.get("/{template_id}/preview", response_model=TemplatePreviewResponse, summary="预览模板生成的 payload")
async def preview_template(
template_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
预览模板生成的 payload(使用默认值,普通用户只能访问启用的模板)
- **template_id**: 模板 ID
"""
template = TemplateService.get_template(template_id, db)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在"
)
# 普通用户只能访问启用的模板
if not current_user.is_admin and template.is_active is not True:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权访问此模板"
)
try:
preview_payload = TemplateService.generate_preview_payload(template, db)
# 使用合并后的配置
merged_config = TemplateService.merge_parent_config(template, db)
return {
"template_id": template.id,
"template_name": template.name,
"preview_payload": preview_payload,
"field_config": merged_config
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"生成预览失败: {str(e)}"
)
@router.post("/", response_model=TemplateResponse, summary="创建新模板(管理员)")
async def create_template(
template_data: TemplateCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""
创建新的打卡任务模板(仅管理员)
- **name**: 模板名称
- **description**: 模板描述
- **field_config**: 字段配置(JSON
- **is_active**: 是否启用
"""
return TemplateService.create_template(template_data, db)
@router.put("/{template_id}", response_model=TemplateResponse, summary="更新模板(管理员)")
async def update_template(
template_id: int,
template_data: TemplateUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""
更新模板信息(仅管理员)
- **template_id**: 模板 ID
- **name**: 模板名称
- **description**: 模板描述
- **field_config**: 字段配置(JSON
- **is_active**: 是否启用
"""
return TemplateService.update_template(template_id, template_data, db)
@router.delete("/{template_id}", summary="删除模板(管理员)")
async def delete_template(
template_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""
删除模板(仅管理员)
- **template_id**: 模板 ID
"""
TemplateService.delete_template(template_id, db)
return {"message": "模板删除成功"}
@router.post("/create-task", response_model=TaskResponse, summary="从模板创建任务")
async def create_task_from_template(
request: TaskFromTemplateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
从模板创建打卡任务
- **template_id**: 模板 ID
- **thread_id**: 接龙项目 ID
- **field_values**: 用户填写的字段值
- **task_name**: 任务名称(可选)
"""
task = TemplateService.create_task_from_template(
template_id=request.template_id,
thread_id=request.thread_id,
field_values=request.field_values,
user_id=current_user.id,
task_name=request.task_name,
db=db
)
return task
+294
View File
@@ -0,0 +1,294 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from backend.models import get_db, User
from backend.schemas.user import UserCreate, UserUpdate, UserResponse, TokenStatus, UserUpdateProfile
from backend.schemas.task import TaskResponse
from backend.services.user_service import UserService
from backend.services.task_service import TaskService
from backend.dependencies import get_current_user, get_current_admin_user
router = APIRouter()
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED, summary="创建用户(管理员)")
async def create_user(
user_data: UserCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""
创建用户(需要管理员权限)
- **jwt_sub**: QQ 扫码登录的唯一用户标识
- **alias**: 用户别名(用于登录)
- **role**: 角色(可选,默认 "user"
"""
try:
user = UserService.create_user(user_data, db)
return user
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"创建用户失败: {str(e)}"
)
@router.get("/me", response_model=UserResponse, summary="获取当前用户信息")
async def get_current_user_info(
current_user: User = Depends(get_current_user)
):
"""
获取当前登录用户的信息
"""
# 创建响应对象,手动添加 has_password 字段
user_dict = {
"id": current_user.id,
"alias": current_user.alias,
"jwt_sub": current_user.jwt_sub,
"role": current_user.role,
"is_approved": current_user.is_approved,
"jwt_exp": current_user.jwt_exp,
"email": current_user.email,
"has_password": bool(current_user.password_hash),
"created_at": current_user.created_at,
"updated_at": current_user.updated_at,
}
return user_dict
@router.get("/me/status", response_model=dict, summary="获取当前用户审批状态")
async def get_user_status(
current_user: User = Depends(get_current_user)
):
"""
获取用户审批状态(不要求审批通过)
"""
return {
"user_id": current_user.id,
"alias": current_user.alias,
"is_approved": current_user.is_approved,
"created_at": current_user.created_at.isoformat() if current_user.created_at else None
}
@router.put("/me/profile", response_model=UserResponse, summary="更新个人信息")
async def update_current_user_profile(
profile_data: UserUpdateProfile,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
更新当前用户的个人信息
- **alias**: 新别名(可选)
- **current_password**: 当前密码(修改密码时必填)
- **new_password**: 新密码(可选)
注意:
- 修改密码时必须提供 current_password 和 new_password
- 首次设置密码时不需要 current_password
"""
try:
user = UserService.update_user_profile(current_user.id, profile_data, db)
return user
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"更新个人信息失败: {str(e)}"
)
@router.get("/me/token_status", response_model=TokenStatus, summary="获取当前用户 Token 状态")
async def get_current_user_token_status(
current_user: User = Depends(get_current_user)
):
"""
获取当前用户的 Token 状态
"""
from datetime import datetime
is_valid = True
days_until_expiry = None
expires_at = None
expiring_soon = False
if current_user.jwt_exp and current_user.jwt_exp != "0":
try:
exp_timestamp = int(current_user.jwt_exp)
current_timestamp = int(datetime.now().timestamp())
expires_at = exp_timestamp
if current_timestamp > exp_timestamp:
is_valid = False
else:
days_until_expiry = (exp_timestamp - current_timestamp) // 86400
# 检查是否在30分钟内过期
minutes_until_expiry = (exp_timestamp - current_timestamp) // 60
expiring_soon = minutes_until_expiry <= 30
except ValueError:
pass
return {
"is_valid": is_valid,
"jwt_exp": current_user.jwt_exp,
"jwt_sub": current_user.jwt_sub,
"expires_at": expires_at,
"days_until_expiry": days_until_expiry,
"expiring_soon": expiring_soon
}
@router.get("/me/tasks", response_model=List[TaskResponse], summary="获取当前用户的任务列表")
async def get_current_user_tasks(
include_inactive: bool = Query(True, description="是否包含未启用的任务"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取当前登录用户的所有打卡任务
- **include_inactive**: 是否包含未启用的任务(默认 True)
"""
try:
tasks = TaskService.get_user_tasks(current_user.id, db, include_inactive)
return tasks
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取任务列表失败: {str(e)}"
)
@router.get("", response_model=List[UserResponse], summary="获取所有用户(管理员)")
async def get_all_users(
skip: int = Query(0, ge=0, description="跳过记录数"),
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
search: Optional[str] = Query(None, description="搜索关键词(alias 或 jwt_sub"),
role: Optional[str] = Query(None, description="过滤角色 (user/admin)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""
获取所有用户列表(需要管理员权限)
- **skip**: 跳过记录数
- **limit**: 限制记录数
- **search**: 搜索关键词(模糊匹配 alias 或 jwt_sub
- **role**: 过滤角色(user/admin
"""
try:
users = UserService.get_all_users(db, skip, limit, search, role)
return users
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取用户列表失败: {str(e)}"
)
@router.get("/{user_id}", response_model=UserResponse, summary="获取指定用户")
async def get_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取指定用户信息
- 普通用户只能查看自己的信息
- 管理员可以查看所有用户信息
"""
# 检查权限
if current_user.role != "admin" and current_user.id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足,只能查看自己的信息"
)
user = UserService.get_user_by_id(user_id, db)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"用户 ID {user_id} 不存在"
)
return user
@router.put("/{user_id}", response_model=UserResponse, summary="更新用户信息")
async def update_user(
user_id: int,
user_data: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
更新用户信息
- 普通用户只能更新自己的部分信息(不包括 role)
- 管理员可以更新所有用户的所有信息
"""
# 检查权限
if current_user.role != "admin":
if current_user.id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足,只能更新自己的信息"
)
# 普通用户不能修改 role
if user_data.role is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="普通用户不能修改角色"
)
try:
user = UserService.update_user(user_id, user_data, db)
return user
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"更新用户失败: {str(e)}"
)
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, summary="删除用户(管理员)")
async def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""
删除用户(需要管理员权限)
"""
try:
UserService.delete_user(user_id, db)
return None
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"删除用户失败: {str(e)}"
)
+65
View File
@@ -0,0 +1,65 @@
import os
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import List
# 项目根目录
BASE_DIR = Path(__file__).resolve().parent.parent
class Settings(BaseSettings):
"""应用配置"""
model_config = SettingsConfigDict(
env_file=str(BASE_DIR / ".env"),
env_file_encoding='utf-8',
case_sensitive=True,
extra='ignore'
)
# 项目根目录
BASE_DIR: Path = BASE_DIR
# 项目基础配置
PROJECT_NAME: str = "CheckIn API"
VERSION: str = "2.0.0"
API_PREFIX: str = "/api"
# 数据库配置
DATABASE_URL: str = f"sqlite:///{BASE_DIR}/data/checkin.db"
# CORS 配置(从环境变量读取,用逗号分隔)
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
@property
def cors_origins_list(self) -> List[str]:
"""将CORS_ORIGINS字符串转换为列表"""
return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()]
# 日志配置
LOG_FILE: Path = BASE_DIR / "logs" / "backend.log"
LOG_LEVEL: str = "INFO"
# 会话文件配置
SESSION_DIR: Path = BASE_DIR / "sessions"
SESSION_CLEANUP_HOURS: int = 24
# 邮件配置(从 .env 读取)
SMTP_SERVER: str = ""
SMTP_PORT: int = 465
SMTP_SENDER_EMAIL: str = ""
SMTP_SENDER_PASSWORD: str = ""
SMTP_USE_SSL: bool = True
# 定时任务配置
CHECKIN_SCHEDULE_HOUR: int = 20 # 20:00
CHECKIN_SCHEDULE_MINUTE: int = 0
TOKEN_CHECK_INTERVAL_MINUTES: int = 30
SESSION_CLEANUP_INTERVAL_HOURS: int = 24
# Selenium / Chrome 配置(从 .env 读取)
CHROME_BINARY_PATH: str = ""
CHROMEDRIVER_PATH: str = ""
settings = Settings()
+97
View File
@@ -0,0 +1,97 @@
from datetime import datetime
from typing import Optional
from fastapi import Depends, HTTPException, Header, status
from sqlalchemy.orm import Session
from backend.models import get_db, User
async def get_current_user(
authorization: Optional[str] = Header(None),
db: Session = Depends(get_db)
) -> User:
"""
获取当前用户
从 Authorization header 中验证 Token 并返回用户
"""
if not authorization:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未提供认证信息",
headers={"WWW-Authenticate": "Bearer"},
)
# 移除 "Bearer " 前缀(如果存在)
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
# 从数据库查询用户
user = db.query(User).filter(User.authorization == token).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证信息",
headers={"WWW-Authenticate": "Bearer"},
)
# 检查 Token 是否过期
if user.jwt_exp and user.jwt_exp != "0":
try:
exp_timestamp = int(user.jwt_exp)
current_timestamp = int(datetime.now().timestamp())
if current_timestamp > exp_timestamp:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 已过期,请重新登录",
headers={"WWW-Authenticate": "Bearer"},
)
except ValueError:
pass # jwt_exp 格式不正确,跳过验证
return user
async def require_approved_user(
current_user: User = Depends(get_current_user)
) -> User:
"""
要求用户已通过审批
"""
if not current_user.is_approved:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="您的账户正在等待管理员审批,请耐心等待(24小时内)"
)
return current_user
async def get_current_admin_user(
current_user: User = Depends(require_approved_user)
) -> User:
"""
获取当前管理员用户
验证用户是否具有管理员权限
"""
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足,需要管理员权限"
)
return current_user
async def get_optional_user(
authorization: Optional[str] = Header(None),
db: Session = Depends(get_db)
) -> Optional[User]:
"""
可选的用户认证
如果提供了 Token 则返回用户,否则返回 None
"""
if not authorization:
return None
try:
return await get_current_user(authorization, db)
except HTTPException:
return None
+113
View File
@@ -0,0 +1,113 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import logging
from pathlib import Path
from backend.config import settings
from backend.models import init_db
# 配置日志
settings.LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=settings.LOG_LEVEL,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[
logging.FileHandler(settings.LOG_FILE, encoding="utf-8"),
logging.StreamHandler(),
],
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
# 启动时执行
logger.info("正在启动 CheckIn API 服务...")
# 初始化数据库
logger.info("正在初始化数据库...")
init_db()
logger.info("数据库初始化完成")
# 确保必要的目录存在
settings.SESSION_DIR.mkdir(parents=True, exist_ok=True)
(settings.BASE_DIR / "data").mkdir(parents=True, exist_ok=True)
# 启动调度器
logger.info("正在启动调度器...")
from backend.services.scheduler_service import start_scheduler
start_scheduler()
logger.info(f"CheckIn API 服务已启动,版本: {settings.VERSION}")
yield
# 关闭时执行
logger.info("正在关闭 CheckIn API 服务...")
from backend.services.scheduler_service import stop_scheduler
stop_scheduler()
logger.info("CheckIn API 服务已关闭")
# 创建 FastAPI 应用
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
description="接龙自动打卡系统 API",
lifespan=lifespan,
)
# 配置 CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list, # 使用属性方法获取列表
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 健康检查端点
@app.get("/health")
async def health_check():
"""健康检查"""
return {
"status": "healthy",
"version": settings.VERSION,
"service": settings.PROJECT_NAME,
}
# 根路径
@app.get("/")
async def root():
"""API 根路径"""
return {
"message": "欢迎使用接龙自动打卡系统 API",
"version": settings.VERSION,
"docs": "/docs",
"health": "/health",
}
# 注册路由
from backend.api import auth, users, check_in, admin, tasks, templates
app.include_router(auth.router, prefix=f"{settings.API_PREFIX}/auth", tags=["认证"])
app.include_router(users.router, prefix=f"{settings.API_PREFIX}/users", tags=["用户"])
app.include_router(tasks.router, prefix=f"{settings.API_PREFIX}/tasks", tags=["打卡任务"])
app.include_router(check_in.router, prefix=f"{settings.API_PREFIX}/check_in", tags=["打卡"])
app.include_router(admin.router, prefix=f"{settings.API_PREFIX}/admin", tags=["管理员"])
app.include_router(templates.router, prefix=f"{settings.API_PREFIX}/templates", tags=["任务模板"])
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=True,
log_level="info",
)
+7
View File
@@ -0,0 +1,7 @@
from backend.models.database import Base, get_db, init_db
from backend.models.user import User
from backend.models.check_in_task import CheckInTask
from backend.models.check_in_record import CheckInRecord
from backend.models.task_template import TaskTemplate
__all__ = ["Base", "get_db", "init_db", "User", "CheckInTask", "CheckInRecord", "TaskTemplate"]
+31
View File
@@ -0,0 +1,31 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from backend.models.database import Base
class CheckInRecord(Base):
"""打卡记录模型"""
__tablename__ = "check_in_records"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
task_id = Column(Integer, ForeignKey("check_in_tasks.id", ondelete="CASCADE"), nullable=False, index=True, comment="任务 ID")
status = Column(String(20), nullable=False, index=True, comment="状态: success/failure/out_of_time/unknown/pending")
response_text = Column(Text, default="", comment="响应文本")
error_message = Column(Text, default="", comment="错误信息")
location = Column(Text, default="{}", comment="位置信息 JSON")
trigger_type = Column(String(50), default="scheduled", comment="触发类型: scheduled/manual/admin")
check_in_time = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True, comment="打卡时间(UTC")
# 关联任务
task = relationship("CheckInTask", back_populates="check_in_records")
# 添加复合索引:加速常见查询
__table_args__ = (
Index('ix_record_task_time', 'task_id', 'check_in_time'), # 获取任务的打卡记录(按时间排序)
Index('ix_record_status_time', 'status', 'check_in_time'), # 按状态和时间查询
)
def __repr__(self):
return f"<CheckInRecord(id={self.id}, task_id={self.task_id}, status={self.status})>"
+39
View File
@@ -0,0 +1,39 @@
from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime, ForeignKey, Index
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from backend.models.database import Base
class CheckInTask(Base):
"""打卡任务模型"""
__tablename__ = "check_in_tasks"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, comment="用户 ID")
payload_config = Column(Text, default="{}", nullable=False, comment="完整的 payload 配置 JSON(从模板生成,包含 ThreadId 和所有字段)")
name = Column(String(100), default="", comment="任务名称(用户自定义)")
is_active = Column(Boolean, default=True, comment="是否启用自动打卡(不影响手动打卡)")
cron_expression = Column(String(100), default="0 20 * * *", nullable=True, comment="Crontab 表达式(NULL 表示禁用自动打卡,否则按表达式执行)")
created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), comment="更新时间")
# 关联用户
user = relationship("User", back_populates="tasks")
# 关联打卡记录
check_in_records = relationship("CheckInRecord", back_populates="task", cascade="all, delete-orphan")
# 添加索引:加速查询
__table_args__ = (
Index('ix_task_user_active', 'user_id', 'is_active'),
Index('ix_task_cron', 'cron_expression'), # 加速查询启用了定时打卡的任务
)
def __repr__(self):
return f"<CheckInTask(id={self.id}, user_id={self.user_id}, name={self.name}, cron={self.cron_expression})>"
@property
def is_scheduled_enabled(self) -> bool:
"""判断是否启用了自动打卡(is_active 为 True 且 cron_expression 不为空)"""
return bool(self.is_active) and bool(self.cron_expression)
+52
View File
@@ -0,0 +1,52 @@
from sqlalchemy import create_engine, event
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from datetime import datetime, timezone
from backend.config import settings
# 创建数据库引擎
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False}, # SQLite 特定配置
echo=False, # 生产环境设为 False
)
# 创建会话工厂
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 创建基类
Base = declarative_base()
# SQLite timezone 修复:在加载对象后,将所有 naive datetime 转换为 UTC timezone-aware
@event.listens_for(Base, "load", propagate=True)
def receive_load(target, context):
"""在从数据库加载对象后,将所有 datetime 字段转换为 timezone-aware (UTC)"""
for attr_name in dir(target):
# 跳过私有属性和方法
if attr_name.startswith('_'):
continue
try:
attr_value = getattr(target, attr_name)
# 如果是 naive datetime,添加 UTC timezone
if isinstance(attr_value, datetime) and attr_value.tzinfo is None:
setattr(target, attr_name, attr_value.replace(tzinfo=timezone.utc))
except (AttributeError, TypeError):
# 某些属性可能无法访问或设置,跳过
continue
def get_db():
"""依赖注入:获取数据库会话"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""初始化数据库:创建所有表"""
Base.metadata.create_all(bind=engine)
+56
View File
@@ -0,0 +1,56 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from datetime import datetime, timezone
from backend.config import settings
import sqlite3
# SQLite 类型转换器:将从数据库读取的字符串转换为 timezone-aware datetime
def convert_timestamp(val):
"""将从数据库读取的字符串转换为 timezone-aware datetime (UTC)"""
if val is None:
return None
# 解析 ISO 8601 格式的字符串
try:
dt = datetime.fromisoformat(val.decode() if isinstance(val, bytes) else val)
# 如果是 naive datetime,添加 UTC timezone
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except (ValueError, AttributeError):
return None
# 注册 SQLite 类型转换器(全局)
sqlite3.register_converter("DATETIME", convert_timestamp)
sqlite3.register_converter("TIMESTAMP", convert_timestamp)
# 创建数据库引擎
# 为 SQLite 连接添加 detect_types 参数以启用类型转换
engine = create_engine(
settings.DATABASE_URL,
connect_args={
"check_same_thread": False, # SQLite 特定配置
"detect_types": sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES # 启用类型转换
},
echo=False, # 生产环境设为 False
)
# 创建会话工厂
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 创建基类
Base = declarative_base()
def get_db():
"""依赖注入:获取数据库会话"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""初始化数据库:创建所有表"""
Base.metadata.create_all(bind=engine)
+29
View File
@@ -0,0 +1,29 @@
from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from backend.models.database import Base
class TaskTemplate(Base):
"""打卡任务模板"""
__tablename__ = "task_templates"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
name = Column(String(100), nullable=False, comment="模板名称")
description = Column(Text, nullable=True, comment="模板描述")
# 父模板 ID(用于继承)
parent_id = Column(Integer, ForeignKey("task_templates.id", ondelete="SET NULL"), nullable=True, comment="父模板 ID")
# 字段配置(JSON 格式)
field_config = Column(Text, nullable=False, comment="字段配置(JSON")
is_active = Column(Boolean, default=True, comment="是否启用")
created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), comment="更新时间")
# 自引用关系:父模板和子模板
parent = relationship("TaskTemplate", remote_side=[id], backref="children")
def __repr__(self):
return f"<TaskTemplate(id={self.id}, name='{self.name}', is_active={self.is_active})>"
+39
View File
@@ -0,0 +1,39 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Index
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from backend.models.database import Base
class User(Base):
"""用户模型 - 账户信息"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
jwt_sub = Column(String(200), unique=True, nullable=True, index=True, comment="QQ 扫码登录的唯一用户标识(注册时为空)")
alias = Column(String(50), unique=True, nullable=False, index=True, comment="用户别名(用于登录)")
email = Column(String(100), nullable=True, comment="用户邮箱(用于接收通知)")
password_hash = Column(String(200), nullable=True, comment="密码哈希(bcrypt加密)")
authorization = Column(Text, nullable=True, comment="当前有效的 QQ Token")
jwt_exp = Column(String(20), default="0", comment="Token 过期时间戳")
role = Column(String(20), default="user", index=True, comment="角色: user/admin")
is_approved = Column(Boolean, default=False, index=True, comment="是否已通过管理员审批")
registered_ip = Column(String(50), nullable=True, comment="注册时的 IP 地址")
created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), comment="更新时间")
# 关联打卡任务
tasks = relationship("CheckInTask", back_populates="user", cascade="all, delete-orphan")
# 添加复合索引:加速审批管理查询
__table_args__ = (
Index('ix_user_role_approved', 'role', 'is_approved'), # 管理员查询待审批用户
)
def __repr__(self):
return f"<User(id={self.id}, alias={self.alias}, jwt_sub={self.jwt_sub}, role={self.role})>"
@property
def is_admin(self) -> bool:
"""判断是否为管理员"""
return self.role == "admin"
+16
View File
@@ -0,0 +1,16 @@
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
sqlalchemy>=2.0.25
pydantic>=2.5.3
pydantic-settings>=2.1.0
python-dotenv>=1.0.0
python-jose[cryptography]>=3.3.0
python-multipart>=0.0.6
apscheduler>=3.10.4
filelock>=3.13.1
selenium>=4.16.0
pillow>=10.4.0
requests>=2.31.0
pyjwt>=2.8.0
bcrypt>=4.1.2
croniter>=1.3.8
+71
View File
@@ -0,0 +1,71 @@
from backend.schemas.user import (
UserBase,
UserCreate,
UserUpdate,
UserResponse,
UserWithToken,
TokenStatus,
)
from backend.schemas.auth import (
QRCodeRequest,
QRCodeResponse,
QRCodeStatusResponse,
TokenVerifyRequest,
TokenVerifyResponse,
)
from backend.schemas.check_in import (
ManualCheckInRequest,
BatchCheckInRequest,
CheckInRecordResponse,
CheckInRecordWithTaskInfo,
CheckInResultResponse,
)
from backend.schemas.task import (
TaskBase,
TaskCreate,
TaskUpdate,
TaskResponse,
)
from backend.schemas.template import (
FieldOption,
FieldConfigItem,
FieldConfig,
TemplateBase,
TemplateCreate,
TemplateUpdate,
TemplateResponse,
TaskFromTemplateRequest,
TemplatePreviewResponse,
)
__all__ = [
"UserBase",
"UserCreate",
"UserUpdate",
"UserResponse",
"UserWithToken",
"TokenStatus",
"QRCodeRequest",
"QRCodeResponse",
"QRCodeStatusResponse",
"TokenVerifyRequest",
"TokenVerifyResponse",
"ManualCheckInRequest",
"BatchCheckInRequest",
"CheckInRecordResponse",
"CheckInRecordWithTaskInfo",
"CheckInResultResponse",
"TaskBase",
"TaskCreate",
"TaskUpdate",
"TaskResponse",
"FieldOption",
"FieldConfigItem",
"FieldConfig",
"TemplateBase",
"TemplateCreate",
"TemplateUpdate",
"TemplateResponse",
"TaskFromTemplateRequest",
"TemplatePreviewResponse",
]
+49
View File
@@ -0,0 +1,49 @@
from typing import Optional
from pydantic import BaseModel, Field
class QRCodeRequest(BaseModel):
"""请求二维码 Schema"""
alias: str = Field(..., description="用户别名")
class QRCodeResponse(BaseModel):
"""二维码响应 Schema"""
session_id: str = Field(..., description="会话 ID")
qrcode_image: str = Field(..., description="二维码 Base64 图片")
class QRCodeStatusResponse(BaseModel):
"""二维码状态响应 Schema"""
status: str = Field(..., description="状态: pending/waiting_scan/success/error")
message: Optional[str] = Field(None, description="状态消息")
user_id: Optional[int] = Field(None, description="用户 ID (扫码成功时返回)")
authorization: Optional[str] = Field(None, description="Token (扫码成功时返回)")
qrcode_image: Optional[str] = Field(None, description="二维码 Base64 图片(等待扫描时返回)")
class TokenVerifyRequest(BaseModel):
"""Token 验证请求 Schema"""
authorization: str = Field(..., description="Token")
class TokenVerifyResponse(BaseModel):
"""Token 验证响应 Schema"""
is_valid: bool = Field(..., description="Token 是否有效")
message: str = Field(..., description="验证消息")
user_id: Optional[int] = Field(None, description="用户 ID")
class AliasLoginRequest(BaseModel):
"""别名+密码登录请求 Schema"""
alias: str = Field(..., min_length=2, max_length=50, description="用户别名")
password: str = Field(..., min_length=6, description="密码")
class AliasLoginResponse(BaseModel):
"""别名+密码登录响应 Schema"""
success: bool = Field(..., description="登录是否成功")
message: str = Field(..., description="登录消息")
user_id: Optional[int] = Field(None, description="用户 ID")
authorization: Optional[str] = Field(None, description="Token")
alias: Optional[str] = Field(None, description="用户别名")
+48
View File
@@ -0,0 +1,48 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
class ManualCheckInRequest(BaseModel):
"""手动打卡请求 Schema(已废弃,现在使用路径参数 task_id)"""
task_id: Optional[int] = Field(None, description="任务 ID")
class BatchCheckInRequest(BaseModel):
"""批量打卡请求 Schema"""
task_ids: list[int] = Field(..., description="任务 ID 列表")
class CheckInRecordResponse(BaseModel):
"""打卡记录响应 Schema"""
model_config = ConfigDict(from_attributes=True)
id: int
task_id: int
status: str
response_text: str
error_message: str
location: str
trigger_type: str
check_in_time: datetime # Pydantic v2 自动序列化为 ISO 8601 格式
# 新增字段:用户和任务信息(用于管理员查看)
user_id: Optional[int] = Field(None, description="用户 ID")
user_email: Optional[str] = Field(None, description="用户邮箱")
task_name: Optional[str] = Field(None, description="任务名称")
thread_id: Optional[str] = Field(None, description="接龙 ID")
class CheckInRecordWithTaskInfo(CheckInRecordResponse):
"""带任务信息的打卡记录响应 Schema"""
task_name: str
task_signature: str
user_alias: str
class CheckInResultResponse(BaseModel):
"""打卡结果响应 Schema"""
success: bool
message: str
record_id: Optional[int] = None
error: Optional[str] = None
+153
View File
@@ -0,0 +1,153 @@
from datetime import datetime
from typing import Optional
import json
from pydantic import BaseModel, Field, field_validator
class TaskBase(BaseModel):
"""打卡任务基础 Schema"""
payload_config: str = Field(..., description="完整的 payload 配置 JSON(包含 ThreadId 和所有字段)")
name: Optional[str] = Field("", max_length=100, description="任务名称(用户自定义)")
is_active: Optional[bool] = Field(True, description="是否启用自动打卡")
@field_validator('payload_config')
@classmethod
def validate_payload_config(cls, v: str) -> str:
"""
验证 payload_config 是否为有效的 JSON,并且包含必需的 ThreadId 字段
"""
if not v or not v.strip():
raise ValueError("payload_config 不能为空")
try:
payload = json.loads(v)
except json.JSONDecodeError as e:
raise ValueError(f"payload_config 必须是有效的 JSON 格式: {str(e)}")
# 检查是否为字典类型
if not isinstance(payload, dict):
raise ValueError("payload_config 必须是 JSON 对象(字典)")
# 检查必需字段 ThreadId
if 'ThreadId' not in payload:
raise ValueError("payload_config 必须包含 ThreadId 字段")
thread_id = payload.get('ThreadId')
if not thread_id or not str(thread_id).strip():
raise ValueError("ThreadId 不能为空")
return v
class TaskCreate(TaskBase):
"""创建打卡任务 Schema"""
cron_expression: Optional[str] = Field(
None,
max_length=100,
description="Crontab 表达式(例如 '0 20 * * *' 表示每天 20:00)。NULL 表示禁用定时打卡"
)
@field_validator('cron_expression')
@classmethod
def validate_cron_expression(cls, v: Optional[str]) -> Optional[str]:
"""验证 Crontab 表达式格式"""
if v is None:
return v # NULL 允许(表示禁用定时打卡)
if not v.strip():
raise ValueError("cron_expression 不能为空字符串,应该使用 NULL")
try:
from croniter import croniter
if not croniter.is_valid(v):
raise ValueError(f"无效的 Crontab 表达式: '{v}'")
except Exception as e:
raise ValueError(f"Crontab 表达式验证失败: {str(e)}")
return v
class TaskUpdate(BaseModel):
"""更新打卡任务 Schema"""
payload_config: Optional[str] = None
name: Optional[str] = None
is_active: Optional[bool] = None
cron_expression: Optional[str] = Field(
None,
max_length=100,
description="Crontab 表达式。NULL 表示禁用定时打卡"
)
@field_validator('payload_config')
@classmethod
def validate_payload_config(cls, v: Optional[str]) -> Optional[str]:
"""
验证 payload_config 是否为有效的 JSON(如果提供的话)
"""
if v is None:
return v
if not v.strip():
raise ValueError("payload_config 不能为空字符串")
try:
payload = json.loads(v)
except json.JSONDecodeError as e:
raise ValueError(f"payload_config 必须是有效的 JSON 格式: {str(e)}")
# 检查是否为字典类型
if not isinstance(payload, dict):
raise ValueError("payload_config 必须是 JSON 对象(字典)")
# 检查必需字段 ThreadId
if 'ThreadId' not in payload:
raise ValueError("payload_config 必须包含 ThreadId 字段")
thread_id = payload.get('ThreadId')
if not thread_id or not str(thread_id).strip():
raise ValueError("ThreadId 不能为空")
return v
@field_validator('cron_expression')
@classmethod
def validate_cron_expression(cls, v: Optional[str]) -> Optional[str]:
"""验证 Crontab 表达式(与 TaskCreate 相同)"""
if v is None:
return v
if not v.strip():
raise ValueError("cron_expression 不能为空字符串,应该使用 NULL")
try:
from croniter import croniter
if not croniter.is_valid(v):
raise ValueError(f"无效的 Crontab 表达式: '{v}'")
except Exception as e:
raise ValueError(f"Crontab 表达式验证失败: {str(e)}")
return v
class TaskResponse(TaskBase):
"""打卡任务响应 Schema"""
id: int
user_id: int
created_at: datetime
updated_at: Optional[datetime] = None
cron_expression: Optional[str] = Field(
None,
description="当前 Crontab 表达式(NULL = 禁用定时打卡)"
)
is_scheduled_enabled: Optional[bool] = Field(
None,
description="是否启用了定时打卡"
)
# 新增字段:最后一次打卡信息
last_check_in_time: Optional[datetime] = Field(None, description="最后一次打卡时间")
last_check_in_status: Optional[str] = Field(None, description="最后一次打卡状态")
thread_id: Optional[str] = Field(None, description="接龙 ID(从 payload_config 中提取)")
class Config:
from_attributes = True
+147
View File
@@ -0,0 +1,147 @@
from datetime import datetime
from typing import Optional, Dict, Any, List, Union
from pydantic import BaseModel, Field, field_validator
import json
class FieldOption(BaseModel):
"""字段选项(用于 select 类型)"""
label: str = Field(..., description="选项显示文本")
value: str = Field(..., description="选项值")
class FieldConfigItem(BaseModel):
"""单个字段配置项"""
display_name: str = Field(..., description="字段显示名称")
field_type: str = Field(..., description="字段输入类型:text, textarea, number, select")
default_value: str = Field(default="", description="默认值")
required: bool = Field(default=True, description="是否必填")
hidden: bool = Field(default=False, description="是否隐藏(直接使用默认值)")
placeholder: Optional[str] = Field(None, description="输入提示")
value_type: str = Field(default="string", description="值类型:string, int, double")
options: Optional[List[FieldOption]] = Field(None, description="选项列表(仅 select 类型)")
@field_validator('field_type')
@classmethod
def validate_field_type(cls, v):
allowed_types = ['text', 'textarea', 'number', 'select']
if v not in allowed_types:
raise ValueError(f'field_type must be one of {allowed_types}')
return v
@field_validator('value_type')
@classmethod
def validate_value_type(cls, v):
allowed_types = ['string', 'int', 'double']
if v not in allowed_types:
raise ValueError(f'value_type must be one of {allowed_types}')
return v
class FieldConfigValues(BaseModel):
"""Values 字段的嵌套配置(如 location, temperature 等)"""
pass
class Config:
extra = 'allow' # 允许任意字段
class FieldConfig(BaseModel):
"""完整的字段配置"""
signature: Optional[FieldConfigItem] = None
texts: Optional[FieldConfigItem] = None
values: Optional[Dict[str, FieldConfigItem]] = Field(None, description="Values 字段的嵌套配置")
class TemplateBase(BaseModel):
"""模板基础 Schema"""
name: str = Field(..., min_length=1, max_length=100, description="模板名称")
description: Optional[str] = Field(None, description="模板描述")
parent_id: Optional[int] = Field(None, description="父模板 ID(用于继承)")
field_config: Union[str, FieldConfig] = Field(..., description="字段配置(JSON 字符串或对象)")
is_active: bool = Field(default=True, description="是否启用")
@field_validator('field_config')
@classmethod
def validate_field_config(cls, v):
"""验证并转换 field_config"""
if isinstance(v, str):
try:
# 尝试解析 JSON 字符串
config_dict = json.loads(v)
return json.dumps(config_dict) # 返回格式化的 JSON 字符串
except json.JSONDecodeError:
raise ValueError('field_config must be valid JSON string')
elif isinstance(v, dict):
# 如果是字典,转换为 JSON 字符串
return json.dumps(v)
elif isinstance(v, FieldConfig):
# 如果是 FieldConfig 对象,转换为 JSON 字符串
return v.model_dump_json(exclude_none=True)
else:
raise ValueError('field_config must be JSON string, dict, or FieldConfig object')
class TemplateCreate(TemplateBase):
"""创建模板 Schema"""
pass
class TemplateUpdate(BaseModel):
"""更新模板 Schema"""
name: Optional[str] = Field(None, min_length=1, max_length=100, description="模板名称")
description: Optional[str] = Field(None, description="模板描述")
parent_id: Optional[int] = Field(None, description="父模板 ID(用于继承)")
field_config: Optional[Union[str, FieldConfig]] = Field(None, description="字段配置(JSON 字符串或对象)")
is_active: Optional[bool] = Field(None, description="是否启用")
@field_validator('field_config')
@classmethod
def validate_field_config(cls, v):
"""验证并转换 field_config"""
if v is None:
return v
if isinstance(v, str):
try:
config_dict = json.loads(v)
return json.dumps(config_dict)
except json.JSONDecodeError:
raise ValueError('field_config must be valid JSON string')
elif isinstance(v, dict):
return json.dumps(v)
elif isinstance(v, FieldConfig):
return v.model_dump_json(exclude_none=True)
else:
raise ValueError('field_config must be JSON string, dict, or FieldConfig object')
class TemplateResponse(BaseModel):
"""模板响应 Schema"""
id: int
name: str
description: Optional[str]
parent_id: Optional[int]
field_config: str # JSON 字符串
is_active: bool
created_at: datetime
updated_at: Optional[datetime]
class Config:
from_attributes = True
class TaskFromTemplateRequest(BaseModel):
"""从模板创建任务的请求 Schema"""
template_id: int = Field(..., description="模板 ID")
thread_id: str = Field(..., min_length=1, description="接龙项目 ID")
field_values: Dict[str, Any] = Field(default_factory=dict, description="用户填写的字段值")
task_name: Optional[str] = Field(None, max_length=100, description="任务名称(可选)")
class TemplatePreviewResponse(BaseModel):
"""模板预览响应 Schema"""
template_id: int
template_name: str
preview_payload: Dict[str, Any] = Field(..., description="预览生成的 payload(使用默认值)")
field_config: Dict[str, Any] = Field(..., description="字段配置(用于前端渲染表单)")
+64
View File
@@ -0,0 +1,64 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class UserBase(BaseModel):
"""用户基础 Schema"""
alias: str = Field(..., min_length=2, max_length=50, description="用户别名(用于登录)")
class UserCreate(UserBase):
"""创建用户 Schema(管理员手动创建,只需要别名)"""
role: Optional[str] = Field("user", description="角色: user/admin")
email: Optional[str] = Field(None, description="邮箱地址")
class UserUpdate(BaseModel):
"""更新用户 Schema(管理员编辑用户)"""
alias: Optional[str] = Field(None, min_length=2, max_length=50, description="用户别名")
role: Optional[str] = None
is_approved: Optional[bool] = None
email: Optional[str] = None
password: Optional[str] = Field(None, min_length=6, description="新密码(可选,留空表示不修改)")
reset_password: Optional[bool] = Field(False, description="是否清空密码")
class UserUpdateProfile(BaseModel):
"""用户更新个人信息 Schema"""
alias: Optional[str] = Field(None, min_length=2, max_length=50, description="新别名")
email: Optional[str] = Field(None, description="邮箱地址")
current_password: Optional[str] = Field(None, min_length=6, description="当前密码(修改密码时必填)")
new_password: Optional[str] = Field(None, min_length=6, description="新密码")
class UserResponse(BaseModel):
"""用户响应 Schema"""
id: int
alias: str
jwt_sub: str
role: str
is_approved: bool
jwt_exp: str
email: Optional[str] = None
has_password: bool = False # 是否已设置密码
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class UserWithToken(UserResponse):
"""带 Token 的用户响应 Schema"""
authorization: Optional[str] = None
class TokenStatus(BaseModel):
"""Token 状态 Schema"""
is_valid: bool
jwt_exp: str
jwt_sub: str
expires_at: Optional[int] = None # Unix 时间戳(秒)
days_until_expiry: Optional[int] = None
expiring_soon: bool = False # 是否即将过期(30分钟内)
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
创建管理员用户的脚本
使用方法:
python backend/scripts/create_admin.py
或使用虚拟环境:
./venv/Scripts/python.exe backend/scripts/create_admin.py
"""
import sys
import os
from pathlib import Path
# 添加项目根目录到路径
BASE_DIR = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(BASE_DIR))
from backend.models import init_db, User
from backend.models.database import SessionLocal
from backend.services.auth_service import AuthService
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def create_admin_user(alias: str):
"""
将现有用户升级为管理员(或创建管理员占位符)
Args:
alias: 用户别名
"""
# 初始化数据库
init_db()
# 创建数据库会话
db = SessionLocal()
try:
# 检查别名是否已存在
existing_user = db.query(User).filter(User.alias == alias).first()
if existing_user:
print(f"[OK] 找到用户:{alias}")
print(f" 用户 ID: {existing_user.id}")
print(f" QQ 标识 (jwt_sub): {existing_user.jwt_sub}")
print(f" 当前角色: {existing_user.role}")
print(f" 审批状态: {existing_user.is_approved}")
# 如果已经是管理员
if existing_user.role == "admin":
print("\n该用户已经是管理员")
return
# 升级为管理员
response = input("\n是否将该用户升级为管理员?(y/n): ")
if response.lower() == 'y':
existing_user.role = "admin"
existing_user.is_approved = True # 确保已审批
db.commit()
print("\n" + "=" * 60)
print("[成功] 用户已升级为管理员!")
print("=" * 60)
print(f" 用户 ID: {existing_user.id}")
print(f" 别名: {existing_user.alias}")
print(f" QQ 标识: {existing_user.jwt_sub}")
print(f" 角色: admin")
print("=" * 60)
else:
print("操作已取消")
else:
print(f"\n[错误] 未找到别名为 '{alias}' 的用户")
print("\n请先使用该别名进行 QQ 扫码注册,然后再运行此脚本升级为管理员")
except Exception as e:
logger.error(f"[错误] 操作失败: {e}")
db.rollback()
raise
finally:
db.close()
def main():
"""主函数"""
print("=" * 60)
print("接龙自动打卡系统 - 设置管理员")
print("=" * 60)
print()
print("[说明]")
print(" 此脚本将已注册的用户升级为管理员")
print(" 请先使用别名进行 QQ 扫码注册,然后运行此脚本")
print()
# 获取用户别名
alias = input("请输入要设置为管理员的用户别名 [admin]: ").strip() or "admin"
print()
print("=" * 60)
print(f"准备将用户 '{alias}' 设置为管理员")
print("=" * 60)
print()
create_admin_user(alias)
if __name__ == "__main__":
main()
@@ -0,0 +1,72 @@
"""
数据库迁移脚本:为 task_templates 表添加 parent_id 字段
运行方法:
python backend/scripts/migrate_add_parent_id_to_templates.py
"""
import sys
import os
from pathlib import Path
# 设置 UTF-8 编码输出(Windows 兼容)
if sys.platform == "win32":
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
# 添加项目根目录到 Python 路径
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from sqlalchemy import text
from backend.models.database import engine, SessionLocal
def migrate():
"""为 task_templates 表添加 parent_id 字段"""
print("=" * 60)
print("开始数据库迁移:添加 parent_id 字段到 task_templates 表")
print("=" * 60)
db = SessionLocal()
try:
# 检查字段是否已存在
result = db.execute(text(
"SELECT COUNT(*) FROM pragma_table_info('task_templates') WHERE name='parent_id'"
))
field_exists = result.fetchone()[0] > 0
if field_exists:
print("⚠️ parent_id 字段已存在,跳过迁移")
return
# 添加 parent_id 字段
print("📝 正在添加 parent_id 字段...")
db.execute(text(
"ALTER TABLE task_templates ADD COLUMN parent_id INTEGER"
))
db.commit()
print("✅ parent_id 字段添加成功")
# 创建外键约束(SQLite 不支持直接添加外键,需要重建表)
print("\n📝 注意:SQLite 不支持直接添加外键约束")
print(" 如需外键约束,请重建表或在下次完整迁移时处理")
print("\n" + "=" * 60)
print("✅ 数据库迁移完成!")
print("=" * 60)
except Exception as e:
print(f"\n❌ 迁移失败: {str(e)}")
db.rollback()
import traceback
traceback.print_exc()
sys.exit(1)
finally:
db.close()
if __name__ == "__main__":
migrate()
@@ -0,0 +1,57 @@
"""
添加 payload_config 字段到 check_in_tasks 表的迁移脚本
运行方式:
python backend/scripts/migrate_add_payload_config.py
.venv/Scripts/python.exe backend/scripts/migrate_add_payload_config.py
"""
import sys
import os
from pathlib import Path
# 添加项目根目录到 Python 路径
project_root = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(project_root))
from sqlalchemy import text
from backend.models.database import engine
def migrate():
"""执行迁移"""
print("开始迁移:添加 payload_config 字段...")
with engine.connect() as conn:
# 检查字段是否已存在
result = conn.execute(text("PRAGMA table_info(check_in_tasks)"))
columns = [row[1] for row in result]
if 'payload_config' in columns:
print("[OK] payload_config 字段已存在,跳过迁移")
return
# 添加 payload_config 字段(JSON 文本,存储完整的 payload 配置)
print("添加 payload_config 字段...")
conn.execute(text("""
ALTER TABLE check_in_tasks
ADD COLUMN payload_config TEXT DEFAULT '{}' NOT NULL
"""))
conn.commit()
print("[OK] payload_config 字段添加成功")
print("\n注意:现有任务的 payload_config 默认为空 JSON {}")
print(" Worker 将使用默认的固定字段值。")
print(" 新创建的任务将从模板继承完整的 payload 配置。")
if __name__ == "__main__":
try:
migrate()
print("\n[SUCCESS] 迁移完成!")
except Exception as e:
print(f"\n[ERROR] 迁移失败: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
@@ -0,0 +1,132 @@
"""
删除 check_in_tasks 表中不再需要的旧列的迁移脚本
删除的列:
- signature (VARCHAR) - 已在 payload_config 中
- texts (VARCHAR) - 已在 payload_config 中
- values (TEXT) - 已在 payload_config 中
- thread_id (VARCHAR) - 已在 payload_config 的 ThreadId 中
- email (VARCHAR) - 从 user 表的 email 字段获取
新架构只保留:
- id, user_id, payload_config, name, is_active, created_at, updated_at
运行方式:
python backend/scripts/migrate_remove_old_columns.py
venv/Scripts/python.exe backend/scripts/migrate_remove_old_columns.py
"""
import sys
import os
from pathlib import Path
# 添加项目根目录到 Python 路径
project_root = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(project_root))
from sqlalchemy import text, inspect
from backend.models.database import engine
def migrate():
"""执行迁移:删除旧列"""
print("开始迁移:删除 check_in_tasks 表中的旧列...")
print("将删除的列: signature, texts, values, thread_id, email")
print("=" * 60)
with engine.connect() as conn:
# 检查表结构
inspector = inspect(engine)
columns = [col['name'] for col in inspector.get_columns('check_in_tasks')]
print(f"\n当前表列: {', '.join(columns)}")
old_columns = ['signature', 'texts', 'values', 'thread_id', 'email']
columns_to_remove = [col for col in old_columns if col in columns]
if not columns_to_remove:
print("\n[OK] 旧列已被删除,跳过迁移")
return
print(f"\n需要删除的列: {', '.join(columns_to_remove)}")
# SQLite 不支持直接 DROP COLUMN,需要重建表
# 步骤:
# 1. 创建新表(只包含需要的列)
# 2. 复制数据
# 3. 删除旧表
# 4. 重命名新表
print("\n正在重建表结构...")
# 1. 创建新表
conn.execute(text("""
CREATE TABLE check_in_tasks_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
payload_config TEXT NOT NULL DEFAULT '{}',
name VARCHAR(100) DEFAULT '',
is_active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""))
print(" [OK] 创建新表结构")
# 2. 复制数据(只复制保留的列)
conn.execute(text("""
INSERT INTO check_in_tasks_new
(id, user_id, payload_config, name, is_active, created_at, updated_at)
SELECT
id, user_id, payload_config, name, is_active, created_at, updated_at
FROM check_in_tasks
"""))
print(" [OK] 复制数据到新表")
# 3. 删除旧表
conn.execute(text("DROP TABLE check_in_tasks"))
print(" [OK] 删除旧表")
# 4. 重命名新表
conn.execute(text("ALTER TABLE check_in_tasks_new RENAME TO check_in_tasks"))
print(" [OK] 重命名新表")
# 5. 重建索引
conn.execute(text("""
CREATE INDEX ix_check_in_tasks_user_id ON check_in_tasks(user_id)
"""))
conn.execute(text("""
CREATE INDEX ix_check_in_tasks_id ON check_in_tasks(id)
"""))
conn.execute(text("""
CREATE INDEX ix_task_user_active ON check_in_tasks(user_id, is_active)
"""))
print(" [OK] 重建索引")
conn.commit()
print("\n[SUCCESS] 表结构迁移成功!")
print("\n新的表结构:")
inspector = inspect(engine)
new_columns = [col['name'] for col in inspector.get_columns('check_in_tasks')]
print(f" 列: {', '.join(new_columns)}")
if __name__ == "__main__":
try:
migrate()
print("\n" + "=" * 60)
print("[完成] 迁移成功完成!")
print("\n数据库已更新为新架构:")
print(" - 删除了 signature, texts, values, thread_id, email 列")
print(" - 保留了 payload_config 列(存储完整的 JSON payload")
print(" - ThreadId 现在存储在 payload_config 中")
print(" - Email 现在从 user 表获取")
print("=" * 60)
except Exception as e:
print(f"\n[ERROR] 迁移失败: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
+85
View File
@@ -0,0 +1,85 @@
import logging
from datetime import datetime, timedelta
from typing import List, Dict, Any
from sqlalchemy.orm import Session
from backend.models import User
logger = logging.getLogger(__name__)
class AdminService:
"""管理员服务"""
@staticmethod
def get_pending_users(db: Session) -> List[User]:
"""获取待审批用户列表"""
users = db.query(User).filter(
User.is_approved == False,
User.role == "user"
).order_by(User.created_at.desc()).all()
return users
@staticmethod
def approve_user(user_id: int, db: Session) -> Dict[str, Any]:
"""审批通过用户"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return {"success": False, "message": "用户不存在"}
if user.is_approved:
return {"success": False, "message": "用户已经通过审批"}
user.is_approved = True
user.updated_at = datetime.now()
db.commit()
logger.info(f"管理员审批通过用户: {user.alias} (ID: {user.id})")
return {
"success": True,
"message": "审批成功",
"user_id": user.id
}
@staticmethod
def reject_user(user_id: int, db: Session) -> Dict[str, Any]:
"""拒绝并删除用户"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return {"success": False, "message": "用户不存在"}
alias = user.alias
db.delete(user)
db.commit()
logger.info(f"管理员拒绝用户: {alias} (ID: {user_id})")
return {
"success": True,
"message": "已拒绝并删除用户"
}
@staticmethod
def delete_expired_pending_users(db: Session) -> int:
"""删除24小时未审批的用户"""
cutoff_time = datetime.now() - timedelta(hours=24)
expired_users = db.query(User).filter(
User.is_approved == False,
User.role == "user",
User.created_at < cutoff_time
).all()
count = len(expired_users)
for user in expired_users:
logger.info(f"删除过期未审批用户: {user.alias} (ID: {user.id})")
db.delete(user)
db.commit()
return count
+477
View File
@@ -0,0 +1,477 @@
import uuid
import logging
import threading
import jwt
import bcrypt
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from urllib.parse import unquote
from sqlalchemy.orm import Session
from backend.models import User
from backend.workers.token_refresher import get_token_headless, get_session_data
from backend.config import settings
logger = logging.getLogger(__name__)
class AuthService:
"""认证服务"""
@staticmethod
def request_qrcode(alias: str, client_ip: str, db: Session) -> Dict[str, Any]:
"""
请求 QQ 扫码二维码(支持新用户注册)
Args:
alias: 用户别名
client_ip: 客户端 IP 地址
db: 数据库会话
Returns:
包含 session_id 和 qrcode_base64 的字典
"""
from backend.services.registration_manager import registration_manager
import time
# 检查用户名是否已在数据库中存在
existing_user = db.query(User).filter(User.alias == alias).first()
# 生成唯一的会话 ID
session_id = str(uuid.uuid4())
if existing_user:
# 检查是否为空 jwt_sub(测试账号)
if not existing_user.jwt_sub or existing_user.jwt_sub == "":
logger.warning(f"用户 {alias} 是测试账号(空 jwt_sub),禁止登录")
return {
"status": "error",
"message": "此账户为测试账号,暂未绑定 QQ,无法登录"
}
# 老用户:刷新 Token
logger.info(f"老用户 {alias} 请求刷新 Token,会话: {session_id}")
# 在后台线程启动 Selenium,传入 jwt_sub
thread = threading.Thread(
target=get_token_headless,
args=(session_id, existing_user.jwt_sub, alias, client_ip),
daemon=True
)
thread.start()
else:
# 新用户:预占用户名
if not registration_manager.reserve_alias(alias, session_id, timeout_seconds=120):
logger.warning(f"用户名 {alias} 已被预占")
return {
"status": "error",
"message": "该用户名正在被其他人注册,请稍后再试或更换用户名"
}
logger.info(f"新用户 {alias} 请求注册,会话: {session_id},已预占用户名")
# 在后台线程启动 Selenium,不传入 jwt_sub(新用户)
thread = threading.Thread(
target=get_token_headless,
args=(session_id, None, alias, client_ip),
daemon=True
)
thread.start()
# 等待二维码生成(最多等待 30 秒)
logger.info(f"等待会话 {session_id} 的二维码生成...")
max_wait_time = 30
start_time = time.time()
while time.time() - start_time < max_wait_time:
session_data = get_session_data(session_id)
if session_data:
status = session_data.get("status")
# 二维码已生成
if status == "waiting_scan":
qr_image_data = session_data.get("qr_image_data")
if qr_image_data:
logger.info(f"会话 {session_id} 的二维码已生成")
return {
"session_id": session_id,
"qrcode_base64": qr_image_data
}
# 如果已经失败,直接返回错误
elif status == "failed":
error_msg = session_data.get("message", "生成二维码失败")
logger.error(f"会话 {session_id} 生成二维码失败: {error_msg}")
return {
"status": "error",
"message": error_msg
}
# 每 0.5 秒检查一次
time.sleep(0.5)
# 超时
logger.error(f"会话 {session_id} 等待二维码生成超时({max_wait_time}秒)")
return {
"status": "error",
"message": f"生成二维码超时,请重试"
}
@staticmethod
def get_qrcode_status(session_id: str, db: Session) -> Dict[str, Any]:
"""
检查二维码扫描状态
Args:
session_id: 会话 ID
db: 数据库会话
Returns:
包含状态信息的字典
"""
session_data = get_session_data(session_id)
if not session_data:
return {
"status": "pending",
"message": "会话不存在或正在初始化"
}
status = session_data.get("status")
jwt_sub = session_data.get("jwt_sub") # 使用 jwt_sub 而非 signature
if status == "waiting_scan":
return {
"status": "waiting_scan",
"message": "请使用手机 QQ 扫描二维码",
"qrcode_image": session_data.get("qr_image_data")
}
elif status == "success":
token = session_data.get("token")
alias = session_data.get("alias") # 新增:从 session 中获取 alias
# 解析 JWT Token 获取 jwt_exp 和 jwt_sub
jwt_exp = "0"
jwt_sub = ""
if not token:
logger.error("Token 为空")
return {
"status": "error",
"message": "Token 为空"
}
try:
# 清洗 TokenURL 解码 + 去除 Bearer 前缀(参考 v1 实现)
pure_token = unquote(token) # URL 解码
if pure_token.lower().startswith('bearer '):
pure_token = pure_token[7:] # 去除 "Bearer " 前缀
decoded = jwt.decode(pure_token, options={"verify_signature": False})
jwt_exp = str(decoded.get("exp", 0))
jwt_sub = decoded.get("sub", "")
logger.info(f"成功解析 JWT for sub={jwt_sub}, exp={jwt_exp}")
except Exception as e:
logger.error(f"解析 JWT Token 失败: {e}")
return {
"status": "error",
"message": f"Token 解析失败: {str(e)}"
}
# 查找用户(通过 jwt_sub
user = db.query(User).filter(User.jwt_sub == jwt_sub).first()
if user:
# 老用户:更新 Token(存储清理后的 token
# 注意:如果通过别名登录,需要验证 jwt_sub 是否匹配
if alias and alias == user.alias:
# 用户使用别名登录,验证 jwt_sub 是否一致
# 如果用户之前的 jwt_sub 不为空且与当前不一致,说明QQ号被换绑了
existing_jwt_sub = getattr(user, 'jwt_sub', '')
if isinstance(existing_jwt_sub, str) and existing_jwt_sub.strip() and existing_jwt_sub != jwt_sub:
logger.warning(f"⚠️ 用户 {user.alias} 的 jwt_sub 不匹配!数据库: {existing_jwt_sub}, 当前: {jwt_sub}")
return {
"status": "error",
"message": "QQ账号不匹配,请使用正确的QQ号扫码登录"
}
user.authorization = pure_token # 存储清理后的 token
user.jwt_exp = jwt_exp
user.updated_at = datetime.now()
db.commit()
db.refresh(user)
logger.info(f"更新老用户 {user.alias} 的 Token")
return {
"status": "success",
"message": "登录成功",
"token": pure_token, # 返回清理后的 token
"user": {
"id": user.id,
"alias": user.alias,
"role": user.role,
"is_approved": user.is_approved,
"jwt_sub": user.jwt_sub
},
"is_new_user": False
}
else:
# 新用户:创建账户
from backend.services.registration_manager import registration_manager
# 验证用户名是否被预占
if not alias or not registration_manager.is_alias_reserved(alias):
logger.error(f"新用户注册失败:用户名 {alias} 未预占或已过期")
return {
"status": "error",
"message": "注册失败:会话已过期,请重新扫码"
}
# 检查用户名是否已被其他人注册(防止竞态)
existing_user_by_alias = db.query(User).filter(User.alias == alias).first()
if existing_user_by_alias:
registration_manager.release_alias(alias)
logger.error(f"新用户注册失败:用户名 {alias} 已被占用")
return {
"status": "error",
"message": "注册失败:用户名已被占用,请更换用户名"
}
# 创建新用户(待审批状态)
client_ip = session_data.get("client_ip", "")
new_user = User(
jwt_sub=jwt_sub,
alias=alias,
authorization=pure_token, # 存储清理后的 token
jwt_exp=jwt_exp,
role="user",
is_approved=False, # 待审批
registered_ip=client_ip
)
db.add(new_user)
db.commit()
db.refresh(new_user)
# 释放用户名预占
registration_manager.release_alias(alias)
logger.info(f"✅ 新用户 {alias} 注册成功(待审批),ID: {new_user.id}")
# 发送邮件通知管理员
try:
from backend.services.email_service import EmailService
EmailService.notify_new_user_registration(new_user, db)
except Exception as e:
logger.error(f"发送注册通知邮件失败: {e}")
return {
"status": "success",
"message": "注册成功,请等待管理员审批(24小时内)",
"token": pure_token, # 返回清理后的 token
"user": {
"id": new_user.id,
"alias": new_user.alias,
"role": new_user.role,
"is_approved": new_user.is_approved,
"jwt_sub": new_user.jwt_sub
},
"is_new_user": True
}
elif status == "error":
return {
"status": "error",
"message": session_data.get("message", "未知错误")
}
else:
return {
"status": "pending",
"message": "正在初始化..."
}
@staticmethod
def verify_token(authorization: str, db: Session) -> Dict[str, Any]:
"""
验证 Token 有效性
Args:
authorization: Token
db: 数据库会话
Returns:
包含验证结果的字典
"""
# 移除 "Bearer " 前缀
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
# 从数据库查询用户
user = db.query(User).filter(User.authorization == token).first()
if not user:
return {
"is_valid": False,
"message": "Token 无效"
}
# 检查 Token 是否过期
if user.jwt_exp and user.jwt_exp != "0":
try:
exp_timestamp = int(user.jwt_exp)
current_timestamp = int(datetime.now().timestamp())
if current_timestamp > exp_timestamp:
return {
"is_valid": False,
"message": "Token 已过期",
"user_id": user.id
}
# 计算剩余天数
days_until_expiry = (exp_timestamp - current_timestamp) // 86400
return {
"is_valid": True,
"message": "Token 有效",
"user_id": user.id,
"days_until_expiry": days_until_expiry
}
except ValueError:
logger.error(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}")
return {
"is_valid": True,
"message": "Token 有效",
"user_id": user.id
}
@staticmethod
def alias_login(alias: str, password: str, db: Session) -> Dict[str, Any]:
"""
别名+密码登录
Args:
alias: 用户别名
password: 密码
db: 数据库会话
Returns:
包含登录结果的字典
"""
# 查找用户
user = db.query(User).filter(User.alias == alias).first()
if not user:
logger.warning(f"别名登录失败:用户 {alias} 不存在")
return {
"success": False,
"message": "用户名或密码错误"
}
# 检查用户是否设置了密码
if not user.password_hash:
logger.warning(f"别名登录失败:用户 {alias} 未设置密码")
return {
"success": False,
"message": "该用户未设置密码,请使用扫码登录"
}
# 验证密码
try:
password_bytes = password.encode('utf-8')
hash_bytes = user.password_hash.encode('utf-8')
if not bcrypt.checkpw(password_bytes, hash_bytes):
logger.warning(f"别名登录失败:用户 {alias} 密码错误")
return {
"success": False,
"message": "用户名或密码错误"
}
except Exception as e:
logger.error(f"密码验证异常:{e}")
return {
"success": False,
"message": "登录失败,请稍后重试"
}
# 检查 Token 状态(仅作提示,不阻止登录)
token_warning = None
if not user.authorization or user.jwt_exp == "0":
logger.info(f"用户 {alias} Token 无效,允许密码登录但需提示用户更新")
token_warning = "token_invalid"
else:
# 检查 Token 是否过期
try:
exp_timestamp = int(user.jwt_exp)
current_timestamp = int(datetime.now().timestamp())
if current_timestamp > exp_timestamp:
logger.info(f"用户 {alias} Token 已过期,允许密码登录但需提示用户更新")
token_warning = "token_expired"
except ValueError:
logger.error(f"用户 {user.id} 的 jwt_exp 格式不正确: {user.jwt_exp}")
# 登录成功
logger.info(f"✅ 用户 {alias} (ID: {user.id}) 别名登录成功")
result = {
"success": True,
"message": "登录成功",
"user_id": user.id,
"authorization": user.authorization,
"alias": user.alias
}
# 如果 Token 有问题,添加警告信息
if token_warning:
result["token_warning"] = token_warning
if token_warning == "token_invalid":
result["warning_message"] = "登录成功,但检测到登录凭证无效,部分功能可能受限,建议扫码更新"
elif token_warning == "token_expired":
result["warning_message"] = "登录成功,但检测到登录凭证已过期,部分功能可能受限,建议扫码更新"
return result
@staticmethod
def hash_password(password: str) -> str:
"""
使用 bcrypt 加密密码
Args:
password: 明文密码
Returns:
加密后的密码哈希
"""
password_bytes = password.encode('utf-8')
salt = bcrypt.gensalt()
hash_bytes = bcrypt.hashpw(password_bytes, salt)
return hash_bytes.decode('utf-8')
@staticmethod
def verify_password(password: str, password_hash: str) -> bool:
"""
验证密码
Args:
password: 明文密码
password_hash: 密码哈希
Returns:
密码是否正确
"""
try:
password_bytes = password.encode('utf-8')
hash_bytes = password_hash.encode('utf-8')
return bcrypt.checkpw(password_bytes, hash_bytes)
except Exception as e:
logger.error(f"密码验证异常:{e}")
return False
+588
View File
@@ -0,0 +1,588 @@
import logging
from typing import List, Dict, Any, Optional
from datetime import datetime
from sqlalchemy.orm import Session
import json
import threading
from backend.models import User, CheckInTask, CheckInRecord
from backend.workers.check_in_worker import perform_check_in
logger = logging.getLogger(__name__)
class CheckInService:
"""打卡服务"""
@staticmethod
def create_pending_check_in_record(task: CheckInTask, trigger_type: str, db: Session) -> int:
"""
创建一个待处理的打卡记录并返回 record_id
Args:
task: 打卡任务对象
trigger_type: 触发类型 (manual/scheduled/admin)
db: 数据库会话
Returns:
打卡记录 ID
"""
logger.info(f"🎯 创建待处理打卡记录 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id})")
# 创建一个 pending 状态的记录
record = CheckInRecord(
task_id=task.id,
status="pending",
response_text="",
error_message="",
location="{}",
trigger_type=trigger_type
)
db.add(record)
db.commit()
db.refresh(record)
logger.info(f"✅ 创建待处理记录成功 - Record ID: {record.id}")
return record.id
@staticmethod
def execute_check_in_async(task_id: int, record_id: int, user_token: str):
"""
在后台线程中执行打卡操作
Args:
task_id: 任务 ID
record_id: 打卡记录 ID
user_token: 用户 Token
"""
from backend.models.database import SessionLocal
# 创建独立的数据库会话
db = SessionLocal()
try:
logger.info(f"🤖 后台线程开始执行打卡 - Task ID: {task_id}, Record ID: {record_id}")
# 获取任务对象
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
if not task:
logger.error(f"❌ 任务不存在 - Task ID: {task_id}")
# 更新记录状态为失败
record = db.query(CheckInRecord).filter(CheckInRecord.id == record_id).first()
if record:
db.query(CheckInRecord).filter(CheckInRecord.id == record_id).update({
"status": "failure",
"error_message": "任务不存在"
})
db.commit()
return
# 执行打卡
result = perform_check_in(task, user_token)
# 更新记录
db.query(CheckInRecord).filter(CheckInRecord.id == record_id).update({
"status": result["status"],
"response_text": result["response_text"],
"error_message": result["error_message"]
})
db.commit()
if result["success"]:
logger.info(f"✅ 后台打卡成功 - Record ID: {record_id}")
else:
logger.error(f"❌ 后台打卡失败 - Record ID: {record_id}, 错误: {result['error_message']}")
except Exception as e:
logger.error(f"💥 后台打卡异常 - Task ID: {task_id}, Record ID: {record_id}, 错误: {str(e)}")
# 更新记录状态
try:
db.query(CheckInRecord).filter(CheckInRecord.id == record_id).update({
"status": "failure",
"error_message": f"后台执行异常: {str(e)}"
})
db.commit()
except Exception as inner_e:
logger.error(f"💥 更新记录失败: {str(inner_e)}")
finally:
db.close()
@staticmethod
def start_async_check_in(task: CheckInTask, trigger_type: str, db: Session) -> Dict[str, Any]:
"""
启动异步打卡任务
Args:
task: 打卡任务对象
trigger_type: 触发类型 (manual/scheduled/admin)
db: 数据库会话
Returns:
包含 record_id 的字典
"""
logger.info(f"🚀 启动异步打卡 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id})")
# 获取用户的 Token
user = task.user
if not user or not user.authorization:
error_msg = f"用户没有有效的 Token"
logger.error(f"{error_msg} - Task ID: {task.id}")
# 创建失败记录
record = CheckInRecord(
task_id=task.id,
status="failure",
response_text="",
error_message=error_msg,
location="{}",
trigger_type=trigger_type
)
db.add(record)
db.commit()
db.refresh(record)
return {
"record_id": record.id,
"status": "failure",
"message": error_msg
}
# 检查 Token 是否过期
if user.jwt_exp and user.jwt_exp != "0":
try:
exp_timestamp = int(user.jwt_exp)
current_timestamp = int(datetime.now().timestamp())
if current_timestamp > exp_timestamp:
error_msg = f"Token 已过期"
logger.warning(f"{error_msg} - Task ID: {task.id}")
record = CheckInRecord(
task_id=task.id,
status="failure",
response_text="",
error_message=f"{error_msg},请重新扫码登录",
location="{}",
trigger_type=trigger_type
)
db.add(record)
db.commit()
db.refresh(record)
return {
"record_id": record.id,
"status": "failure",
"message": f"{error_msg},请重新扫码登录"
}
except ValueError:
pass
# 创建待处理记录
record_id = CheckInService.create_pending_check_in_record(task, trigger_type, db)
# 在后台线程中执行打卡
import threading
thread = threading.Thread(
target=CheckInService.execute_check_in_async,
args=(task.id, record_id, user.authorization),
daemon=True
)
thread.start()
logger.info(f"✅ 异步打卡任务已启动 - Record ID: {record_id}")
return {
"record_id": record_id,
"status": "pending",
"message": "打卡任务已启动,正在后台处理"
}
@staticmethod
def perform_task_check_in(task: CheckInTask, trigger_type: str, db: Session) -> Dict[str, Any]:
"""
执行单个任务的打卡
Args:
task: 打卡任务对象
trigger_type: 触发类型 (manual/scheduled/admin)
db: 数据库会话
Returns:
打卡结果字典
"""
logger.info(f"🎯 开始打卡 - 任务: {task.name or f'Task-{task.id}'} (ID: {task.id}), 触发: {trigger_type}")
# 获取用户的 Token
user = task.user
if not user or not user.authorization:
error_msg = f"用户没有有效的 Token"
logger.error(f"{error_msg} - Task ID: {task.id}, User ID: {user.id if user else 'None'}")
# 记录失败
record = CheckInRecord(
task_id=task.id,
status="failure",
response_text="",
error_message=error_msg,
location="{}",
trigger_type=trigger_type
)
db.add(record)
db.commit()
db.refresh(record)
return {
"success": False,
"message": error_msg,
"record_id": record.id
}
# 检查 Token 是否过期
if user.jwt_exp and user.jwt_exp != "0":
try:
exp_timestamp = int(user.jwt_exp)
current_timestamp = int(datetime.now().timestamp())
if current_timestamp > exp_timestamp:
error_msg = f"Token 已过期"
expires_at = datetime.fromtimestamp(exp_timestamp)
logger.warning(f"{error_msg} - 过期时间: {expires_at}, 用户: {user.alias}, Task ID: {task.id}")
# 记录失败
record = CheckInRecord(
task_id=task.id,
status="failure",
response_text="",
error_message=error_msg,
location="{}",
trigger_type=trigger_type
)
db.add(record)
db.commit()
db.refresh(record)
return {
"success": False,
"message": f"{error_msg},请重新扫码登录",
"record_id": record.id
}
except ValueError:
pass
# 执行打卡(传递 task 对象和用户 token)
logger.info(f"🤖 调用 Selenium Worker 执行打卡...")
result = perform_check_in(task, user.authorization)
# 保存打卡记录
record = CheckInRecord(
task_id=task.id,
status=result["status"],
response_text=result["response_text"],
error_message=result["error_message"],
location="{}",
trigger_type=trigger_type
)
db.add(record)
db.commit()
db.refresh(record)
if result["success"]:
logger.info(f"✅ 打卡成功 - Record ID: {record.id}")
else:
logger.error(f"❌ 打卡失败 - {result['error_message']}")
return {
"success": result["success"],
"message": "打卡成功" if result["success"] else f"打卡失败: {result['error_message']}",
"record_id": record.id
}
@staticmethod
def batch_check_in_tasks(task_ids: List[int], db: Session) -> Dict[str, Any]:
"""
批量打卡任务
Args:
task_ids: 任务 ID 列表
db: 数据库会话
Returns:
批量打卡结果
"""
logger.info(f"🚀 开始批量打卡,任务数量: {len(task_ids)}")
results = {
"total": len(task_ids),
"success": 0,
"failure": 0,
"skipped": 0,
"details": []
}
# 优化:一次性查询所有任务,避免 N+1 查询
tasks = db.query(CheckInTask).filter(CheckInTask.id.in_(task_ids)).all()
tasks_dict = {task.id: task for task in tasks}
for task_id in task_ids:
try:
task = tasks_dict.get(task_id)
if not task:
logger.warning(f"⚠️ 任务 ID {task_id} 不存在,跳过")
results["skipped"] += 1
results["details"].append({
"task_id": task_id,
"success": False,
"message": "任务不存在"
})
continue
# 执行打卡(移除 is_active 检查,允许手动打卡)
result = CheckInService.perform_task_check_in(task, "admin", db)
if result["success"]:
results["success"] += 1
logger.info(f"✅ 任务 {task_id} 批量打卡成功")
else:
results["failure"] += 1
logger.error(f"❌ 任务 {task_id} 批量打卡失败: {result['message']}")
results["details"].append({
"task_id": task_id,
"task_name": task.name or f'Task-{task.id}',
"success": result["success"],
"message": result["message"],
"record_id": result.get("record_id")
})
except Exception as e:
logger.error(f"💥 任务 {task_id} 处理异常: {str(e)}")
results["failure"] += 1
results["details"].append({
"task_id": task_id,
"success": False,
"message": f"异常: {str(e)}"
})
logger.info(f"📊 批量打卡完成 - 成功: {results['success']}, 失败: {results['failure']}, 跳过: {results['skipped']}")
return results
@staticmethod
def scheduled_check_in_all_active_tasks(db: Session) -> Dict[str, Any]:
"""
定时任务:为所有启用的任务执行打卡
Args:
db: 数据库会话
Returns:
打卡结果统计
"""
logger.info("开始执行定时打卡任务...")
# 获取所有启用的任务(预加载用户信息)
from sqlalchemy.orm import joinedload
active_tasks = db.query(CheckInTask).options(
joinedload(CheckInTask.user)
).filter(CheckInTask.is_active == True).all()
logger.info(f"找到 {len(active_tasks)} 个启用的任务")
results = {
"total": len(active_tasks),
"success": 0,
"failure": 0,
"skipped": 0,
"details": []
}
for task in active_tasks:
# 检查用户是否有 Token
if not task.user or not task.user.authorization:
logger.warning(f"任务 ID: {task.id} 的用户没有 Token,跳过")
results["skipped"] += 1
continue
# 检查 Token 是否过期
if task.user.jwt_exp and task.user.jwt_exp != "0":
try:
exp_timestamp = int(task.user.jwt_exp)
current_timestamp = int(datetime.now().timestamp())
if current_timestamp > exp_timestamp:
logger.warning(f"任务 ID: {task.id} 的用户 Token 已过期,跳过")
results["skipped"] += 1
continue
except ValueError:
pass
# 执行打卡
result = CheckInService.perform_task_check_in(task, "scheduled", db)
if result["success"]:
results["success"] += 1
else:
results["failure"] += 1
results["details"].append({
"task_id": task.id,
"task_name": task.name or f'Task-{task.id}',
"success": result["success"],
"message": result["message"]
})
logger.info(f"定时打卡任务完成,成功: {results['success']}, 失败: {results['failure']}, 跳过: {results['skipped']}")
return results
@staticmethod
def get_task_records(
task_id: int,
db: Session,
skip: int = 0,
limit: int = 100,
status: Optional[str] = None,
trigger_type: Optional[str] = None
) -> List[CheckInRecord]:
"""
获取任务的打卡记录
Args:
task_id: 任务 ID
db: 数据库会话
skip: 跳过记录数
limit: 限制记录数
status: 过滤状态 (success/failure)
trigger_type: 过滤触发类型 (scheduler/manual)
Returns:
打卡记录列表
"""
query = db.query(CheckInRecord).filter(CheckInRecord.task_id == task_id)
if status:
query = query.filter(CheckInRecord.status == status)
if trigger_type:
query = query.filter(CheckInRecord.trigger_type == trigger_type)
return query.order_by(
CheckInRecord.check_in_time.desc()
).offset(skip).limit(limit).all()
@staticmethod
def get_user_records(
user_id: int,
db: Session,
skip: int = 0,
limit: int = 100,
status: Optional[str] = None,
trigger_type: Optional[str] = None
) -> List[CheckInRecord]:
"""
获取用户的所有打卡记录
Args:
user_id: 用户 ID
db: 数据库会话
skip: 跳过记录数
limit: 限制记录数
status: 过滤状态 (success/failure)
trigger_type: 过滤触发类型 (scheduler/manual)
Returns:
打卡记录列表
"""
# 获取用户的所有任务ID
user_task_ids = db.query(CheckInTask.id).filter(CheckInTask.user_id == user_id).all()
task_ids = [task_id for (task_id,) in user_task_ids]
# 查询这些任务的打卡记录
query = db.query(CheckInRecord).filter(CheckInRecord.task_id.in_(task_ids))
if status:
query = query.filter(CheckInRecord.status == status)
if trigger_type:
query = query.filter(CheckInRecord.trigger_type == trigger_type)
return query.order_by(
CheckInRecord.check_in_time.desc()
).offset(skip).limit(limit).all()
@staticmethod
def get_all_records(
db: Session,
skip: int = 0,
limit: int = 100,
task_id: Optional[int] = None,
status: Optional[str] = None
) -> List[CheckInRecord]:
"""
获取所有打卡记录(管理员)
Args:
db: 数据库会话
skip: 跳过记录数
limit: 限制记录数
task_id: 过滤任务 ID
status: 过滤状态
Returns:
打卡记录列表
"""
query = db.query(CheckInRecord)
if task_id:
query = query.filter(CheckInRecord.task_id == task_id)
if status:
query = query.filter(CheckInRecord.status == status)
return query.order_by(
CheckInRecord.check_in_time.desc()
).offset(skip).limit(limit).all()
@staticmethod
def enrich_record_with_user_task_info(record: CheckInRecord, db: Session) -> dict:
"""
为打卡记录添加用户和任务信息
Args:
record: 打卡记录对象
db: 数据库会话
Returns:
包含额外信息的记录字典
"""
# 获取任务信息
task = db.query(CheckInTask).filter(CheckInTask.id == record.task_id).first()
# 获取用户信息
user = None
task_name = None
thread_id = None
if task:
user = db.query(User).filter(User.id == task.user_id).first()
task_name = task.name
# 从 payload_config 提取 ThreadId
try:
payload = json.loads(str(task.payload_config))
thread_id = payload.get('ThreadId')
except:
pass
# 转换为字典并添加额外字段
record_dict = {
'id': record.id,
'task_id': record.task_id,
'status': record.status,
'response_text': record.response_text,
'error_message': record.error_message,
'location': record.location,
'trigger_type': record.trigger_type,
'check_in_time': record.check_in_time,
'user_id': user.id if user else None,
'user_email': user.email if user else None,
'task_name': task_name,
'thread_id': thread_id,
}
return record_dict
+301
View File
@@ -0,0 +1,301 @@
import smtplib
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import List
from datetime import datetime
from sqlalchemy.orm import Session
from backend.config import settings
from backend.models import User
logger = logging.getLogger(__name__)
class EmailService:
"""邮件通知服务"""
@staticmethod
def send_email(to_emails: List[str], subject: str, body_html: str) -> bool:
"""
发送邮件
Args:
to_emails: 收件人邮箱列表
subject: 邮件主题
body_html: 邮件正文(HTML 格式)
Returns:
是否发送成功
"""
# 检查邮件配置
if not all([settings.SMTP_SERVER, settings.SMTP_SENDER_EMAIL, settings.SMTP_SENDER_PASSWORD]):
logger.warning("邮件配置不完整,跳过发送邮件")
return False
try:
# 创建邮件
msg = MIMEMultipart('alternative')
msg['From'] = settings.SMTP_SENDER_EMAIL
msg['To'] = ', '.join(to_emails)
msg['Subject'] = subject
# 添加 HTML 正文
html_part = MIMEText(body_html, 'html', 'utf-8')
msg.attach(html_part)
# 连接 SMTP 服务器并发送
if settings.SMTP_USE_SSL:
server = smtplib.SMTP_SSL(settings.SMTP_SERVER, settings.SMTP_PORT)
else:
server = smtplib.SMTP(settings.SMTP_SERVER, settings.SMTP_PORT)
server.starttls()
server.login(settings.SMTP_SENDER_EMAIL, settings.SMTP_SENDER_PASSWORD)
server.sendmail(settings.SMTP_SENDER_EMAIL, to_emails, msg.as_string())
server.quit()
logger.info(f"邮件发送成功: {subject} -> {', '.join(to_emails)}")
return True
except Exception as e:
logger.error(f"邮件发送失败: {e}")
return False
@staticmethod
def notify_new_user_registration(user: User, db: Session) -> bool:
"""
通知管理员有新用户注册
Args:
user: 新注册的用户
db: 数据库会话
Returns:
是否发送成功
"""
# 查询所有管理员邮箱
admins = db.query(User).filter(User.role == "admin", User.email.isnot(None)).all()
admin_emails = [admin.email for admin in admins if admin.email]
if not admin_emails:
logger.warning("没有找到管理员邮箱,无法发送通知")
return False
# 构建邮件内容
subject = f"【接龙自动打卡系统】新用户注册通知 - {user.alias}"
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background-color: #667eea;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}}
.content {{
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
border-radius: 0 0 5px 5px;
}}
.info-table {{
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}}
.info-table td {{
padding: 10px;
border-bottom: 1px solid #ddd;
}}
.info-table td:first-child {{
font-weight: bold;
width: 120px;
}}
.footer {{
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}}
.warning {{
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 10px;
margin: 15px 0;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>🔔 新用户注册通知</h2>
</div>
<div class="content">
<p>尊敬的管理员,</p>
<p>有新用户注册了接龙自动打卡系统,请及时审批。</p>
<table class="info-table">
<tr>
<td>用户名</td>
<td>{user.alias}</td>
</tr>
<tr>
<td>用户 ID</td>
<td>{user.id}</td>
</tr>
<tr>
<td>注册时间</td>
<td>{user.created_at.strftime('%Y-%m-%d %H:%M:%S') if user.created_at else '未知'}</td>
</tr>
<tr>
<td>注册 IP</td>
<td>{user.registered_ip or '未记录'}</td>
</tr>
</table>
<div class="warning">
<strong>⚠️ 重要提示:</strong>
<p>该用户需要在 24 小时内通过审批,否则账户将被自动删除。</p>
<p>请登录管理后台进行审批操作。</p>
</div>
<p>登录地址:<a href="http://localhost:5173/admin/users">http://localhost:5173/admin/users</a></p>
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
<p>接龙自动打卡系统 © {datetime.now().year}</p>
</div>
</div>
</body>
</html>
"""
return EmailService.send_email(admin_emails, subject, body_html)
@staticmethod
def notify_check_in_result(user: User, task_info: dict, success: bool, message: str = "") -> bool:
"""
通知用户打卡结果
Args:
user: 用户对象
task_info: 打卡任务信息(包含 thread_id, texts, values 等)
success: 打卡是否成功
message: 额外消息
Returns:
是否发送成功
"""
if not user.email:
logger.info(f"用户 {user.alias} 未设置邮箱,跳过打卡通知")
return False
# 构建邮件内容
status_text = "✅ 成功" if success else "❌ 失败"
status_color = "#28a745" if success else "#dc3545"
subject = f"【接龙自动打卡】打卡{status_text} - {user.alias}"
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background-color: {status_color};
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}}
.content {{
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
border-radius: 0 0 5px 5px;
}}
.info-table {{
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}}
.info-table td {{
padding: 10px;
border-bottom: 1px solid #ddd;
}}
.info-table td:first-child {{
font-weight: bold;
width: 120px;
}}
.footer {{
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>打卡通知 {status_text}</h2>
</div>
<div class="content">
<p>您好,{user.alias}</p>
<p>您的接龙自动打卡任务已执行。</p>
<table class="info-table">
<tr>
<td>执行时间</td>
<td>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</td>
</tr>
<tr>
<td>任务 ID</td>
<td>{task_info.get('thread_id', '未知')}</td>
</tr>
<tr>
<td>打卡状态</td>
<td><strong style="color: {status_color};">{status_text}</strong></td>
</tr>
{f'<tr><td>详细信息</td><td>{message}</td></tr>' if message else ''}
</table>
<p>如有问题,请及时检查您的打卡配置。</p>
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
<p>接龙自动打卡系统 © {datetime.now().year}</p>
</div>
</div>
</body>
</html>
"""
return EmailService.send_email([user.email], subject, body_html)
+217
View File
@@ -0,0 +1,217 @@
"""
用户名预占和注册限流管理器
"""
import time
import threading
import logging
from typing import Optional, Dict
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
class RegistrationManager:
"""用户注册管理器 - 处理用户名预占和注册限流"""
def __init__(self):
# 用户名预占记录: {alias: {session_id: str, expire_time: float}}
self._reserved_aliases: Dict[str, Dict] = {}
# Cookie 注册限流记录: {cookie_value: expire_time}
self._registration_cookies: Dict[str, float] = {}
# 线程锁
self._lock = threading.RLock()
# 启动清理线程
self._start_cleanup_thread()
def reserve_alias(self, alias: str, session_id: str, timeout_seconds: int = 120) -> bool:
"""
预占用户名
Args:
alias: 用户名
session_id: 会话 ID
timeout_seconds: 超时时间(秒),默认 120 秒(2 分钟)
Returns:
是否预占成功
"""
with self._lock:
current_time = time.time()
expire_time = current_time + timeout_seconds
# 检查用户名是否已被预占
if alias in self._reserved_aliases:
reservation = self._reserved_aliases[alias]
# 检查是否过期
if reservation['expire_time'] > current_time:
# 未过期,检查是否是同一个 session
if reservation['session_id'] == session_id:
# 同一个 session,更新过期时间
reservation['expire_time'] = expire_time
logger.info(f"用户名 {alias} 预占时间已更新(session: {session_id}")
return True
else:
# 不同 session,预占失败
logger.warning(f"用户名 {alias} 已被占用(session: {reservation['session_id']}")
return False
# 预占用户名
self._reserved_aliases[alias] = {
'session_id': session_id,
'expire_time': expire_time
}
logger.info(f"用户名 {alias} 已预占(session: {session_id}, 超时: {timeout_seconds}s")
return True
def release_alias(self, alias: str, session_id: Optional[str] = None) -> bool:
"""
释放用户名预占
Args:
alias: 用户名
session_id: 会话 ID(可选,如果提供则只释放匹配的 session)
Returns:
是否释放成功
"""
with self._lock:
if alias not in self._reserved_aliases:
return False
reservation = self._reserved_aliases[alias]
# 如果指定了 session_id,则只释放匹配的
if session_id and reservation['session_id'] != session_id:
logger.warning(f"尝试释放用户名 {alias},但 session 不匹配")
return False
del self._reserved_aliases[alias]
logger.info(f"用户名 {alias} 预占已释放")
return True
def is_alias_reserved(self, alias: str) -> bool:
"""
检查用户名是否被预占
Args:
alias: 用户名
Returns:
是否被预占
"""
with self._lock:
if alias not in self._reserved_aliases:
return False
reservation = self._reserved_aliases[alias]
current_time = time.time()
# 检查是否过期
if reservation['expire_time'] <= current_time:
# 已过期,自动释放
del self._reserved_aliases[alias]
return False
return True
def check_registration_cookie(self, cookie_value: str) -> bool:
"""
检查 Cookie 是否在限流期内
Args:
cookie_value: Cookie 值
Returns:
True 表示可以注册,False 表示在限流期内
"""
with self._lock:
current_time = time.time()
# 检查 Cookie 是否存在
if cookie_value in self._registration_cookies:
expire_time = self._registration_cookies[cookie_value]
# 检查是否过期
if expire_time > current_time:
remaining = int(expire_time - current_time)
logger.warning(f"Cookie {cookie_value[:8]}... 在限流期内(剩余 {remaining} 秒)")
return False
else:
# 已过期,移除记录
del self._registration_cookies[cookie_value]
return True
def record_registration(self, cookie_value: str, cooldown_seconds: int = 600) -> None:
"""
记录注册操作(10 分钟冷却)
Args:
cookie_value: Cookie 值
cooldown_seconds: 冷却时间(秒),默认 600 秒(10 分钟)
"""
with self._lock:
current_time = time.time()
expire_time = current_time + cooldown_seconds
self._registration_cookies[cookie_value] = expire_time
logger.info(f"Cookie {cookie_value[:8]}... 已记录注册(冷却 {cooldown_seconds} 秒)")
def _cleanup_expired_records(self) -> None:
"""清理过期的预占记录和限流记录"""
with self._lock:
current_time = time.time()
# 清理过期的用户名预占
expired_aliases = [
alias for alias, reservation in self._reserved_aliases.items()
if reservation['expire_time'] <= current_time
]
for alias in expired_aliases:
del self._reserved_aliases[alias]
logger.debug(f"用户名 {alias} 预占已过期,自动释放")
# 清理过期的注册限流记录
expired_cookies = [
cookie for cookie, expire_time in self._registration_cookies.items()
if expire_time <= current_time
]
for cookie in expired_cookies:
del self._registration_cookies[cookie]
logger.debug(f"Cookie {cookie[:8]}... 限流记录已过期,自动清理")
if expired_aliases or expired_cookies:
logger.info(f"清理完成:{len(expired_aliases)} 个用户名,{len(expired_cookies)} 个 Cookie")
def _start_cleanup_thread(self) -> None:
"""启动定期清理线程"""
def cleanup_loop():
while True:
try:
time.sleep(60) # 每 60 秒清理一次
self._cleanup_expired_records()
except Exception as e:
logger.error(f"清理线程异常: {e}")
thread = threading.Thread(target=cleanup_loop, daemon=True)
thread.start()
logger.info("注册管理器清理线程已启动")
def get_stats(self) -> Dict:
"""获取当前状态统计"""
with self._lock:
return {
'reserved_aliases_count': len(self._reserved_aliases),
'rate_limited_cookies_count': len(self._registration_cookies),
'reserved_aliases': list(self._reserved_aliases.keys()),
}
# 全局单例
registration_manager = RegistrationManager()
+382
View File
@@ -0,0 +1,382 @@
import logging
import os
import time
from datetime import datetime
from pathlib import Path
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from filelock import FileLock
from sqlalchemy.orm import Session
from croniter import croniter
from backend.config import settings
from backend.models import get_db, User, CheckInTask
from backend.services.check_in_service import CheckInService
from backend.services.admin_service import AdminService
from backend.workers.email_notifier import send_expiration_notification
logger = logging.getLogger(__name__)
# 全局调度器实例
scheduler = None
scheduler_lock = None
def load_scheduled_tasks(db: Session, scheduler_instance):
"""
从数据库加载所有启用的定时任务并添加到 APScheduler
只加载满足以下条件的任务:
- is_active = True
- cron_expression IS NOT NULL
Args:
db: 数据库会话
scheduler_instance: APScheduler BackgroundScheduler 实例
Returns:
包含统计信息的字典
"""
logger.info("正在从数据库加载定时任务...")
# 移除所有现有的动态任务(保留系统任务)
for job in scheduler_instance.get_jobs():
if job.id.startswith('task_'):
scheduler_instance.remove_job(job.id)
# 查询所有启用且有 cron 表达式的任务
tasks = db.query(CheckInTask).filter(
CheckInTask.is_active == True,
CheckInTask.cron_expression.isnot(None)
).all()
loaded_count = 0
skipped_count = 0
error_count = 0
for task in tasks:
try:
# 验证 cron 表达式
cron_str = str(task.cron_expression) if task.cron_expression else None
if not cron_str or not croniter.is_valid(cron_str):
logger.warning(f"跳过任务 {task.id}: 无效的 cron 表达式 '{task.cron_expression}'")
skipped_count += 1
continue
# 创建任务 ID
job_id = f"task_{task.id}"
# 检查任务是否已存在
if scheduler_instance.get_job(job_id):
logger.debug(f"任务 {task.id} 已存在,跳过")
continue
# 添加任务到调度器
scheduler_instance.add_job(
func=scheduled_check_in_task,
trigger=CronTrigger.from_crontab(cron_str),
id=job_id,
name=f"CheckIn-Task-{task.id}",
args=[task.id],
replace_existing=True
)
logger.info(f"✅ 加载任务 {task.id}: {task.name} (Cron: {task.cron_expression})")
loaded_count += 1
except Exception as e:
logger.error(f"❌ 加载任务 {task.id} 时出错: {str(e)}")
error_count += 1
result = {
"loaded": loaded_count,
"skipped": skipped_count,
"errors": error_count,
"total": len(tasks)
}
logger.info(f"任务加载完成: {result}")
return result
def scheduled_check_in_task(task_id: int):
"""
执行指定任务的定时打卡
这是由 APScheduler 在 cron 触发器触发时调用的函数
使用与批量打卡相同的逻辑
"""
from backend.models.database import SessionLocal
db = SessionLocal()
try:
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
if not task:
logger.error(f"任务 {task_id} 不存在")
return
if not task.is_scheduled_enabled:
logger.info(f"任务 {task_id} 未启用定时打卡 (is_active={task.is_active}, cron={task.cron_expression})")
return
logger.info(f"🤖 执行定时打卡任务 {task_id}")
# 开始异步打卡
CheckInService.start_async_check_in(task, "scheduled", db)
except Exception as e:
logger.error(f"执行定时打卡任务 {task_id} 时出错: {str(e)}", exc_info=True)
finally:
db.close()
def cleanup_expired_pending_users():
"""定时清理过期未审批用户(24小时未审批)"""
logger.info("Scheduler: 正在清理过期未审批用户...")
try:
# 创建数据库会话
db = next(get_db())
try:
count = AdminService.delete_expired_pending_users(db)
logger.info(f"Scheduler: 已删除 {count} 个过期未审批用户")
finally:
db.close()
except Exception as e:
logger.error(f"Scheduler: 清理过期用户任务发生错误: {e}", exc_info=True)
def check_token_expiration():
"""
检查 Token 是否即将过期,并发送邮件提醒
检查所有用户的 Token,如果在 30 分钟内过期,发送提醒邮件
注意:现在需要检查用户的任务,因为邮箱地址在任务中
"""
logger.info("Scheduler: 正在执行 Token 过期检查...")
try:
# 创建数据库会话
db = next(get_db())
try:
# 获取所有用户
users = db.query(User).all()
current_timestamp = int(datetime.now().timestamp())
notified_count = 0
for user in users:
if not user.jwt_exp or user.jwt_exp == "0":
continue
try:
exp_timestamp = int(user.jwt_exp)
# 检查是否在 30 分钟内过期(0 < 剩余时间 < 1800秒)
time_until_expiry = exp_timestamp - current_timestamp
if 0 < time_until_expiry < 1800: # 30分钟 = 1800秒
# 使用用户账户的邮箱发送通知
if user.email:
logger.info(f"用户 {user.alias} 的 Token 即将过期,发送邮件提醒到 {user.email}...")
send_expiration_notification(user.email, user.jwt_exp)
notified_count += 1
except ValueError:
logger.warning(f"用户 {user.alias} 的 jwt_exp 格式不正确: {user.jwt_exp}")
continue
logger.info(f"Scheduler: Token 过期检查完成,共发送 {notified_count} 封提醒邮件")
finally:
db.close()
except Exception as e:
logger.error(f"Scheduler: Token 过期检查任务发生错误: {e}", exc_info=True)
def scheduled_check_in():
"""
定时打卡任务:每天定时为所有启用的任务执行打卡
"""
logger.info("Scheduler: 开始执行定时打卡任务...")
try:
# 创建数据库会话
db = next(get_db())
try:
result = CheckInService.scheduled_check_in_all_active_tasks(db)
logger.info(
f"Scheduler: 定时打卡任务完成,"
f"总计: {result['total']}, "
f"成功: {result['success']}, "
f"失败: {result['failure']}, "
f"跳过: {result['skipped']}"
)
finally:
db.close()
except Exception as e:
logger.error(f"Scheduler: 定时打卡任务发生错误: {e}", exc_info=True)
def cleanup_old_sessions():
"""
清理旧的会话文件
删除超过指定时间的会话文件
"""
logger.info("Scheduler: 开始清理旧会话文件...")
try:
session_dir = settings.SESSION_DIR
if not session_dir.exists():
logger.info("Scheduler: 会话目录不存在,跳过清理")
return
current_time = time.time()
cleanup_threshold = settings.SESSION_CLEANUP_HOURS * 3600 # 转换为秒
deleted_count = 0
for file_path in session_dir.glob("*.json"):
try:
# 获取文件修改时间
file_mtime = file_path.stat().st_mtime
file_age = current_time - file_mtime
# 如果文件超过阈值,删除它
if file_age > cleanup_threshold:
# 同时删除对应的锁文件
lock_file = session_dir / f"{file_path.stem}.json.lock"
file_path.unlink()
if lock_file.exists():
lock_file.unlink()
deleted_count += 1
logger.debug(f"删除旧会话文件: {file_path.name}")
except Exception as e:
logger.error(f"删除会话文件 {file_path.name} 时出错: {e}")
logger.info(f"Scheduler: 会话文件清理完成,共删除 {deleted_count} 个文件")
except Exception as e:
logger.error(f"Scheduler: 清理会话文件任务发生错误: {e}", exc_info=True)
def start_scheduler():
"""
启动调度器
使用文件锁确保在多进程部署时只有一个调度器运行
"""
global scheduler, scheduler_lock
# 创建调度器锁文件
lock_file = settings.BASE_DIR / "scheduler.lock"
scheduler_lock = FileLock(lock_file, timeout=1)
try:
# 尝试获取锁
scheduler_lock.acquire(blocking=False)
logger.info("成功获取调度器锁,启动调度器...")
# 创建后台调度器
scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
# 添加定时打卡任务(每天指定时间)
scheduler.add_job(
scheduled_check_in,
trigger=CronTrigger(
hour=settings.CHECKIN_SCHEDULE_HOUR,
minute=settings.CHECKIN_SCHEDULE_MINUTE
),
id="scheduled_check_in",
name="定时打卡任务",
replace_existing=True
)
logger.info(
f"已添加定时打卡任务: 每天 {settings.CHECKIN_SCHEDULE_HOUR:02d}:{settings.CHECKIN_SCHEDULE_MINUTE:02d}"
)
# 添加 Token 过期检查任务(每隔指定分钟)
scheduler.add_job(
check_token_expiration,
trigger="interval",
minutes=settings.TOKEN_CHECK_INTERVAL_MINUTES,
id="check_token_expiration",
name="Token 过期检查任务",
replace_existing=True
)
logger.info(
f"已添加 Token 过期检查任务: 每 {settings.TOKEN_CHECK_INTERVAL_MINUTES} 分钟"
)
# 添加会话文件清理任务(每隔指定小时)
scheduler.add_job(
cleanup_old_sessions,
trigger="interval",
hours=settings.SESSION_CLEANUP_INTERVAL_HOURS,
id="cleanup_old_sessions",
name="清理旧会话文件任务",
replace_existing=True
)
logger.info(
f"已添加会话清理任务: 每 {settings.SESSION_CLEANUP_INTERVAL_HOURS} 小时"
)
# 添加清理过期未审批用户任务(每小时执行一次)
scheduler.add_job(
cleanup_expired_pending_users,
trigger="interval",
hours=1,
id="cleanup_expired_pending_users",
name="清理过期未审批用户任务",
replace_existing=True
)
logger.info("已添加清理过期未审批用户任务: 每 1 小时")
# 新增:从数据库加载动态任务
db = next(get_db())
try:
load_scheduled_tasks(db, scheduler)
finally:
db.close()
# 启动调度器
scheduler.start()
logger.info("调度器已启动")
except Exception as e:
logger.warning(f"无法获取调度器锁或启动失败: {e}")
logger.info("可能其他进程已经在运行调度器,跳过启动")
scheduler_lock = None
def stop_scheduler():
"""
停止调度器并释放锁
"""
global scheduler, scheduler_lock
if scheduler:
logger.info("正在停止调度器...")
scheduler.shutdown()
logger.info("调度器已停止")
if scheduler_lock:
try:
scheduler_lock.release()
logger.info("已释放调度器锁")
except Exception as e:
logger.warning(f"释放调度器锁时出错: {e}")
+386
View File
@@ -0,0 +1,386 @@
import logging
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import desc
import json
from backend.models import User, CheckInTask, CheckInRecord
from backend.schemas.task import TaskCreate, TaskUpdate
logger = logging.getLogger(__name__)
class TaskService:
"""打卡任务服务"""
@staticmethod
def create_task(user_id: int, task_data: TaskCreate, db: Session) -> CheckInTask:
"""
创建打卡任务
Args:
user_id: 用户 ID
task_data: 任务数据
db: 数据库会话
Returns:
创建的任务对象
"""
import json
# 1. 检查用户是否存在
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError(f"用户 ID {user_id} 不存在")
# 2. 从 payload_config 中提取 ThreadId 用于唯一性校验
try:
payload = json.loads(task_data.payload_config)
thread_id = payload.get('ThreadId')
if not thread_id:
raise ValueError("payload_config 中缺少 ThreadId")
except json.JSONDecodeError:
raise ValueError("payload_config 格式错误,必须是有效的 JSON")
# 3. 验证唯一性:同一用户在同一个接龙中不能有重复的任务
# 查询用户的所有任务,检查是否已经有同一个 ThreadId
existing_tasks = db.query(CheckInTask).filter(
CheckInTask.user_id == user_id
).all()
for task in existing_tasks:
try:
existing_payload = json.loads(task.payload_config)
if existing_payload.get('ThreadId') == thread_id:
logger.warning(f"⚠️ 任务创建冲突 - User: {user.alias}({user_id}), ThreadId: {thread_id}")
raise ValueError(
f"该接龙中已存在任务。ThreadId: {thread_id}"
)
except (json.JSONDecodeError, AttributeError, TypeError):
# 跳过无法解析的 payload_config
logger.debug(f"跳过无法解析的任务配置 - Task ID: {task.id}")
continue
# 4. 记录日志
task_name = task_data.name or f"接龙任务 {thread_id}"
logger.info(f"📝 用户 {user.alias}({user_id}) 正在创建任务: {task_name}")
# 5. 创建任务
task = CheckInTask(
user_id=user_id,
payload_config=task_data.payload_config,
name=task_data.name or task_name,
is_active=task_data.is_active if task_data.is_active is not None else True
)
try:
db.add(task)
db.commit()
db.refresh(task)
logger.info(f"✅ 任务创建成功 - ID: {task.id}, Name: {task.name}, ThreadId: {thread_id}")
# 如果任务启用且包含 cron_expression,立即添加到调度器
if task.is_scheduled_enabled:
TaskService._reload_scheduler_for_task(task, db)
return task
except Exception as e:
db.rollback()
logger.error(f"❌ 任务创建失败: {str(e)}")
raise ValueError(f"任务创建失败: {str(e)}")
@staticmethod
def get_task(task_id: int, db: Session) -> Optional[CheckInTask]:
"""
获取任务详情
Args:
task_id: 任务 ID
db: 数据库会话
Returns:
任务对象或 None
"""
return db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
@staticmethod
def enrich_task_with_check_in_info(task: CheckInTask, db: Session) -> dict:
"""
为任务添加最后一次打卡信息和 ThreadId
Args:
task: 任务对象
db: 数据库会话
Returns:
包含额外信息的任务字典
"""
# 获取最后一次打卡记录
last_record = db.query(CheckInRecord).filter(
CheckInRecord.task_id == task.id
).order_by(desc(CheckInRecord.check_in_time)).first()
# 从 payload_config 提取 ThreadId
thread_id = None
try:
payload = json.loads(str(task.payload_config))
thread_id = payload.get('ThreadId')
except (json.JSONDecodeError, AttributeError, TypeError):
logger.debug(f"无法从任务 {task.id} 的 payload_config 中提取 ThreadId")
pass
# 转换为字典并添加额外字段
task_dict = {
'id': task.id,
'user_id': task.user_id,
'payload_config': task.payload_config,
'name': task.name,
'is_active': task.is_active,
'cron_expression': task.cron_expression,
'is_scheduled_enabled': task.is_scheduled_enabled,
'created_at': task.created_at,
'updated_at': task.updated_at,
'thread_id': thread_id,
'last_check_in_time': last_record.check_in_time if last_record else None,
'last_check_in_status': last_record.status if last_record else None,
}
return task_dict
@staticmethod
def get_user_tasks(user_id: int, db: Session, include_inactive: bool = True) -> List[CheckInTask]:
"""
获取用户的所有任务
Args:
user_id: 用户 ID
db: 数据库会话
include_inactive: 是否包含未启用的任务
Returns:
任务列表
"""
query = db.query(CheckInTask).filter(CheckInTask.user_id == user_id)
if not include_inactive:
query = query.filter(CheckInTask.is_active == True)
return query.order_by(desc(CheckInTask.created_at)).all()
@staticmethod
def get_all_active_tasks(db: Session) -> List[CheckInTask]:
"""
获取所有启用的任务(用于定时打卡)
Args:
db: 数据库会话
Returns:
启用的任务列表
"""
return db.query(CheckInTask).filter(CheckInTask.is_active == True).all()
@staticmethod
def update_task(task_id: int, task_data: TaskUpdate, db: Session) -> Optional[CheckInTask]:
"""
更新任务
Args:
task_id: 任务 ID
task_data: 更新数据
db: 数据库会话
Returns:
更新后的任务对象或 None
"""
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
if not task:
return None
# 更新字段
update_data = task_data.model_dump(exclude_unset=True)
# 检查是否更新了 cron_expression 或 is_active
cron_changed = 'cron_expression' in update_data
active_changed = 'is_active' in update_data
for field, value in update_data.items():
setattr(task, field, value)
db.commit()
db.refresh(task)
logger.info(f"任务 {task_id} 已更新")
# 如果 cron_expression 或 is_active 发生变化,重新加载调度器
if cron_changed or active_changed:
TaskService._reload_scheduler_for_task(task, db)
return task
@staticmethod
def delete_task(task_id: int, db: Session) -> bool:
"""
删除任务
Args:
task_id: 任务 ID
db: 数据库会话
Returns:
是否删除成功
"""
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
if not task:
return False
db.delete(task)
db.commit()
logger.info(f"任务 {task_id} 已删除")
# 从调度器中移除该任务
TaskService._remove_task_from_scheduler(task_id)
return True
@staticmethod
def toggle_task(task_id: int, db: Session) -> Optional[CheckInTask]:
"""
切换任务的启用状态
Args:
task_id: 任务 ID
db: 数据库会话
Returns:
更新后的任务对象或 None
"""
task = db.query(CheckInTask).filter(CheckInTask.id == task_id).first()
if not task:
return None
task.is_active = not task.is_active
db.commit()
db.refresh(task)
logger.info(f"任务 {task_id} 状态已切换为: {'启用' if task.is_active else '禁用'}")
# 重新加载调度器
TaskService._reload_scheduler_for_task(task, db)
return task
@staticmethod
def get_task_records(task_id: int, db: Session, limit: int = 50) -> List[CheckInRecord]:
"""
获取任务的打卡记录
Args:
task_id: 任务 ID
db: 数据库会话
limit: 返回记录数量限制
Returns:
打卡记录列表
"""
return (
db.query(CheckInRecord)
.filter(CheckInRecord.task_id == task_id)
.order_by(desc(CheckInRecord.check_in_time))
.limit(limit)
.all()
)
@staticmethod
def verify_task_ownership(task_id: int, user_id: int, db: Session) -> bool:
"""
验证任务是否属于指定用户
Args:
task_id: 任务 ID
user_id: 用户 ID
db: 数据库会话
Returns:
是否属于该用户
"""
task = db.query(CheckInTask).filter(
CheckInTask.id == task_id,
CheckInTask.user_id == user_id
).first()
return task is not None
@staticmethod
def _reload_scheduler_for_task(task: CheckInTask, db: Session):
"""
重新加载指定任务到调度器
Args:
task: 任务对象
db: 数据库会话
"""
try:
from backend.services.scheduler_service import scheduler
from apscheduler.triggers.cron import CronTrigger
from croniter import croniter
if not scheduler:
logger.warning(f"调度器未启动,无法加载任务 {task.id}")
return
job_id = f"task_{task.id}"
# 先移除旧的任务(如果存在)
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
logger.debug(f"从调度器移除旧任务: {job_id}")
# 如果任务启用且有有效的 cron 表达式,添加新任务
if task.is_scheduled_enabled:
cron_str = str(task.cron_expression)
if croniter.is_valid(cron_str):
from backend.services.scheduler_service import scheduled_check_in_task
scheduler.add_job(
func=scheduled_check_in_task,
trigger=CronTrigger.from_crontab(cron_str),
id=job_id,
name=f"CheckIn-Task-{task.id}",
args=[task.id],
replace_existing=True
)
logger.info(f"✅ 任务 {task.id} 已添加到调度器: {cron_str}")
else:
logger.warning(f"任务 {task.id} 的 cron 表达式无效: {cron_str}")
else:
logger.info(f"任务 {task.id} 未启用或无 cron 表达式,已从调度器移除")
except Exception as e:
logger.error(f"重新加载任务 {task.id} 到调度器失败: {str(e)}")
@staticmethod
def _remove_task_from_scheduler(task_id: int):
"""
从调度器中移除指定任务
Args:
task_id: 任务 ID
"""
try:
from backend.services.scheduler_service import scheduler
if not scheduler:
return
job_id = f"task_{task_id}"
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
logger.info(f"✅ 任务 {task_id} 已从调度器移除")
except Exception as e:
logger.error(f"从调度器移除任务 {task_id} 失败: {str(e)}")
+568
View File
@@ -0,0 +1,568 @@
import logging
import json
from typing import List, Dict, Any, Optional
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from backend.models import TaskTemplate, CheckInTask
from backend.schemas.template import TemplateCreate, TemplateUpdate
logger = logging.getLogger(__name__)
class TemplateService:
"""模板服务"""
@staticmethod
def _deep_merge(parent: Any, child: Any) -> Any:
"""
深度合并配置,子配置会覆盖父配置
Args:
parent: 父配置
child: 子配置
Returns:
合并后的配置
"""
# 如果子配置不是字典或数组,直接返回子配置(覆盖)
if not isinstance(child, (dict, list)):
return child
# 如果父配置不是同类型,直接返回子配置
if type(parent) != type(child):
return child
# 处理字典合并
if isinstance(child, dict):
result = dict(parent) # 先复制父配置
for key, value in child.items():
if key in parent:
# 递归合并
result[key] = TemplateService._deep_merge(parent[key], value)
else:
# 新字段,直接添加
result[key] = value
return result
# 处理数组合并
if isinstance(child, list):
# 数组按索引位置合并
result = []
max_len = max(len(parent), len(child))
for i in range(max_len):
if i < len(child):
if i < len(parent):
# 两边都有,递归合并
result.append(TemplateService._deep_merge(parent[i], child[i]))
else:
# 只有子配置有,直接添加
result.append(child[i])
else:
# 只有父配置有,保留父配置
result.append(parent[i])
return result
return child
@staticmethod
def merge_parent_config(template: TaskTemplate, db: Session) -> Dict[str, Any]:
"""
合并父模板的字段配置到当前模板
Args:
template: 当前模板对象
db: 数据库会话
Returns:
合并后的完整字段配置
"""
# 解析当前模板配置
current_config = json.loads(str(template.field_config))
# 如果没有父模板,直接返回当前配置
if template.parent_id is None:
return current_config
# 获取父模板
parent = db.query(TaskTemplate).filter(TaskTemplate.id == template.parent_id).first()
if not parent:
logger.warning(f"模板 {template.id} 的父模板 {template.parent_id} 不存在")
return current_config
# 递归获取父模板的完整配置(支持多层继承)
parent_config = TemplateService.merge_parent_config(parent, db)
# 深度合并配置:子模板的配置会覆盖父模板的同名字段
merged = TemplateService._deep_merge(parent_config, current_config)
return merged
@staticmethod
def create_template(template_data: TemplateCreate, db: Session) -> TaskTemplate:
"""
创建新模板
Args:
template_data: 模板创建数据
db: 数据库会话
Returns:
创建的模板对象
"""
try:
# 验证 field_config 是有效的 JSON
if isinstance(template_data.field_config, str):
json.loads(template_data.field_config)
template = TaskTemplate(
name=template_data.name,
description=template_data.description,
field_config=template_data.field_config,
parent_id=template_data.parent_id,
is_active=template_data.is_active,
)
db.add(template)
db.commit()
db.refresh(template)
logger.info(f"创建模板成功: {template.name} (ID: {template.id})")
return template
except json.JSONDecodeError as e:
logger.error(f"模板字段配置 JSON 格式错误: {str(e)}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"字段配置 JSON 格式错误: {str(e)}"
)
except Exception as e:
logger.error(f"创建模板失败: {str(e)}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"创建模板失败: {str(e)}"
)
@staticmethod
def get_template(template_id: int, db: Session) -> Optional[TaskTemplate]:
"""
获取单个模板
Args:
template_id: 模板 ID
db: 数据库会话
Returns:
模板对象或 None
"""
return db.query(TaskTemplate).filter(TaskTemplate.id == template_id).first()
@staticmethod
def get_all_templates(
db: Session,
skip: int = 0,
limit: int = 100,
is_active: Optional[bool] = None
) -> List[TaskTemplate]:
"""
获取所有模板列表
Args:
db: 数据库会话
skip: 跳过记录数
limit: 限制记录数
is_active: 过滤启用状态
Returns:
模板列表
"""
query = db.query(TaskTemplate)
if is_active is not None:
query = query.filter(TaskTemplate.is_active == is_active)
return query.order_by(TaskTemplate.created_at.desc()).offset(skip).limit(limit).all()
@staticmethod
def update_template(
template_id: int,
template_data: TemplateUpdate,
db: Session
) -> TaskTemplate:
"""
更新模板
Args:
template_id: 模板 ID
template_data: 更新数据
db: 数据库会话
Returns:
更新后的模板对象
"""
template = TemplateService.get_template(template_id, db)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在"
)
try:
# 更新字段
update_data = template_data.model_dump(exclude_unset=True)
# 验证 field_config 如果有更新
if 'field_config' in update_data and update_data['field_config']:
json.loads(update_data['field_config'])
for field, value in update_data.items():
setattr(template, field, value)
db.commit()
db.refresh(template)
logger.info(f"更新模板成功: {template.name} (ID: {template.id})")
return template
except json.JSONDecodeError as e:
logger.error(f"模板字段配置 JSON 格式错误: {str(e)}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"字段配置 JSON 格式错误: {str(e)}"
)
except Exception as e:
logger.error(f"更新模板失败: {str(e)}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"更新模板失败: {str(e)}"
)
@staticmethod
def delete_template(template_id: int, db: Session) -> bool:
"""
删除模板
Args:
template_id: 模板 ID
db: 数据库会话
Returns:
是否删除成功
"""
template = TemplateService.get_template(template_id, db)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在"
)
try:
db.delete(template)
db.commit()
logger.info(f"删除模板成功: {template.name} (ID: {template_id})")
return True
except Exception as e:
logger.error(f"删除模板失败: {str(e)}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"删除模板失败: {str(e)}"
)
@staticmethod
def _is_field_config(obj: Any) -> bool:
"""判断是否为字段配置对象"""
return isinstance(obj, dict) and 'display_name' in obj
@staticmethod
def _is_object_field(obj: Any) -> bool:
"""判断是否为对象字段(包含多个子字段配置)"""
if not isinstance(obj, dict):
return False
if 'display_name' in obj:
return False
# 检查所有值是否都是字段配置对象
return all(
TemplateService._is_field_config(v)
for v in obj.values()
if isinstance(v, dict)
) and len(obj) > 0
@staticmethod
def _process_field_value(key: str, config: Any, field_values: Dict[str, Any]) -> Any:
"""
递归处理字段配置,生成 payload 值
Args:
key: 字段名
config: 字段配置
field_values: 用户输入值
Returns:
处理后的值
"""
# 1. 普通字段配置
if TemplateService._is_field_config(config):
if config.get('hidden', False):
value = config.get('default_value', '')
else:
value = field_values.get(key, config.get('default_value', ''))
value_type = config.get('value_type', 'string')
return TemplateService._validate_and_convert_value(value, value_type, key)
# 2. 数组字段
if isinstance(config, list):
result = []
for item_config in config:
# 检查数组元素是否是字段配置对象
if TemplateService._is_field_config(item_config):
# 数组元素是字段配置对象,需要序列化为 JSON 字符串
value = item_config.get('default_value', '')
value_type = item_config.get('value_type', 'string')
# 将对象序列化为 JSON 字符串
if value_type == 'json':
if isinstance(value, str):
# 如果是字符串,验证 JSON 格式
try:
json.loads(value)
except json.JSONDecodeError as e:
# 提供更详细的错误信息
error_detail = f"数组元素的默认值不是有效的 JSON: {value}\n"
error_detail += f"JSON 解析错误: {str(e)}\n"
error_detail += "常见问题: 数字不能有前导零(如 00.00 应改为 0.0)"
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_detail
)
result.append(value)
else:
# 如果是对象,序列化为 JSON 字符串
result.append(json.dumps(value, ensure_ascii=False))
else:
result.append(TemplateService._validate_and_convert_value(value, value_type, key))
elif isinstance(item_config, dict):
# 数组元素是普通对象,递归处理
item = {}
for item_key, item_value in item_config.items():
# 保持键名原样
item[item_key] = TemplateService._process_field_value(
item_key, item_value, field_values
)
result.append(item)
else:
result.append(item_config)
return result
# 3. 对象字段(包含多个子字段)
if TemplateService._is_object_field(config):
result = {}
for sub_key, sub_config in config.items():
# 保持键名原样
result[sub_key] = TemplateService._process_field_value(
sub_key, sub_config, field_values
)
return result
# 4. 其他情况,返回原值
return config
@staticmethod
def generate_preview_payload(template: TaskTemplate, db: Session) -> Dict[str, Any]:
"""
生成模板预览 payload(使用默认值)
完全根据模板配置动态生成
新架构:配置完全映射到 Payload 结构
Args:
template: 模板对象
db: 数据库会话
Returns:
预览 payload
"""
try:
# 合并父模板配置
field_config = TemplateService.merge_parent_config(template, db)
# 初始化 payload,只包含 ThreadId(唯一必需,不在模板中配置)
payload = {
"ThreadId": "<接龙项目ID>"
}
# 递归处理所有字段,保持键名原样
for key, config in field_config.items():
payload[key] = TemplateService._process_field_value(key, config, {})
return payload
except json.JSONDecodeError as e:
logger.error(f"解析模板配置失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"解析模板配置失败: {str(e)}"
)
@staticmethod
def assemble_payload_from_template(
template: TaskTemplate,
thread_id: str,
field_values: Dict[str, Any],
db: Session
) -> Dict[str, Any]:
"""
根据模板和用户输入组装完整的 payload
完全根据模板配置动态生成
新架构:配置完全映射到 Payload 结构
Args:
template: 模板对象
thread_id: 接龙项目 ID
field_values: 用户填写的字段值
db: 数据库会话
Returns:
完整的 payload
"""
try:
# 合并父模板配置
field_config = TemplateService.merge_parent_config(template, db)
# 初始化 payload,只包含 ThreadId(唯一必需)
payload = {
"ThreadId": thread_id
}
# 递归处理所有字段,保持键名原样
for key, config in field_config.items():
payload[key] = TemplateService._process_field_value(key, config, field_values)
return payload
except json.JSONDecodeError as e:
logger.error(f"解析模板配置失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"解析模板配置失败"
)
except Exception as e:
logger.error(f"组装 payload 失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"组装 payload 失败: {str(e)}"
)
@staticmethod
def _validate_and_convert_value(value: Any, value_type: str, field_name: str) -> Any:
"""
验证并转换字段值类型
Args:
value: 字段值
value_type: 期望的类型 (string, int, double, bool, json)
field_name: 字段名(用于错误提示)
Returns:
转换后的值
"""
try:
if value_type == 'int':
return int(value) if value != '' else 0
elif value_type == 'double':
return float(value) if value != '' else 0.0
elif value_type == 'bool':
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() in ('true', '1', 'yes')
return bool(value)
elif value_type == 'json':
# JSON 类型:如果是字符串,尝试解析后再序列化;如果是对象,直接序列化
if isinstance(value, str):
# 验证是否为有效 JSON
json.loads(value)
return value
else:
# 将对象序列化为 JSON 字符串
return json.dumps(value, ensure_ascii=False)
else: # string
return str(value)
except (ValueError, TypeError, json.JSONDecodeError) as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"字段 '{field_name}' 类型错误:期望 {value_type},实际值为 '{value}',错误: {str(e)}"
)
@staticmethod
def create_task_from_template(
template_id: int,
thread_id: str,
field_values: Dict[str, Any],
user_id: int,
task_name: Optional[str],
db: Session
) -> CheckInTask:
"""
从模板创建打卡任务
Args:
template_id: 模板 ID
thread_id: 接龙项目 ID
field_values: 用户填写的字段值
user_id: 用户 ID
task_name: 任务名称(可选)
db: 数据库会话
Returns:
创建的任务对象
"""
# 获取模板
template = TemplateService.get_template(template_id, db)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在"
)
# 检查模板是否启用
if template.is_active is not True:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="该模板未启用,无法创建任务"
)
# 组装 payload
payload = TemplateService.assemble_payload_from_template(
template, thread_id, field_values, db
)
# 生成任务名称
if not task_name:
signature = payload.get('Signature', 'Unknown')
task_name = f"{template.name} - {signature}"
# 创建任务(只存储 payload_config,不再需要 thread_id 和 email
try:
task = CheckInTask(
user_id=user_id,
payload_config=json.dumps(payload, ensure_ascii=False),
name=task_name,
is_active=True
)
db.add(task)
db.commit()
db.refresh(task)
logger.info(f"从模板创建任务成功: {task.name} (ID: {task.id}, 模板: {template.name}, ThreadId: {thread_id})")
return task
except Exception as e:
logger.error(f"从模板创建任务失败: {str(e)}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"创建任务失败: {str(e)}"
)
+289
View File
@@ -0,0 +1,289 @@
import logging
from typing import List, Optional
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import or_
from backend.models import User
from backend.schemas.user import UserCreate, UserUpdate, UserUpdateProfile
logger = logging.getLogger(__name__)
class UserService:
"""用户服务"""
@staticmethod
def create_user(user_data: UserCreate, db: Session) -> User:
"""
创建用户(管理员手动创建)
Args:
user_data: 用户创建数据(只需要 alias 和 role)
db: 数据库会话
Returns:
创建的用户对象
"""
# 检查 alias 是否已存在
existing_alias = db.query(User).filter(User.alias == user_data.alias).first()
if existing_alias:
raise ValueError(f"用户别名 {user_data.alias} 已存在")
# 创建用户(管理员创建的用户没有 jwt_sub,需要后续扫码绑定)
user = User(
jwt_sub="", # 空字符串表示未绑定 QQ
alias=user_data.alias,
role=user_data.role or "user",
is_approved=True, # 管理员创建的用户默认已审批
jwt_exp="0",
authorization=None,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info(f"管理员创建用户成功: {user.alias} (ID: {user.id}, 角色: {user.role})")
return user
@staticmethod
def get_user_by_id(user_id: int, db: Session) -> Optional[User]:
"""
根据 ID 获取用户
Args:
user_id: 用户 ID
db: 数据库会话
Returns:
用户对象或 None
"""
return db.query(User).filter(User.id == user_id).first()
@staticmethod
def get_user_by_alias(alias: str, db: Session) -> Optional[User]:
"""
根据 alias 获取用户
Args:
alias: 用户别名
db: 数据库会话
Returns:
用户对象或 None
"""
return db.query(User).filter(User.alias == alias).first()
@staticmethod
def get_user_by_jwt_sub(jwt_sub: str, db: Session) -> Optional[User]:
"""
根据 jwt_sub 获取用户
Args:
jwt_sub: QQ 用户标识
db: 数据库会话
Returns:
用户对象或 None
"""
return db.query(User).filter(User.jwt_sub == jwt_sub).first()
@staticmethod
def get_all_users(
db: Session,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
role: Optional[str] = None
) -> List[User]:
"""
获取所有用户
Args:
db: 数据库会话
skip: 跳过记录数
limit: 限制记录数
search: 搜索关键词(alias 或 jwt_sub
role: 过滤角色(user/admin
Returns:
用户列表
"""
query = db.query(User)
# 搜索过滤
if search:
query = query.filter(
or_(
User.alias.ilike(f"%{search}%"),
User.jwt_sub.ilike(f"%{search}%")
)
)
# 角色过滤
if role:
query = query.filter(User.role == role)
return query.offset(skip).limit(limit).all()
@staticmethod
def update_user(user_id: int, user_data: UserUpdate, db: Session) -> User:
"""
更新用户信息(管理员操作)
Args:
user_id: 用户 ID
user_data: 用户更新数据
db: 数据库会话
Returns:
更新后的用户对象
"""
from backend.services.auth_service import AuthService
user = UserService.get_user_by_id(user_id, db)
if not user:
raise ValueError(f"用户 ID {user_id} 不存在")
# 更新字段
update_data = user_data.model_dump(exclude_unset=True)
# 如果更新 alias,检查是否重复
if "alias" in update_data and update_data["alias"] != user.alias:
existing_user = db.query(User).filter(User.alias == update_data["alias"]).first()
if existing_user:
raise ValueError(f"用户别名 {update_data['alias']} 已存在")
# 处理密码重置
if update_data.get("reset_password"):
user.password_hash = None
logger.info(f"管理员重置用户 {user.alias} (ID: {user_id}) 的密码")
# 处理密码修改
elif "password" in update_data and update_data["password"]:
user.password_hash = AuthService.hash_password(update_data["password"])
logger.info(f"管理员修改用户 {user.alias} (ID: {user_id}) 的密码")
# 更新其他字段(排除密码相关字段)
excluded_fields = {"password", "reset_password"}
for key, value in update_data.items():
if key not in excluded_fields:
setattr(user, key, value)
user.updated_at = datetime.now()
db.commit()
db.refresh(user)
logger.info(f"更新用户成功: {user.alias} (ID: {user.id})")
return user
@staticmethod
def update_user_profile(user_id: int, profile_data: UserUpdateProfile, db: Session) -> User:
"""
更新用户个人信息(别名、邮箱和密码)
Args:
user_id: 用户 ID
profile_data: 个人信息更新数据
db: 数据库会话
Returns:
更新后的用户对象
"""
from backend.services.auth_service import AuthService
user = UserService.get_user_by_id(user_id, db)
if not user:
raise ValueError(f"用户 ID {user_id} 不存在")
update_data = profile_data.model_dump(exclude_unset=True)
# 更新别名
if "alias" in update_data and update_data["alias"] != user.alias:
existing_user = db.query(User).filter(User.alias == update_data["alias"]).first()
if existing_user:
raise ValueError(f"用户别名 {update_data['alias']} 已存在")
user.alias = update_data["alias"]
logger.info(f"用户 ID {user_id} 别名更新: {user.alias}")
# 更新邮箱
if "email" in update_data:
user.email = update_data["email"]
logger.info(f"用户 ID {user_id} 邮箱更新: {user.email}")
# 更新密码
if "new_password" in update_data and update_data["new_password"]:
# 如果用户已设置密码,需要验证当前密码
if user.password_hash:
if "current_password" not in update_data or not update_data["current_password"]:
raise ValueError("修改密码时必须提供当前密码")
# 验证当前密码
if not AuthService.verify_password(update_data["current_password"], user.password_hash):
raise ValueError("当前密码错误")
# 设置新密码
user.password_hash = AuthService.hash_password(update_data["new_password"])
logger.info(f"用户 ID {user_id} 密码已更新")
user.updated_at = datetime.now()
db.commit()
db.refresh(user)
logger.info(f"✅ 更新用户个人信息成功: {user.alias} (ID: {user.id})")
return user
@staticmethod
def delete_user(user_id: int, db: Session) -> bool:
"""
删除用户
Args:
user_id: 用户 ID
db: 数据库会话
Returns:
是否删除成功
"""
user = UserService.get_user_by_id(user_id, db)
if not user:
raise ValueError(f"用户 ID {user_id} 不存在")
alias = user.alias
db.delete(user)
db.commit()
logger.info(f"删除用户成功: {alias} (ID: {user_id})")
return True
@staticmethod
def get_users_by_role(role: str, db: Session) -> List[User]:
"""
获取指定角色的用户
Args:
role: 角色(user/admin
db: 数据库会话
Returns:
用户列表
"""
return db.query(User).filter(User.role == role).all()
@staticmethod
def count_users(db: Session, role: Optional[str] = None) -> int:
"""
统计用户数量
Args:
db: 数据库会话
role: 角色过滤(可选)
Returns:
用户数量
"""
query = db.query(User)
if role:
query = query.filter(User.role == role)
return query.count()
+275
View File
@@ -0,0 +1,275 @@
import requests
import json
import time
import os
import logging
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from typing import Dict, Any
from backend.config import settings
from backend.workers.email_notifier import send_success_notification, send_failure_notification
logger = logging.getLogger(__name__)
# Chrome 配置路径 - 从设置中读取
CHROME_BINARY_PATH = settings.CHROME_BINARY_PATH
CHROMEDRIVER_PATH = settings.CHROMEDRIVER_PATH
def get_live_x_api_payload(auth_token: str) -> str:
"""
启动一个临时的无头浏览器会话,获取新鲜的 x-api-request-payload
Args:
auth_token: 用户的 Authorization Token
Returns:
x-api-request-payload 值,失败返回 None
"""
logger.info("正在启动临时浏览器会话以监听网络日志...")
# 根据配置创建 Service
if CHROMEDRIVER_PATH:
service = Service(executable_path=CHROMEDRIVER_PATH)
else:
service = Service() # 使用 Selenium Manager 自动管理
chrome_options = Options()
# 如果配置了 Chrome 路径,则使用配置的路径
if CHROME_BINARY_PATH:
chrome_options.binary_location = CHROME_BINARY_PATH
# 开启性能日志记录功能
logging_prefs = {'performance': 'ALL'}
chrome_options.set_capability('goog:loggingPrefs', logging_prefs)
# Headless 模式配置
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
chrome_options.add_argument(f'user-agent={user_agent}')
chrome_options.add_argument("--headless")
chrome_options.add_argument("--no-sandbox")
chrome_options.add_argument("--disable-dev-shm-usage")
chrome_options.add_argument("--window-size=1920,1080")
chrome_options.add_argument('--ignore-certificate-errors')
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
driver = webdriver.Chrome(service=service, options=chrome_options)
payload_signature = None
try:
# 导航到同源空白页,用于设置 Cookie
driver.get("https://i.jielong.com/my-class")
# 注入长期 Token
driver.add_cookie({
'name': 'token',
'value': auth_token,
'domain': '.jielong.com'
})
# 导航到触发 API 的页面
driver.get("https://i.jielong.com/my-form")
# 等待并捕获 x-api-request-payload
max_wait_time = 20 # 最多等待20秒
start_time = time.time()
found = False
while time.time() - start_time < max_wait_time:
logs = driver.get_log('performance')
for entry in logs:
log = json.loads(entry['message'])['message']
if log['method'] == 'Network.requestWillBeSent':
headers = log.get('params', {}).get('request', {}).get('headers', {})
headers_lower = {k.lower(): v for k, v in headers.items()}
if 'x-api-request-payload' in headers_lower:
payload_signature = headers_lower['x-api-request-payload']
logger.info("成功通过网络日志捕获到现场的 x-api-request-payload")
found = True
break
if found:
break
time.sleep(1)
if not payload_signature:
raise Exception(f"{max_wait_time} 秒内未能通过网络日志捕获到 x-api-request-payload。")
except Exception as e:
logger.error(f"获取现场 x-api-request-payload 时失败: {e}")
debug_screenshot = os.path.join(settings.BASE_DIR, 'payload_debug.png')
driver.save_screenshot(debug_screenshot)
finally:
driver.quit()
return payload_signature
def perform_check_in(task, user_token: str) -> Dict[str, Any]:
"""
执行打卡任务
Args:
task: CheckInTask 对象,包含打卡任务配置
user_token: 用户的 Authorization Token(从 task.user.authorization 获取)
Returns:
打卡结果字典:
- success: 是否成功
- status: 状态 (success/failure)
- response_text: 响应文本
- error_message: 错误信息
"""
# 从 payload_config 中提取 Signature 用于日志
try:
payload_dict = json.loads(task.payload_config) if task.payload_config else {}
signature = payload_dict.get('Signature', 'Unknown')
except:
signature = 'Unknown'
logger.info(f"Selenium打卡: 正在为任务 ID: {task.id} (Signature: {signature}) 执行打卡...")
if not user_token:
error_msg = f"任务 ID: {task.id} (Signature: {signature}) 的 Token 为空,跳过。"
logger.error(error_msg)
return {
"success": False,
"status": "failure",
"response_text": "",
"error_message": error_msg
}
# 获取 x-api-request-payload
payload_signature = get_live_x_api_payload(user_token)
if not payload_signature:
error_msg = f"任务 ID: {task.id} (Signature: {signature}) 未能获取到现场签名,打卡中止。"
logger.error(error_msg)
return {
"success": False,
"status": "failure",
"response_text": "",
"error_message": error_msg
}
try:
# 使用任务的 payload_config(从模板生成的完整配置,包含 ThreadId)
payload = json.loads(task.payload_config) if task.payload_config else {}
if not payload.get('ThreadId'):
error_msg = f"任务 ID: {task.id} 的 payload_config 缺少 ThreadId"
logger.error(error_msg)
return {
"success": False,
"status": "failure",
"response_text": "",
"error_message": error_msg
}
headers = {
'User-Agent': "Mozilla%2f5.0+(Linux%3b+Android+16%3b+wv)+AppleWebKit%2f537.36+(KHTML%2c+like+Gecko)+Chrome%2f142.0.0.0+Safari%2f537.36+QQ%2f9.2.30.31620+QQ%2fMiniApp",
'Accept-Encoding': "gzip",
'Content-Type': "application/json",
'authorization': f"Bearer {user_token}",
'x-api-request-referer': "https://appservice.qq.com/1110276759",
'x-api-request-payload': payload_signature,
'referer': "https://appservice.qq.com/1110276759/8.10.1.7/page-frame.html",
'platform': "qq",
'x-api-request-mode': "cors",
}
url = "https://api.jielong.com/api/CheckIn/EditRecord"
# 打印请求详情用于调试
payload_json = json.dumps(payload, ensure_ascii=False)
logger.info(f"📤 打卡请求详情 - 任务 ID: {task.id} (Signature: {signature})")
logger.info(f"📍 URL: {url}")
logger.info(f"📦 Payload: {payload_json}")
logger.info(f"🔑 x-api-request-payload: {payload_signature[:50]}...")
response = requests.post(url, data=payload_json, headers=headers)
response.raise_for_status()
response_text = response.text
logger.info(f"✉️ 任务 ID: {task.id} (Signature: {signature}) 打卡请求完成!响应: {response_text}")
# 判断响应内容(参考 V1 实现逻辑)
# 使用用户账户的邮箱,而不是任务的邮箱
email = task.user.email if task.user else None
# 情况1: 明确包含"打卡成功" → 成功
if "打卡成功" in response_text:
logger.info(f"✅ 检测到成功关键字 '打卡成功',打卡成功")
if email:
send_success_notification(email)
return {
"success": True,
"status": "success",
"response_text": response_text,
"error_message": ""
}
# 情况2: 不在打卡时间范围 → 标记为时间范围外
# 支持多种匹配方式:直接文本匹配、JSON Data 字段、Description 字段
elif ("不在打卡时间范围" in response_text or
"不在打卡时间" in response_text or
'"Data":"不在打卡时间范围"' in response_text or
'"Description":"不在打卡时间范围"' in response_text):
logger.warning(f"⏰ 检测到'不在打卡时间范围',打卡时间不符")
return {
"success": False,
"status": "out_of_time",
"response_text": response_text,
"error_message": "不在打卡时间范围内"
}
# 情况3: Token 失效的特征标识 → 失败
elif ("登录" in response_text):
logger.warning(f"⚠️ 检测到登录失败关键字,Token 可能已失效")
if email:
send_failure_notification(email)
return {
"success": False,
"status": "failure",
"response_text": response_text,
"error_message": "Token 已失效,需要重新授权"
}
# 情况4: 其他响应 → 需要人工确认(标记为异常)
else:
logger.warning(f"⚠️ 未识别的响应内容,请检查: {response_text[:200]}...")
# 标记为未知状态,记录完整响应供后续分析
return {
"success": False,
"status": "unknown",
"response_text": response_text,
"error_message": "未识别的响应,请人工确认"
}
except requests.exceptions.RequestException as e:
error_msg = f"为任务 ID: {task.id} (Signature: {signature}) 打卡时请求失败: {e}"
logger.error(error_msg)
response_text = ""
if e.response is not None:
response_text = e.response.text
logger.error(f"响应状态码: {e.response.status_code}, 响应内容: {response_text}")
return {
"success": False,
"status": "failure",
"response_text": response_text,
"error_message": str(e)
}
except Exception as e:
error_msg = f"为任务 ID: {task.id} (Signature: {signature}) 打卡时发生未知错误: {e}"
logger.error(error_msg)
return {
"success": False,
"status": "failure",
"response_text": "",
"error_message": str(e)
}
@@ -2,13 +2,13 @@ import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import time
import csv
import os
import logging
import configparser
from pathlib import Path
from shared_config import CONFIG_PATH, CONFIG_FILE_LOCK, get_logger, CONFIG_INI_PATH
from backend.config import settings
logger = get_logger(__name__)
logger = logging.getLogger(__name__)
# --- 邮件模板 ---
@@ -30,7 +30,7 @@ EXPIRATION_HTML_TEMPLATE = """
<h1>注意!</h1>
<div class="message">
<p>{name}请注意!</p>
<p>您的 <span class="important">token</span> 已经到期前往 <span class="important"><a href="http://localhost:5000">http://localhost:5000</a></span> 重新刷新您的 token否则您的自动打卡功能将会失效</p>
<p>您的 <span class="important">token</span> 已经到期尽快重新刷新您的 token否则您的自动打卡功能将会失效</p>
<p><strong>到期时间:</strong> {exp_time}</p>
</div>
<p class="footer">邮件发送时间: {send_time}</p>
@@ -72,7 +72,7 @@ FAILURE_HTML_TEMPLATE = """
<title>打卡失败通知</title>
<style>
body {{ font-family: Arial, sans-serif; background-color: #f4f4f4; color: #333; margin: 20px; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); }}
h1 {{ color: #d9534f; }} /* 红色标题 */
h1 {{ color: #d9534f; }}
.message {{ background-color: #fff; padding: 15px; border: 1px solid #ddd; border-radius: 5px; margin-bottom: 20px; }}
.important {{ font-weight: bold; color: #d9534f; }}
.footer {{ font-size: 0.9em; color: #666; }}
@@ -84,14 +84,53 @@ FAILURE_HTML_TEMPLATE = """
<p>{name}您好!</p>
<p>系统于 <span class="important">{send_time}</span> 尝试为您自动打卡时失败</p>
<p><strong>失败原因:</strong> 服务器返回 "需要登录"这通常意味着您的 <span class="important">Token 已失效</span></p>
<p><strong>请您立即前往 <span class="important"><a href="http://localhost:5000">http://localhost:5000</a></span> 刷新您的 Token以确保后续打卡能够成功</strong></p>
<p><strong>请您立即刷新您的 Token以确保后续打卡能够成功</strong></p>
</div>
<p class="footer">感谢您的使用</p>
</body>
</html>
"""
def _send_email(to_email, subject, html_content, email_settings):
def get_email_settings():
"""
config.ini 读取邮件配置
Returns:
dict: 邮件配置如果配置文件不存在则返回 None
"""
if not settings.EMAIL_CONFIG_FILE.exists():
logger.warning("找不到 config.ini,无法发送邮件")
return None
try:
config_parser = configparser.ConfigParser()
config_parser.read(settings.EMAIL_CONFIG_FILE, encoding='utf-8')
if 'Email' not in config_parser:
logger.warning("config.ini 中缺少 [Email] 配置段")
return None
return config_parser['Email']
except Exception as e:
logger.error(f"读取邮件配置失败: {e}")
return None
def _send_email(to_email: str, subject: str, html_content: str, email_settings: dict) -> bool:
"""
发送邮件
Args:
to_email: 收件人邮箱
subject: 邮件主题
html_content: HTML 邮件内容
email_settings: 邮件配置
Returns:
是否发送成功
"""
try:
msg = MIMEMultipart()
msg["From"] = email_settings['senderemail']
@@ -104,75 +143,98 @@ def _send_email(to_email, subject, html_content, email_settings):
server.sendmail(msg["From"], msg["To"], msg.as_string())
logger.info(f"已成功向 {to_email} 发送邮件,主题: {subject}")
return True
except Exception as e:
logger.error(f"{to_email} 发送邮件时失败: {e}")
return False
def send_expiration_notification(email: str, jwt_exp: str) -> bool:
"""
发送 Token 到期提醒邮件
Args:
email: 收件人邮箱
jwt_exp: Token 过期时间戳
Returns:
是否发送成功
"""
email_settings = get_email_settings()
if not email_settings:
return False
try:
exp_time = time.strftime("%Y年%m月%d%H:%M:%S", time.localtime(float(jwt_exp)))
send_time = time.strftime("%Y年%m月%d%H:%M:%S", time.localtime())
def send_notification_email(user_config, email_settings):
"""发送Token到期提醒邮件"""
html = EXPIRATION_HTML_TEMPLATE.format(
name=user_config["email"],
exp_time=time.strftime("%Y年%m月%d%H:%M:%S", time.localtime(float(user_config["jwt_exp"]))),
send_time=time.strftime("%Y年%m月%d%H:%M:%S", time.localtime())
name=email,
exp_time=exp_time,
send_time=send_time
)
_send_email(user_config["email"], "接龙管家Token到期通知", html, email_settings)
def send_success_notification(user_config, email_settings):
"""发送打卡成功通知邮件"""
html = SUCCESS_HTML_TEMPLATE.format(
name=user_config["email"],
send_time=time.strftime("%Y年%m月%d%H:%M:%S", time.localtime())
)
_send_email(user_config["email"], "自动打卡成功通知", html, email_settings)
return _send_email(email, "接龙管家Token到期通知", html, email_settings)
def send_failure_notification(user_config, email_settings):
"""发送打卡失败通知邮件"""
html = FAILURE_HTML_TEMPLATE.format(
name=user_config["email"],
send_time=time.strftime("%Y年%m月%d%H:%M:%S", time.localtime())
)
_send_email(user_config["email"], "打卡失败 - 需要刷新Token", html, email_settings)
def notification_worker_loop():
"""后台任务:检查Token是否即将过期,并发送邮件提醒。单次运行。"""
logger.info("Scheduler: 正在执行邮件过期通知检查...")
config_ini_path = os.path.join(os.path.dirname(__file__), 'config.ini')
try:
# 1. 读取邮件配置
if not os.path.exists(config_ini_path):
logger.warning("Scheduler: 找不到 config.ini,邮件通知功能将跳过。")
return # 直接退出
config_parser = configparser.ConfigParser()
config_parser.read(config_ini_path)
if 'Email' not in config_parser:
logger.warning("Scheduler: config.ini 中缺少 [Email] 部分,跳过。")
return
email_settings = config_parser['Email']
# 2. 线程安全地读取用户配置
with CONFIG_FILE_LOCK:
if not os.path.exists(CONFIG_PATH):
configs = []
else:
with open(CONFIG_PATH, mode='r', encoding='utf-8-sig') as file:
configs = list(csv.DictReader(file))
# 3. 检查每个用户的Token是否即将过期
now = time.time()
for user in configs:
if not user.get('jwt_exp') or not user.get('email'):
continue
try:
exp_time = float(user['jwt_exp'])
# 检查是否在 30 分钟内过期,并且尚未发送过提醒(可选,防止重复发送)
if 0 < (now - exp_time) < 1800:
logger.info(f"{user['Signature']} 的Token过期,准备发送邮件...")
send_notification_email(user, email_settings)
except (ValueError, TypeError):
logger.warning(f"Scheduler: 跳过用户 {user['Signature']},因为jwt_exp格式不正确: {user['jwt_exp']}")
continue
logger.info("Scheduler: 邮件到期检查完成。")
except Exception as e:
logger.error(f"Scheduler: 邮件通知任务发生严重错误: {e}")
logger.error(f"发送过期通知邮件失败: {e}")
return False
def send_success_notification(email: str) -> bool:
"""
发送打卡成功通知邮件
Args:
email: 收件人邮箱
Returns:
是否发送成功
"""
email_settings = get_email_settings()
if not email_settings:
return False
try:
send_time = time.strftime("%Y年%m月%d%H:%M:%S", time.localtime())
html = SUCCESS_HTML_TEMPLATE.format(
name=email,
send_time=send_time
)
return _send_email(email, "自动打卡成功通知", html, email_settings)
except Exception as e:
logger.error(f"发送成功通知邮件失败: {e}")
return False
def send_failure_notification(email: str) -> bool:
"""
发送打卡失败通知邮件
Args:
email: 收件人邮箱
Returns:
是否发送成功
"""
email_settings = get_email_settings()
if not email_settings:
return False
try:
send_time = time.strftime("%Y年%m月%d%H:%M:%S", time.localtime())
html = FAILURE_HTML_TEMPLATE.format(
name=email,
send_time=send_time
)
return _send_email(email, "打卡失败 - 需要刷新Token", html, email_settings)
except Exception as e:
logger.error(f"发送失败通知邮件失败: {e}")
return False
+262
View File
@@ -0,0 +1,262 @@
import os
import logging
import json
from pathlib import Path
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException
from filelock import FileLock
from backend.config import settings
logger = logging.getLogger(__name__)
# Chrome 配置路径
BASE_DIR = settings.BASE_DIR
# 调试文件路径
DEBUG_SCREENSHOT_PATH = os.path.join(BASE_DIR, "debug_screenshot.png")
DEBUG_PAGE_SOURCE_PATH = os.path.join(BASE_DIR, "debug_page_source.html")
def get_chrome_config():
"""获取 Chrome 配置(从 settings 读取)"""
return {
"chrome_binary": settings.CHROME_BINARY_PATH,
"chromedriver": settings.CHROMEDRIVER_PATH
}
def update_session_file(session_id: str, data: dict) -> None:
"""线程安全地写入会话文件"""
filepath = settings.SESSION_DIR / f"{session_id}.json"
lock_path = settings.SESSION_DIR / f"{session_id}.json.lock"
try:
with FileLock(lock_path, timeout=5):
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"写入会话文件 {filepath} 失败: {e}")
def get_session_status(session_id: str) -> str:
"""安全地读取会话文件的状态"""
filepath = settings.SESSION_DIR / f"{session_id}.json"
lock_path = settings.SESSION_DIR / f"{session_id}.json.lock"
if not filepath.exists():
return None
try:
with FileLock(lock_path, timeout=5):
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
if not content:
return None
data = json.loads(content)
return data.get('status')
except (IOError, json.JSONDecodeError) as e:
logger.error(f"读取会话文件 {filepath} 失败: {e}")
return None
def get_session_data(session_id: str) -> dict:
"""读取完整的会话数据"""
filepath = settings.SESSION_DIR / f"{session_id}.json"
lock_path = settings.SESSION_DIR / f"{session_id}.json.lock"
if not filepath.exists():
return None
try:
with FileLock(lock_path, timeout=5):
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
if not content:
return None
return json.loads(content)
except (IOError, json.JSONDecodeError) as e:
logger.error(f"读取会话文件 {filepath} 失败: {e}")
return None
def get_token_headless(session_id: str, jwt_sub: str = None, alias: str = None, client_ip: str = "") -> None:
"""
使用 Selenium 获取 QQ 扫码登录的 Token
Args:
session_id: 会话 ID
jwt_sub: QQ 用户标识(老用户刷新 Token 时提供,新用户为 None)
alias: 用户别名(用于新用户注册)
client_ip: 客户端 IP 地址
"""
driver = None
current_step = "初始化"
try:
# 获取 Chrome 配置
chrome_config = get_chrome_config()
chrome_binary_path = chrome_config["chrome_binary"]
chromedriver_path = chrome_config["chromedriver"]
# 配置 Chrome 选项
current_step = "配置 ChromeDriver"
logger.info(f"Selenium ({session_id}): {current_step}...")
chrome_options = Options()
# 如果指定了自定义 Chrome 路径,则使用
if chrome_binary_path:
chrome_options.binary_location = chrome_binary_path
logger.info(f"Selenium ({session_id}): 使用自定义 Chrome 路径: {chrome_binary_path}")
else:
logger.info(f"Selenium ({session_id}): 使用系统默认 Chrome")
# Headless 模式配置
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
chrome_options.add_argument(f'user-agent={user_agent}')
chrome_options.add_argument("--headless")
chrome_options.add_argument("--no-sandbox")
chrome_options.add_argument("--disable-dev-shm-usage")
chrome_options.add_argument("--window-size=1920,1080")
chrome_options.add_argument('--ignore-certificate-errors')
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
# 启动浏览器
current_step = "启动 Chrome 浏览器"
logger.info(f"Selenium ({session_id}): {current_step}...")
# 如果指定了 ChromeDriver 路径,则使用 Service;否则让 Selenium 自动管理
if chromedriver_path:
service = Service(executable_path=chromedriver_path)
driver = webdriver.Chrome(service=service, options=chrome_options)
logger.info(f"Selenium ({session_id}): 使用自定义 ChromeDriver: {chromedriver_path}")
else:
driver = webdriver.Chrome(options=chrome_options)
logger.info(f"Selenium ({session_id}): 使用 Selenium Manager 自动管理 ChromeDriver")
logger.info(f"Selenium ({session_id}): Chrome 浏览器启动成功")
current_step = "导航到登录页面"
logger.info(f"Selenium ({session_id}): {current_step}...")
driver.get("https://i.jielong.com/login?redirectTo=https%3A%2F%2Fi.jielong.com%2F")
wait = WebDriverWait(driver, 60)
# --- 步骤 1: 点击切换到 QQ 登录 ---
current_step = "查找并点击切换按钮"
toggle_button_selector = "div.login-wrap .toggle"
logger.info(f"Selenium ({session_id}): {current_step} ({toggle_button_selector})...")
toggle_button = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, toggle_button_selector)))
toggle_button.click()
# --- 步骤 2: 勾选同意服务协议 ---
current_step = "勾选同意服务协议"
checkbox_selector = "input.ant-checkbox-input[type='checkbox']"
logger.info(f"Selenium ({session_id}): {current_step} ({checkbox_selector})...")
checkbox = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, checkbox_selector)))
if not checkbox.is_selected():
checkbox.click()
logger.info(f"Selenium ({session_id}): 已勾选服务协议")
# --- 步骤 3: 点击"立即登录"按钮 ---
current_step = "点击立即登录按钮"
login_button_selector = "button.css-1wli0ry.ant-btn.ant-btn-default.login-btn"
logger.info(f"Selenium ({session_id}): {current_step} ({login_button_selector})...")
login_button = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, login_button_selector)))
login_button.click()
# --- 步骤 4: 等待二维码加载 ---
import time
time.sleep(3) # 等待几秒让二维码刷新出来
current_step = "等待QQ二维码图片加载"
qq_qr_image_selector = "#login_container img"
logger.info(f"Selenium ({session_id}): {current_step} ({qq_qr_image_selector})...")
qr_element = wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, qq_qr_image_selector)))
logger.info(f"Selenium ({session_id}): 成功找到QQ二维码元素,正在截图...")
qr_base64 = qr_element.screenshot_as_base64
update_session_file(session_id, {
'status': 'waiting_scan',
'qr_image_data': qr_base64,
'jwt_sub': jwt_sub,
'alias': alias, # 新增:保存 alias
'client_ip': client_ip # 新增:保存 IP
})
current_step = "等待用户扫描登录 (Cookie 'token' 出现)"
cookie_name_to_find = "token"
logger.info(f"Selenium ({session_id}): {current_step}...")
WebDriverWait(driver, 120, 1).until(lambda d: d.get_cookie(cookie_name_to_find) is not None) # 改为 120 秒(2分钟)
cookie = driver.get_cookie(cookie_name_to_find)
if cookie:
logger.info(f"Selenium ({session_id}): 成功在Cookie中捕获到Token")
update_session_file(session_id, {
'status': 'success',
'token': cookie['value'],
'alias': alias, # 保存 alias
'client_ip': client_ip # 保存 IP
})
else:
raise Exception("等待Cookie成功但获取失败")
except TimeoutException:
if get_session_status(session_id) == 'success':
logger.warning(f"Selenium ({session_id}): 一个并发线程超时,但会话已成功,将忽略此超时。")
else:
# 释放预占的用户名
if alias:
from backend.services.registration_manager import registration_manager
registration_manager.release_alias(alias, session_id)
logger.info(f"超时释放用户名预占: {alias}")
error_message = f"操作超时!卡在了步骤: '{current_step}'。请检查CSS选择器或网络。"
logger.error(f"Selenium ({session_id}): {error_message}")
# 保存调试信息(仅当 driver 已创建时)
if driver:
try:
driver.save_screenshot(DEBUG_SCREENSHOT_PATH)
with open(DEBUG_PAGE_SOURCE_PATH, 'w', encoding='utf-8') as f:
f.write(driver.page_source)
logger.error(f"Selenium ({session_id}): 调试截图和源码已保存。当前URL: {driver.current_url}")
except Exception as debug_error:
logger.error(f"Selenium ({session_id}): 保存调试信息失败: {debug_error}")
update_session_file(session_id, {
'status': 'error',
'message': error_message,
'jwt_sub': jwt_sub
})
except Exception as e:
if get_session_status(session_id) == 'success':
logger.warning(f"Selenium ({session_id}): 一个并发线程出错 ({e}),但会话已成功,将忽略此错误。")
else:
# 释放预占的用户名
if alias:
from backend.services.registration_manager import registration_manager
registration_manager.release_alias(alias, session_id)
logger.info(f"异常释放用户名预占: {alias}")
logger.error(f"Selenium ({session_id}): 发生未知错误: {e}", exc_info=True)
update_session_file(session_id, {
'status': 'error',
'message': str(e),
'jwt_sub': jwt_sub
})
finally:
if driver:
try:
driver.quit()
logger.info(f"Selenium ({session_id}): 浏览器已关闭")
except Exception as quit_error:
logger.error(f"Selenium ({session_id}): 关闭浏览器失败: {quit_error}")
-193
View File
@@ -1,193 +0,0 @@
import requests
import configparser
import csv
import json
import time
import os
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from shared_config import (
CONFIG_INI_PATH, CONFIG_PATH, CONFIG_FILE_LOCK, get_logger,
CHROME_BINARY_PATH, CHROMEDRIVER_PATH
)
from email_notifier import send_success_notification, send_failure_notification
logger = get_logger(__name__)
def read_configs():
"""线程安全地读取配置文件"""
with CONFIG_FILE_LOCK:
if not os.path.exists(CONFIG_PATH):
return []
with open(CONFIG_PATH, mode='r', encoding='utf-8-sig') as file:
return list(csv.DictReader(file))
def get_live_x_api_payload(auth_token):
"""
启动一个临时的无头浏览器会话,只为了获取新鲜的 x-api-request-payload。
"""
logger.info("正在启动临时浏览器会话以监听网络日志...")
service = Service(executable_path=CHROMEDRIVER_PATH) if CHROMEDRIVER_PATH else Service()
chrome_options = Options()
chrome_options.binary_location = CHROME_BINARY_PATH
# --- 1. (最关键) 开启性能日志记录功能 ---
# 这会让浏览器记录下所有的网络事件
logging_prefs = {'performance': 'ALL'}
chrome_options.set_capability('goog:loggingPrefs', logging_prefs)
# --- Headless模式配置 ---
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
chrome_options.add_argument(f'user-agent={user_agent}')
chrome_options.add_argument("--headless")
chrome_options.add_argument("--no-sandbox")
chrome_options.add_argument("--disable-dev-shm-usage")
chrome_options.add_argument("--window-size=1920,1080")
chrome_options.add_argument('--ignore-certificate-errors')
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
driver = webdriver.Chrome(service=service, options=chrome_options)
payload_signature = None
try:
# 1. 导航到一个同源空白页,用于设置Cookie
driver.get("https://i.jielong.com/my-class")
# 3. 注入我们的长期Token
driver.add_cookie({
'name': 'token',
'value': auth_token,
'domain': '.jielong.com'
})
# 4. 导航到触发API的页面,这将产生网络日志
driver.get("https://i.jielong.com/my-form")
# 5. 等待几秒,确保页面有足够的时间加载并发起API请求
max_wait_time = 20 # 最多等待20秒
start_time = time.time()
found = False
while time.time() - start_time < max_wait_time:
logs = driver.get_log('performance')
for entry in logs:
log = json.loads(entry['message'])['message']
if log['method'] == 'Network.requestWillBeSent':
headers = log.get('params', {}).get('request', {}).get('headers', {})
headers_lower = {k.lower(): v for k, v in headers.items()}
if 'x-api-request-payload' in headers_lower:
payload_signature = headers_lower['x-api-request-payload']
logger.info("成功通过网络日志捕获到现场的 x-api-request-payload")
found = True
break
if found:
break
time.sleep(1) # 每次轮询间隔1秒
if not payload_signature:
raise Exception(f"{max_wait_time} 秒内未能通过网络日志捕获到 x-api-request-payload。")
except Exception as e:
logger.error(f"获取现场 x-api-request-payload 时失败: {e}")
driver.save_screenshot(os.path.join(os.path.dirname(__file__), 'payload_debug.png'))
finally:
driver.quit()
return payload_signature
def perform_check_in(config):
logger.info(f"Selenium打卡: 正在为 Signature: {config['Signature']} 执行打卡...")
auth_token = config.get('Authorization')
if not auth_token:
logger.error(f"Signature: {config['Signature']} 的长期Token为空,跳过。")
return
payload_signature = get_live_x_api_payload(auth_token)
if not payload_signature:
logger.error(f"Signature: {config['Signature']} 未能获取到现场签名,打卡中止。")
return
email_settings = None
if config.get('email'):
if os.path.exists(CONFIG_INI_PATH):
config_parser = configparser.ConfigParser()
config_parser.read(CONFIG_INI_PATH)
# 使用 .get() 安全访问
if 'Email' in config_parser:
email_settings = config_parser['Email']
else:
logger.warning("在 config.ini 中找不到 [Email] 配置段,无法发送邮件。")
else:
logger.warning("找不到 config.ini,无法发送邮件通知。")
try:
payload = {
"Id": 0,
"ThreadId": config['ThreadId'],
"Number": "",
"Signature": config['Signature'],
"RecordValues": [{
"FieldId": 1,
"Values": [config['Values']],
"Texts": [config['Texts']],
"HasValue": True,
"Scores": [],
"Files": [],
"MatrixValues": [],
"CustomTableValues": [],
"FillInMatrixFieldValues": [],
"MatrixFormValues": []
}],
"DateTarget": "",
"IsNeedManualAudit": False,
"MinuteTarget": -1,
"IsNameNumberComfirm": False
}
headers = {
'User-Agent': "Mozilla%2f5.0+(Linux%3b+Android+16%3b+wv)+AppleWebKit%2f537.36+(KHTML%2c+like+Gecko)+Chrome%2f142.0.0.0+Safari%2f537.36+QQ%2f9.2.30.31620+QQ%2fMiniApp",
'Accept-Encoding': "gzip",
'Content-Type': "application/json",
'authorization': f"Bearer {auth_token}",
'x-api-request-referer': "https://appservice.qq.com/1110276759",
'x-api-request-payload': payload_signature,
'referer': "https://appservice.qq.com/1110276759/8.10.1.7/page-frame.html",
'platform': "qq",
'x-api-request-mode': "cors",
}
url = "https://api.jielong.com/api/CheckIn/EditRecord"
response = requests.post(url, data=json.dumps(payload), headers=headers)
response.raise_for_status()
response_text = response.text
logger.info(f"Signature: {config['Signature']} 打卡请求完成!响应: {response_text}")
# logger.info(f"payload = {payload}")
# logger.info(f"headers = {headers}")
# logger.info(f"url = {url}")
# 判断响应内容
if email_settings:
if "打卡成功" in response_text:
logger.info(f"检测到成功关键字,为 {config['Signature']} 发送成功邮件...")
send_success_notification(config, email_settings)
elif ("QSfqFrHF0jbMZcd3DVuvf6k5HceMjOlDwzX1b/SJ4agLnRkO" in response_text or
"请先授权登录小程序" in response_text): # 打卡失败附带的Data 或 授权失效
logger.warning(f"检测到登录失败关键字,为 {config['Signature']} 发送失败提醒邮件...")
send_failure_notification(config, email_settings)
# 检查HTTP状态码,如果需要的话
response.raise_for_status()
return response.text
except requests.exceptions.RequestException as e:
logger.error(f"为 Signature: {config['Signature']} 打卡时请求失败: {e}")
if e.response is not None:
logger.error(f" 响应状态码: {e.response.status_code}, 响应内容: {e.response.text}")
return e.response.text # 同样返回响应文本
return None # 请求彻底失败
except Exception as e:
logger.error(f"为 Signature: {config['Signature']} 打卡时发生未知错误: {e}")
return None
-1
View File
@@ -1 +0,0 @@
ThreadId,Signature,Texts,Values,jwt_sub,Authorization,jwt_exp,email
1 ThreadId Signature Texts Values jwt_sub Authorization jwt_exp email
-6
View File
@@ -1,6 +0,0 @@
[Email]
SmtpServer = __YOUR_EMAIL_PROVIDER__
SmtpPort = 465
SenderEmail = __YOUR_EMAIL__
# 重要提示:这里通常不是你的邮箱登录密码,而是邮箱服务商提供的“应用专用密码”或“授权码”
SenderPassword = __YOUR_EMAIL_AUTH_CODE__
+474
View File
@@ -0,0 +1,474 @@
# CheckIn App V2 - Deployment Guide
This guide explains how to deploy CheckIn App V2 to a production server using Nginx and systemd.
## Table of Contents
1. [Prerequisites](#prerequisites)
2. [Server Setup](#server-setup)
3. [Application Setup](#application-setup)
4. [Nginx Configuration](#nginx-configuration)
5. [Systemd Service Setup](#systemd-service-setup)
6. [SSL/TLS Certificate](#ssltls-certificate)
7. [Monitoring and Logs](#monitoring-and-logs)
8. [Troubleshooting](#troubleshooting)
---
## Prerequisites
- **Operating System**: Ubuntu 20.04+ or similar Linux distribution
- **Python**: 3.9 or higher
- **Node.js**: 16+ (for building frontend)
- **Nginx**: 1.18 or higher
- **Domain name** (optional but recommended for SSL)
### Install Required Packages
```bash
# Update system
sudo apt update && sudo apt upgrade -y
# Install Python and tools
sudo apt install -y python3 python3-pip python3-venv
# Install Node.js (using NodeSource)
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt install -y nodejs
# Install Nginx
sudo apt install -y nginx
# Install other dependencies
sudo apt install -y git curl wget
```
---
## Server Setup
### 1. Create Application User
```bash
# Create a dedicated user for the application
sudo useradd -r -m -s /bin/bash checkin
sudo usermod -aG www-data checkin
```
### 2. Create Application Directory
```bash
# Create directory structure
sudo mkdir -p /opt/checkin-app
sudo chown -R checkin:www-data /opt/checkin-app
# Create required subdirectories
sudo -u checkin mkdir -p /opt/checkin-app/{data,logs,sessions}
```
---
## Application Setup
### 1. Clone Repository
```bash
# Switch to application user
sudo su - checkin
# Clone the repository
cd /opt/checkin-app
git clone https://github.com/your-repo/checkin-app.git .
# Or upload your files using scp/rsync
```
### 2. Setup Backend
```bash
# Create virtual environment
python3 -m venv venv
# Activate virtual environment
source venv/bin/activate
# Install Python dependencies
pip install -r backend/requirements.txt
# Create .env file
cp .env.example .env
# Edit .env and configure your settings
nano .env
```
**Important Environment Variables:**
```env
# Database
DATABASE_URL=sqlite:///./data/checkin.db
# Security
SECRET_KEY=your-secret-key-here-change-this
ALLOWED_ORIGINS=https://your-domain.com
# QQ Login (if applicable)
QQ_APPID=your-qq-appid
QQ_APPSECRET=your-qq-appsecret
# Email notifications (optional)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password
ADMIN_EMAIL=admin@your-domain.com
```
### 3. Initialize Database
```bash
# Run database migrations if needed
# Example:
# alembic upgrade head
# Or run initialization script
python backend/scripts/create_admin.py
```
### 4. Build Frontend
```bash
# Install frontend dependencies
cd frontend
npm install
# Build for production
npm run build
# Verify build output
ls -lh dist/
```
### 5. Set Permissions
```bash
# Exit from checkin user
exit
# Set proper permissions
sudo chown -R checkin:www-data /opt/checkin-app
sudo chmod -R 755 /opt/checkin-app
sudo chmod -R 775 /opt/checkin-app/{data,logs,sessions}
```
---
## Nginx Configuration
### 1. Copy Configuration
```bash
# Copy example configuration
sudo cp /opt/checkin-app/deployment/nginx.conf.example /etc/nginx/sites-available/checkin-app
# Edit configuration
sudo nano /etc/nginx/sites-available/checkin-app
```
### 2. Update Configuration
Replace the following placeholders:
- `your-domain.com` → Your actual domain name
- `/opt/checkin-app` → Your installation path (if different)
### 3. Enable Site
```bash
# Create symbolic link
sudo ln -s /etc/nginx/sites-available/checkin-app /etc/nginx/sites-enabled/
# Remove default site (optional)
sudo rm /etc/nginx/sites-enabled/default
# Test Nginx configuration
sudo nginx -t
# Reload Nginx
sudo systemctl reload nginx
```
---
## Systemd Service Setup
### 1. Copy Service File
```bash
# Copy example service file
sudo cp /opt/checkin-app/deployment/checkin-app.service.example /etc/systemd/system/checkin-app.service
# Edit service file
sudo nano /etc/systemd/system/checkin-app.service
```
### 2. Update Service File
Replace placeholders:
- `User=www-data``User=checkin` (if using dedicated user)
- `WorkingDirectory=/opt/checkin-app` → Your installation path
- Adjust paths in `ExecStart` if needed
### 3. Enable and Start Service
```bash
# Reload systemd
sudo systemctl daemon-reload
# Enable service (start on boot)
sudo systemctl enable checkin-app.service
# Start service
sudo systemctl start checkin-app.service
# Check status
sudo systemctl status checkin-app.service
# View logs
sudo journalctl -u checkin-app -f
```
---
## SSL/TLS Certificate
### Using Let's Encrypt (Recommended)
```bash
# Install Certbot
sudo apt install -y certbot python3-certbot-nginx
# Obtain certificate
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
# Follow the prompts to configure SSL
# Test auto-renewal
sudo certbot renew --dry-run
```
The Certbot will automatically update your Nginx configuration with SSL settings.
### Manual Certificate Setup
If you have your own SSL certificate:
```bash
# Copy certificate files
sudo mkdir -p /etc/nginx/ssl
sudo cp your-cert.crt /etc/nginx/ssl/
sudo cp your-key.key /etc/nginx/ssl/
# Set permissions
sudo chmod 600 /etc/nginx/ssl/your-key.key
# Update Nginx configuration with certificate paths
```
---
## Monitoring and Logs
### Service Logs
```bash
# View service logs
sudo journalctl -u checkin-app -f
# View last 100 lines
sudo journalctl -u checkin-app -n 100
# View logs since yesterday
sudo journalctl -u checkin-app --since yesterday
```
### Application Logs
```bash
# Backend logs
tail -f /opt/checkin-app/logs/backend.log
# Nginx access logs
sudo tail -f /var/log/nginx/checkin-app-access.log
# Nginx error logs
sudo tail -f /var/log/nginx/checkin-app-error.log
```
### Service Status
```bash
# Check service status
sudo systemctl status checkin-app
# Check if port is listening
sudo netstat -tlnp | grep :8000
# Check process
ps aux | grep python
```
---
## Troubleshooting
### Service Won't Start
```bash
# Check service logs
sudo journalctl -u checkin-app -xe
# Check if port is already in use
sudo lsof -i :8000
# Verify permissions
ls -la /opt/checkin-app/
# Test manual start
sudo -u checkin /opt/checkin-app/venv/bin/python /opt/checkin-app/run_daemon.py
```
### Nginx Errors
```bash
# Test Nginx configuration
sudo nginx -t
# Check error logs
sudo tail -f /var/log/nginx/error.log
# Verify backend is running
curl http://localhost:8000/health
```
### Database Issues
```bash
# Check database file permissions
ls -la /opt/checkin-app/data/
# Check if database is locked
fuser /opt/checkin-app/data/checkin.db
# Backup database
cp /opt/checkin-app/data/checkin.db /opt/checkin-app/data/checkin.db.backup
```
### Frontend Not Loading
```bash
# Verify build exists
ls -la /opt/checkin-app/frontend/dist/
# Check Nginx configuration for root path
grep -n "root" /etc/nginx/sites-available/checkin-app
# Clear browser cache or test with curl
curl -I https://your-domain.com/
```
---
## Updating the Application
### Update Backend
```bash
# Switch to application user
sudo su - checkin
cd /opt/checkin-app
# Pull latest changes
git pull
# Activate virtual environment
source venv/bin/activate
# Update dependencies
pip install -r backend/requirements.txt
# Run migrations if needed
# alembic upgrade head
# Exit and restart service
exit
sudo systemctl restart checkin-app
```
### Update Frontend
```bash
sudo su - checkin
cd /opt/checkin-app/frontend
# Pull latest changes
git pull
# Install dependencies
npm install
# Build
npm run build
# Exit
exit
# No need to restart - Nginx serves static files
```
---
## Security Recommendations
1. **Firewall**: Use `ufw` to restrict access
```bash
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable
```
2. **Regular Updates**: Keep system and packages updated
```bash
sudo apt update && sudo apt upgrade
```
3. **Backup**: Regular backups of database and configuration
```bash
# Create backup script
sudo nano /opt/checkin-app/backup.sh
```
4. **Monitoring**: Consider using monitoring tools like Prometheus, Grafana, or Uptime Kuma
5. **Rate Limiting**: Configure Nginx rate limiting for API endpoints
---
## Additional Resources
- [Nginx Documentation](https://nginx.org/en/docs/)
- [Systemd Documentation](https://www.freedesktop.org/software/systemd/man/)
- [Let's Encrypt](https://letsencrypt.org/)
- [FastAPI Deployment](https://fastapi.tiangolo.com/deployment/)
---
## Support
For issues or questions, please:
- Check the logs first
- Review this guide carefully
- Open an issue on GitHub
- Contact system administrator
+307
View File
@@ -0,0 +1,307 @@
# Deployment Files
This directory contains configuration files and scripts for deploying CheckIn App V2 to a production server.
## Files
- **`nginx.conf.example`** - Nginx reverse proxy configuration
- **`checkin-app.service.example`** - Systemd service file
- **`deploy.sh`** - Automated deployment script
- **`DEPLOYMENT.md`** - Comprehensive deployment guide
## Quick Start
### Option 1: Automated Deployment (Recommended)
```bash
# Make script executable
chmod +x deployment/deploy.sh
# Run installation
sudo deployment/deploy.sh install
```
### Option 2: Manual Deployment
Follow the step-by-step guide in [DEPLOYMENT.md](./DEPLOYMENT.md).
## Deployment Script Usage
The `deploy.sh` script provides three main commands:
### 1. Install (First-time deployment)
```bash
sudo deployment/deploy.sh install
```
This will:
- Check system dependencies
- Create application user
- Setup virtual environment
- Install Python dependencies
- Build frontend
- Configure systemd service
- Configure Nginx
- Start all services
### 2. Update (Update existing installation)
```bash
sudo deployment/deploy.sh update
```
This will:
- Backup database
- Pull latest changes (if using git)
- Update Python dependencies
- Rebuild frontend
- Restart services
### 3. Rollback (Revert to previous version)
```bash
sudo deployment/deploy.sh rollback
```
This will:
- Stop services
- Restore database from latest backup
- Restart services
## Configuration Files
### Nginx Configuration
Edit `/etc/nginx/sites-available/checkin-app` and update:
- `server_name` - Your domain name
- `ssl_certificate` and `ssl_certificate_key` - SSL certificate paths
- `root` - Frontend build directory path (usually `/opt/checkin-app/frontend/dist`)
### Systemd Service
Edit `/etc/systemd/system/checkin-app.service` and update:
- `User` and `Group` - Application user (default: `checkin`)
- `WorkingDirectory` - Application directory (default: `/opt/checkin-app`)
- `ExecStart` - Path to Python executable and run script
### Environment Variables
Create and configure `.env` file in the application root:
```bash
sudo nano /opt/checkin-app/.env
```
Required variables:
```env
# Database
DATABASE_URL=sqlite:///./data/checkin.db
# Security
SECRET_KEY=your-secret-key-here
ALLOWED_ORIGINS=https://your-domain.com
# QQ Login
QQ_APPID=your-appid
QQ_APPSECRET=your-appsecret
```
## SSL Certificate Setup
### Using Let's Encrypt (Recommended)
```bash
# Install Certbot
sudo apt install certbot python3-certbot-nginx
# Obtain certificate
sudo certbot --nginx -d your-domain.com
# Auto-renewal is configured automatically
```
### Manual Certificate
If you have your own SSL certificate:
1. Copy certificate files to `/etc/nginx/ssl/`
2. Update Nginx configuration with correct paths
3. Reload Nginx: `sudo systemctl reload nginx`
## Service Management
### Start Service
```bash
sudo systemctl start checkin-app
```
### Stop Service
```bash
sudo systemctl stop checkin-app
```
### Restart Service
```bash
sudo systemctl restart checkin-app
```
### Check Status
```bash
sudo systemctl status checkin-app
```
### View Logs
```bash
# Application logs
sudo journalctl -u checkin-app -f
# Nginx access logs
sudo tail -f /var/log/nginx/checkin-app-access.log
# Nginx error logs
sudo tail -f /var/log/nginx/checkin-app-error.log
```
## Directory Structure
After deployment, the application structure should look like:
```
/opt/checkin-app/
├── backend/ # Backend Python code
│ ├── api/
│ ├── models/
│ ├── services/
│ └── ...
├── frontend/ # Frontend source code
│ ├── src/
│ ├── dist/ # Built static files (served by Nginx)
│ └── ...
├── venv/ # Python virtual environment
├── data/ # SQLite database
├── logs/ # Application logs
├── sessions/ # Session data
├── deployment/ # Deployment files (this directory)
├── .env # Environment variables
└── run_daemon.py # Application entry point
```
## Troubleshooting
### Service won't start
```bash
# Check logs
sudo journalctl -u checkin-app -xe
# Verify configuration
sudo -u checkin /opt/checkin-app/venv/bin/python /opt/checkin-app/run_daemon.py
```
### Nginx configuration errors
```bash
# Test configuration
sudo nginx -t
# Check error logs
sudo tail -f /var/log/nginx/error.log
```
### Database locked
```bash
# Check what's using the database
sudo fuser /opt/checkin-app/data/checkin.db
# Kill the process if needed
sudo fuser -k /opt/checkin-app/data/checkin.db
```
### Permission issues
```bash
# Fix ownership
sudo chown -R checkin:www-data /opt/checkin-app
# Fix permissions
sudo chmod -R 755 /opt/checkin-app
sudo chmod -R 775 /opt/checkin-app/{data,logs,sessions}
```
## Security Best Practices
1. **Keep system updated**
```bash
sudo apt update && sudo apt upgrade
```
2. **Use firewall**
```bash
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable
```
3. **Regular backups**
```bash
# Backup database
sudo -u checkin cp /opt/checkin-app/data/checkin.db /backup/checkin-$(date +%Y%m%d).db
```
4. **Monitor logs**
```bash
# Setup log rotation
sudo nano /etc/logrotate.d/checkin-app
```
5. **Use strong passwords** and **secure SECRET_KEY**
## Performance Tuning
### Nginx
- Enable gzip compression (already configured)
- Configure caching headers (already configured)
- Adjust worker processes based on CPU cores
### Backend
- Increase uvicorn workers in service file:
```
ExecStart=/opt/checkin-app/venv/bin/uvicorn backend.main:app --workers 4
```
- Consider using Gunicorn with uvicorn workers for production
### Database
- For high traffic, consider switching to PostgreSQL
- Regular VACUUM for SQLite
## Monitoring
Consider setting up monitoring tools:
- **Uptime monitoring**: Uptime Kuma, UptimeRobot
- **Log aggregation**: Loki, ELK Stack
- **Metrics**: Prometheus + Grafana
- **Error tracking**: Sentry
## Support
For detailed deployment instructions, see [DEPLOYMENT.md](./DEPLOYMENT.md).
For issues or questions:
- Check application logs
- Review troubleshooting section
- Open an issue on GitHub
+100
View File
@@ -0,0 +1,100 @@
# ==============================================================================
# CheckIn App V2 - Systemd Service File Example
# ==============================================================================
#
# This file defines a systemd service for running the CheckIn App backend
#
# Installation:
# 1. Copy this file: sudo cp checkin-app.service.example /etc/systemd/system/checkin-app.service
# 2. Edit the file and replace placeholders with your actual values
# 3. Reload systemd: sudo systemctl daemon-reload
# 4. Enable service: sudo systemctl enable checkin-app.service
# 5. Start service: sudo systemctl start checkin-app.service
# 6. Check status: sudo systemctl status checkin-app.service
#
# Management Commands:
# Start: sudo systemctl start checkin-app
# Stop: sudo systemctl stop checkin-app
# Restart: sudo systemctl restart checkin-app
# Status: sudo systemctl status checkin-app
# Logs: sudo journalctl -u checkin-app -f
#
# ==============================================================================
[Unit]
# Service description
Description=CheckIn App V2 - Backend API Service
Documentation=https://github.com/your-repo/checkin-app
# Start after network and database are available
After=network.target
Wants=network-online.target
[Service]
# Service type
Type=simple
# User and Group
# IMPORTANT: Replace 'www-data' with your actual user
# Create a dedicated user: sudo useradd -r -s /bin/false checkin
User=www-data
Group=www-data
# Working directory
# IMPORTANT: Replace with your actual installation path
WorkingDirectory=/opt/checkin-app
# Environment variables
Environment="PATH=/opt/checkin-app/venv/bin:/usr/local/bin:/usr/bin:/bin"
Environment="PYTHONPATH=/opt/checkin-app"
# Load environment variables from .env file (optional)
EnvironmentFile=-/opt/checkin-app/.env
# Command to start the service
# Using uvicorn directly for production
ExecStart=/opt/checkin-app/venv/bin/python /opt/checkin-app/run_daemon.py
# Alternative: Using uvicorn directly with more control
# ExecStart=/opt/checkin-app/venv/bin/uvicorn backend.main:app \
# --host 0.0.0.0 \
# --port 8000 \
# --workers 4 \
# --log-level info \
# --access-log \
# --proxy-headers
# Restart policy
Restart=always
RestartSec=10
# Kill signal
KillSignal=SIGTERM
KillMode=mixed
# Timeout settings
TimeoutStartSec=60
TimeoutStopSec=30
# Resource limits (optional)
LimitNOFILE=65535
# LimitNPROC=4096
# MemoryLimit=2G
# CPUQuota=200%
# Security settings (optional but recommended)
# Restrict access to the filesystem
# ReadWritePaths=/opt/checkin-app/data /opt/checkin-app/logs /opt/checkin-app/sessions
# ReadOnlyPaths=/opt/checkin-app
# Prevent privilege escalation
NoNewPrivileges=true
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=checkin-app
[Install]
# Start on boot
WantedBy=multi-user.target
+358
View File
@@ -0,0 +1,358 @@
#!/bin/bash
# ==============================================================================
# CheckIn App V2 - Quick Deployment Script
# ==============================================================================
#
# This script automates the deployment process for CheckIn App V2
#
# Usage:
# sudo ./deploy.sh [install|update|rollback]
#
# ==============================================================================
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Configuration
APP_NAME="checkin-app"
APP_USER="checkin"
APP_DIR="/opt/checkin-app"
SERVICE_NAME="checkin-app.service"
NGINX_CONFIG="checkin-app"
# Functions
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
check_root() {
if [ "$EUID" -ne 0 ]; then
log_error "This script must be run as root (use sudo)"
exit 1
fi
}
check_dependencies() {
log_info "Checking dependencies..."
local missing_deps=()
# Check Python
if ! command -v python3 &> /dev/null; then
missing_deps+=("python3")
fi
# Check Node.js
if ! command -v node &> /dev/null; then
missing_deps+=("nodejs")
fi
# Check Nginx
if ! command -v nginx &> /dev/null; then
missing_deps+=("nginx")
fi
if [ ${#missing_deps[@]} -ne 0 ]; then
log_error "Missing dependencies: ${missing_deps[*]}"
log_info "Please install them first:"
log_info " sudo apt install -y python3 python3-pip python3-venv nodejs nginx"
exit 1
fi
log_info "All dependencies are installed"
}
create_user() {
if id "$APP_USER" &>/dev/null; then
log_info "User $APP_USER already exists"
else
log_info "Creating user $APP_USER..."
useradd -r -m -s /bin/bash "$APP_USER"
usermod -aG www-data "$APP_USER"
log_info "User $APP_USER created"
fi
}
create_directories() {
log_info "Creating application directories..."
mkdir -p "$APP_DIR"
chown -R "$APP_USER:www-data" "$APP_DIR"
sudo -u "$APP_USER" mkdir -p "$APP_DIR"/{data,logs,sessions}
log_info "Directories created"
}
setup_backend() {
log_info "Setting up backend..."
cd "$APP_DIR"
# Create virtual environment
if [ ! -d "venv" ]; then
log_info "Creating virtual environment..."
sudo -u "$APP_USER" python3 -m venv venv
fi
# Install dependencies
log_info "Installing Python dependencies..."
sudo -u "$APP_USER" bash -c "source venv/bin/activate && pip install --upgrade pip && pip install -r backend/requirements.txt"
# Create .env if not exists
if [ ! -f ".env" ]; then
log_warn ".env file not found, please create one from .env.example"
if [ -f ".env.example" ]; then
sudo -u "$APP_USER" cp .env.example .env
log_info "Created .env from .env.example - please configure it"
fi
fi
log_info "Backend setup complete"
}
build_frontend() {
log_info "Building frontend..."
cd "$APP_DIR/frontend"
# Install dependencies
if [ ! -d "node_modules" ]; then
log_info "Installing Node.js dependencies..."
sudo -u "$APP_USER" npm install
fi
# Build
log_info "Building frontend for production..."
sudo -u "$APP_USER" npm run build
if [ -d "dist" ]; then
log_info "Frontend built successfully"
else
log_error "Frontend build failed - dist directory not found"
exit 1
fi
}
setup_systemd() {
log_info "Setting up systemd service..."
if [ -f "$APP_DIR/deployment/checkin-app.service.example" ]; then
# Copy service file
cp "$APP_DIR/deployment/checkin-app.service.example" "/etc/systemd/system/$SERVICE_NAME"
# Reload systemd
systemctl daemon-reload
# Enable service
systemctl enable "$SERVICE_NAME"
log_info "Systemd service configured"
else
log_error "Service file not found: $APP_DIR/deployment/checkin-app.service.example"
exit 1
fi
}
setup_nginx() {
log_info "Setting up Nginx configuration..."
if [ -f "$APP_DIR/deployment/nginx.conf.example" ]; then
# Copy Nginx config
cp "$APP_DIR/deployment/nginx.conf.example" "/etc/nginx/sites-available/$NGINX_CONFIG"
# Create symlink
if [ ! -L "/etc/nginx/sites-enabled/$NGINX_CONFIG" ]; then
ln -s "/etc/nginx/sites-available/$NGINX_CONFIG" "/etc/nginx/sites-enabled/$NGINX_CONFIG"
fi
# Test Nginx config
if nginx -t; then
log_info "Nginx configuration is valid"
else
log_error "Nginx configuration test failed"
exit 1
fi
log_warn "Please edit /etc/nginx/sites-available/$NGINX_CONFIG and configure your domain"
else
log_error "Nginx config file not found: $APP_DIR/deployment/nginx.conf.example"
exit 1
fi
}
start_services() {
log_info "Starting services..."
# Start application
systemctl start "$SERVICE_NAME"
# Reload Nginx
systemctl reload nginx
# Check status
sleep 2
if systemctl is-active --quiet "$SERVICE_NAME"; then
log_info "Application service started successfully"
else
log_error "Application service failed to start"
systemctl status "$SERVICE_NAME"
exit 1
fi
log_info "All services started"
}
install() {
log_info "Starting installation..."
check_root
check_dependencies
create_user
create_directories
setup_backend
build_frontend
setup_systemd
setup_nginx
# Set permissions
chown -R "$APP_USER:www-data" "$APP_DIR"
chmod -R 755 "$APP_DIR"
chmod -R 775 "$APP_DIR"/{data,logs,sessions}
start_services
echo ""
log_info "================================================"
log_info "Installation complete!"
log_info "================================================"
echo ""
log_info "Next steps:"
log_info "1. Configure .env file: sudo nano $APP_DIR/.env"
log_info "2. Configure Nginx: sudo nano /etc/nginx/sites-available/$NGINX_CONFIG"
log_info "3. Set up SSL certificate: sudo certbot --nginx -d your-domain.com"
log_info "4. Restart services: sudo systemctl restart $SERVICE_NAME nginx"
echo ""
log_info "Useful commands:"
log_info " Status: sudo systemctl status $SERVICE_NAME"
log_info " Logs: sudo journalctl -u $SERVICE_NAME -f"
log_info " Restart: sudo systemctl restart $SERVICE_NAME"
echo ""
}
update() {
log_info "Updating application..."
check_root
cd "$APP_DIR"
# Backup database
if [ -f "data/checkin.db" ]; then
log_info "Backing up database..."
sudo -u "$APP_USER" cp data/checkin.db "data/checkin.db.backup.$(date +%Y%m%d_%H%M%S)"
fi
# Pull latest changes (if using git)
if [ -d ".git" ]; then
log_info "Pulling latest changes..."
sudo -u "$APP_USER" git pull
fi
# Update backend
log_info "Updating backend dependencies..."
sudo -u "$APP_USER" bash -c "source venv/bin/activate && pip install -r backend/requirements.txt"
# Rebuild frontend
build_frontend
# Restart service
log_info "Restarting service..."
systemctl restart "$SERVICE_NAME"
# Check status
sleep 2
if systemctl is-active --quiet "$SERVICE_NAME"; then
log_info "Update completed successfully"
else
log_error "Service failed to start after update"
systemctl status "$SERVICE_NAME"
exit 1
fi
}
rollback() {
log_info "Rolling back to previous version..."
check_root
cd "$APP_DIR"
# Find latest backup
LATEST_BACKUP=$(ls -t data/checkin.db.backup.* 2>/dev/null | head -n 1)
if [ -z "$LATEST_BACKUP" ]; then
log_error "No database backup found"
exit 1
fi
log_info "Found backup: $LATEST_BACKUP"
# Stop service
systemctl stop "$SERVICE_NAME"
# Restore database
log_info "Restoring database..."
sudo -u "$APP_USER" cp "$LATEST_BACKUP" data/checkin.db
# Rollback git (if using git)
if [ -d ".git" ]; then
log_warn "Please manually rollback git to the desired commit"
log_info "Example: git reset --hard <commit-hash>"
fi
# Start service
systemctl start "$SERVICE_NAME"
log_info "Rollback completed"
}
# Main
case "${1:-}" in
install)
install
;;
update)
update
;;
rollback)
rollback
;;
*)
echo "CheckIn App V2 - Deployment Script"
echo ""
echo "Usage: $0 {install|update|rollback}"
echo ""
echo "Commands:"
echo " install - Full installation (first time)"
echo " update - Update existing installation"
echo " rollback - Rollback to previous version"
echo ""
exit 1
;;
esac
exit 0
+216
View File
@@ -0,0 +1,216 @@
# ==============================================================================
# CheckIn App V2 - Nginx Configuration Example
# ==============================================================================
#
# Usage:
# 1. Copy this file: sudo cp nginx.conf.example /etc/nginx/sites-available/checkin-app
# 2. Edit the file and replace placeholders with your actual values
# 3. Create symlink: sudo ln -s /etc/nginx/sites-available/checkin-app /etc/nginx/sites-enabled/
# 4. Test config: sudo nginx -t
# 5. Reload Nginx: sudo systemctl reload nginx
#
# ==============================================================================
# Upstream backend API server
upstream checkin_backend {
# Backend FastAPI server running on port 8000
server 127.0.0.1:8000;
# Optional: Add more backend servers for load balancing
# server 127.0.0.1:8001;
# server 127.0.0.1:8002;
# Keep alive connections
keepalive 32;
}
# HTTP Server - Redirect to HTTPS (optional)
server {
listen 80;
listen [::]:80;
server_name your-domain.com www.your-domain.com;
# Redirect all HTTP traffic to HTTPS
return 301 https://$server_name$request_uri;
}
# HTTPS Server
server {
# SSL Configuration
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name your-domain.com www.your-domain.com;
# SSL Certificate (Let's Encrypt recommended)
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# SSL Configuration (Modern)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security Headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Root directory for frontend static files
root /opt/checkin-app/frontend/dist;
index index.html;
# Access and Error Logs
access_log /var/log/nginx/checkin-app-access.log;
error_log /var/log/nginx/checkin-app-error.log;
# Gzip Compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
# Client body size (for file uploads)
client_max_body_size 10M;
# ==========================================
# API Proxy Configuration
# ==========================================
location /api/ {
# Proxy to backend
proxy_pass http://checkin_backend;
# Proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support (if needed)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffering
proxy_buffering off;
proxy_request_buffering off;
}
# API Documentation
location /docs {
proxy_pass http://checkin_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /redoc {
proxy_pass http://checkin_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /openapi.json {
proxy_pass http://checkin_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ==========================================
# Frontend Static Files
# ==========================================
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Frontend routes (SPA)
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Favicon
location = /favicon.ico {
log_not_found off;
access_log off;
}
# Robots.txt
location = /robots.txt {
log_not_found off;
access_log off;
}
# Health check endpoint
location /health {
proxy_pass http://checkin_backend;
access_log off;
}
}
# ==============================================================================
# Alternative: HTTP-only configuration (for development/internal use)
# ==============================================================================
# Uncomment below if you don't need HTTPS
# server {
# listen 80;
# listen [::]:80;
# server_name your-domain.com;
#
# root /opt/checkin-app/frontend/dist;
# index index.html;
#
# access_log /var/log/nginx/checkin-app-access.log;
# error_log /var/log/nginx/checkin-app-error.log;
#
# gzip on;
# gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
#
# client_max_body_size 10M;
#
# # API Proxy
# location /api/ {
# proxy_pass http://127.0.0.1:8000;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_http_version 1.1;
# proxy_buffering off;
# }
#
# # API Documentation
# location ~ ^/(docs|redoc|openapi.json) {
# proxy_pass http://127.0.0.1:8000;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# }
#
# # Static files
# location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
# expires 1y;
# add_header Cache-Control "public";
# }
#
# # Frontend routes
# location / {
# try_files $uri $uri/ /index.html;
# }
# }
+2
View File
@@ -0,0 +1,2 @@
# API Base URL (Development)
VITE_API_BASE_URL=http://localhost:8000
+2
View File
@@ -0,0 +1,2 @@
# API Base URL (Production)
VITE_API_BASE_URL=/api
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+230
View File
@@ -0,0 +1,230 @@
# 接龙自动打卡系统 - 前端
基于 Vue 3 + Vite + Element Plus 的现代化前端应用。
## 技术栈
- **框架**: Vue 3 (Composition API)
- **构建工具**: Vite
- **UI 库**: Element Plus
- **路由**: Vue Router 4
- **状态管理**: Pinia
- **HTTP 客户端**: Axios
- **图标**: @element-plus/icons-vue
## 项目结构
```
frontend/
├── src/
│ ├── api/ # API 接口
│ │ ├── client.js # Axios 客户端配置
│ │ └── index.js # API 方法封装
│ ├── assets/ # 静态资源
│ ├── components/ # 公共组件
│ │ ├── Layout.vue # 布局组件
│ │ ├── Navbar.vue # 导航栏
│ │ └── QRCodeModal.vue # QR 码扫码组件
│ ├── router/ # 路由配置
│ │ └── index.js
│ ├── stores/ # Pinia 状态管理
│ │ ├── auth.js # 认证状态
│ │ ├── user.js # 用户状态
│ │ ├── checkIn.js # 打卡状态
│ │ └── admin.js # 管理员状态
│ ├── utils/ # 工具函数
│ │ └── helpers.js # 通用辅助函数
│ ├── views/ # 页面组件
│ │ ├── LoginView.vue # 登录页
│ │ ├── DashboardView.vue # 用户仪表盘
│ │ ├── RecordsView.vue # 打卡记录
│ │ ├── NotFoundView.vue # 404 页面
│ │ └── admin/ # 管理员页面
│ │ ├── UsersView.vue # 用户管理
│ │ ├── RecordsView.vue # 所有打卡记录
│ │ ├── StatsView.vue # 统计信息
│ │ └── LogsView.vue # 系统日志
│ ├── App.vue # 根组件
│ ├── main.js # 入口文件
│ └── style.css # 全局样式
├── .env # 环境变量
├── .env.development # 开发环境变量
├── .env.production # 生产环境变量
├── vite.config.js # Vite 配置
├── package.json # 依赖配置
└── README.md # 本文件
```
## 快速开始
### 安装依赖
```bash
npm install
```
### 开发模式
```bash
npm run dev
```
访问 http://localhost:3000
### 生产构建
```bash
npm run build
```
构建产物在 `dist/` 目录。
### 预览生产构建
```bash
npm run preview
```
## 功能特性
### 用户功能
- **QQ 扫码登录**: 支持 QQ 扫码认证
- **个人仪表盘**: 查看 Token 状态、手动打卡
- **打卡记录**: 查看个人打卡历史和统计
- **Token 管理**: 实时监控 Token 过期状态
### 管理员功能
- **用户管理**: CRUD 操作、批量启用/禁用、批量打卡
- **打卡记录**: 查看所有用户的打卡记录
- **统计信息**: 系统整体运行数据统计
- **系统日志**: 实时查看系统运行日志
## API 代理配置
开发环境下,Vite 会自动代理 `/api` 请求到后端服务器:
```javascript
// vite.config.js
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
}
```
## 环境变量
创建 `.env.local` 文件自定义配置:
```env
VITE_API_BASE_URL=http://localhost:8000
```
## 路由结构
- `/login` - 登录页面
- `/dashboard` - 用户仪表盘(需登录)
- `/records` - 打卡记录(需登录)
- `/admin/users` - 用户管理(需管理员权限)
- `/admin/records` - 所有打卡记录(需管理员权限)
- `/admin/stats` - 统计信息(需管理员权限)
- `/admin/logs` - 系统日志(需管理员权限)
## 状态管理
使用 Pinia 进行全局状态管理:
- **authStore**: 认证状态(Token、用户信息)
- **userStore**: 用户管理相关
- **checkInStore**: 打卡记录相关
- **adminStore**: 管理员功能相关
## 组件说明
### QRCodeModal
QQ 扫码登录组件,支持:
- 自动获取二维码
- 轮询扫码状态
- 倒计时和进度显示
- 二维码过期提示和刷新
### Navbar
导航栏组件,支持:
- 基于角色的菜单显示
- 当前路由高亮
- 用户信息显示
- 退出登录
### Layout
页面布局组件,包含:
- 顶部导航栏
- 主内容区域
- 响应式布局
## 开发规范
1. **组件命名**: 使用 PascalCase
2. **文件命名**: 组件文件使用 PascalCase,工具文件使用 camelCase
3. **API 调用**: 统一通过 stores 调用,不直接在组件中调用
4. **错误处理**: 使用 try-catch 并显示友好的错误提示
5. **Loading 状态**: 异步操作需显示加载状态
## 浏览器支持
- Chrome >= 87
- Firefox >= 78
- Safari >= 14
- Edge >= 88
## 常见问题
### 启动时端口被占用
修改 `vite.config.js` 中的 `server.port` 配置。
### API 请求失败
检查后端服务是否启动,默认应在 http://localhost:8000 运行。
### 构建产物过大
Element Plus 已配置按需加载,如需进一步优化,可以:
- 使用动态导入 (dynamic import)
- 配置 CDN 引入
- 启用 gzip 压缩
## 部署
### Nginx 配置示例
```nginx
server {
listen 80;
server_name your-domain.com;
root /var/www/checkin/frontend/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
## 许可证
MIT
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+3008
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.2",
"element-plus": "^2.13.0",
"pinia": "^3.0.4",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"vite": "^7.2.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+42
View File
@@ -0,0 +1,42 @@
<template>
<router-view />
</template>
<script setup>
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
// 应用启动时验证 Token
onMounted(async () => {
if (authStore.isAuthenticated) {
try {
await authStore.fetchCurrentUser()
} catch (error) {
console.error('验证用户信息失败:', error)
// Token 可能已过期,清除认证状态
authStore.clearAuth()
}
}
})
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>
+73
View File
@@ -0,0 +1,73 @@
import axios from 'axios'
// 创建 axios 实例
const client = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器 - 添加 Token
client.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器 - 统一错误处理
client.interceptors.response.use(
(response) => {
return response.data
},
(error) => {
if (error.response) {
// 服务器返回错误状态码
const { status, data } = error.response
if (status === 401) {
// Token 过期或无效,清除登录状态
localStorage.removeItem('token')
localStorage.removeItem('user')
// 延迟跳转,避免阻塞当前异步请求的错误处理
setTimeout(() => {
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
}, 100)
}
// 返回统一的错误对象
return Promise.reject({
status,
message: data.detail || data.message || '请求失败',
data,
})
} else if (error.request) {
// 请求已发出但没有收到响应(超时或网络错误)
return Promise.reject({
status: 0,
message: error.code === 'ECONNABORTED' ? '请求超时,请稍后重试' : '网络错误,请检查您的网络连接',
data: null,
})
} else {
// 发生了触发请求错误的问题
return Promise.reject({
status: 0,
message: error.message || '请求配置错误',
data: null,
})
}
}
)
export default client
+254
View File
@@ -0,0 +1,254 @@
import client from './client'
/**
* 认证 API
*/
export const authAPI = {
// 请求 QR 码
requestQRCode: (alias) => {
return client.post('/api/auth/request_qrcode', { alias })
},
// 查询扫码状态
getQRCodeStatus: (sessionId) => {
return client.get(`/api/auth/qrcode_status/${sessionId}`)
},
// 别名+密码登录
aliasLogin: (alias, password) => {
return client.post('/api/auth/alias_login', { alias, password })
},
// 验证 Token
verifyToken: (token) => {
return client.post('/api/auth/verify_token', { token })
},
}
/**
* 用户 API
*/
export const userAPI = {
// 获取当前用户信息
getCurrentUser: () => {
return client.get('/api/users/me')
},
// 获取当前用户审批状态
getUserStatus: () => {
return client.get('/api/users/me/status')
},
// 获取当前用户 Token 状态
getTokenStatus: () => {
return client.get('/api/users/me/token_status')
},
// 更新当前用户个人信息
updateProfile: (profileData) => {
return client.put('/api/users/me/profile', profileData)
},
// 创建用户(管理员)
createUser: (userData) => {
return client.post('/api/users', userData)
},
// 获取所有用户(管理员)
getUsers: (params = {}) => {
return client.get('/api/users', { params })
},
// 获取指定用户
getUser: (userId) => {
return client.get(`/api/users/${userId}`)
},
// 更新用户
updateUser: (userId, userData) => {
return client.put(`/api/users/${userId}`, userData)
},
// 删除用户
deleteUser: (userId) => {
return client.delete(`/api/users/${userId}`)
},
}
/**
* 任务 API (V2 新增)
*/
export const taskAPI = {
// 获取当前用户的任务列表
getMyTasks: (params = {}) => {
return client.get('/api/tasks', { params })
},
// 创建任务
createTask: (taskData) => {
return client.post('/api/tasks', taskData)
},
// 获取任务详情
getTask: (taskId) => {
return client.get(`/api/tasks/${taskId}`)
},
// 更新任务
updateTask: (taskId, taskData) => {
return client.put(`/api/tasks/${taskId}`, taskData)
},
// 删除任务
deleteTask: (taskId) => {
return client.delete(`/api/tasks/${taskId}`)
},
// 切换任务启用状态
toggleTask: (taskId) => {
return client.post(`/api/tasks/${taskId}/toggle`)
},
// 手动触发任务打卡(异步,立即返回)
checkInTask: (taskId) => {
return client.post(`/api/check_in/manual/${taskId}`)
},
// 查询打卡记录状态
getCheckInRecordStatus: (recordId) => {
return client.get(`/api/check_in/record/${recordId}/status`)
},
// 获取任务的打卡记录
getTaskRecords: (taskId, params = {}) => {
return client.get(`/api/check_in/task/${taskId}/records`, { params })
},
}
/**
* 打卡 API
*/
export const checkInAPI = {
// 手动打卡(兼容旧版,推荐使用 taskAPI.checkInTask
manualCheckIn: (taskId) => {
// 打卡操作耗时较长,设置 120 秒超时
return client.post(`/api/check_in/manual/${taskId}`, {}, {
timeout: 120000 // 120 秒
})
},
// 获取任务打卡记录(兼容旧版,推荐使用 taskAPI.getTaskRecords
getMyRecords: (params = {}) => {
return client.get('/api/check_in/my-records', { params })
},
// 获取所有打卡记录(管理员)
getAllRecords: (params = {}) => {
return client.get('/api/check_in/records', { params })
},
// 统计打卡记录数
getRecordsCount: (params = {}) => {
return client.get('/api/check_in/records/count', { params })
},
}
/**
* 管理员 API
*/
export const adminAPI = {
// 获取待审批用户
getPendingUsers: () => {
return client.get('/api/admin/users/pending')
},
// 审批通过用户
approveUser: (userId) => {
return client.post(`/api/admin/users/${userId}/approve`)
},
// 拒绝用户
rejectUser: (userId) => {
return client.delete(`/api/admin/users/${userId}/reject`)
},
// 批量启用/禁用任务(V2 更新)
batchToggleTasks: (taskIds, isActive) => {
return client.post('/api/admin/batch_toggle_tasks', {
task_ids: taskIds,
is_active: isActive
})
},
// 批量触发打卡(V2 更新)
batchCheckIn: (taskIds) => {
return client.post('/api/admin/batch_check_in', {
task_ids: taskIds
})
},
// 查看系统日志
getLogs: (params = {}) => {
return client.get('/api/admin/logs', { params })
},
// 系统统计信息
getStats: () => {
return client.get('/api/admin/stats')
},
}
/**
* 模板 API
*/
export const templateAPI = {
// 获取所有模板列表
getTemplates: (params = {}) => {
return client.get('/api/templates', { params })
},
// 获取启用的模板列表
getActiveTemplates: (params = {}) => {
return client.get('/api/templates/active', { params })
},
// 获取单个模板详情
getTemplate: (templateId) => {
return client.get(`/api/templates/${templateId}`)
},
// 预览模板生成的 payload
previewTemplate: (templateId) => {
return client.get(`/api/templates/${templateId}/preview`)
},
// 创建模板(管理员)
createTemplate: (templateData) => {
return client.post('/api/templates', templateData)
},
// 更新模板(管理员)
updateTemplate: (templateId, templateData) => {
return client.put(`/api/templates/${templateId}`, templateData)
},
// 删除模板(管理员)
deleteTemplate: (templateId) => {
return client.delete(`/api/templates/${templateId}`)
},
// 从模板创建任务
createTaskFromTemplate: (requestData) => {
return client.post('/api/templates/create-task', requestData)
},
}
// 导出所有 API
export default {
auth: authAPI,
user: userAPI,
task: taskAPI, // V2 新增
checkIn: checkInAPI,
admin: adminAPI,
template: templateAPI, // V2.2 新增
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+316
View File
@@ -0,0 +1,316 @@
<template>
<div class="crontab-editor">
<!-- 模式选择 Tab -->
<div class="mode-tabs">
<button
v-for="m in modes"
:key="m"
:class="{ active: mode === m }"
@click.prevent="switchMode(m)"
class="mode-tab"
type="button"
>
{{ modeLabels[m] }}
</button>
</div>
<!-- 快速模式仅日期 20:00 -->
<div v-if="mode === 'quick'" class="mode-content">
<div class="quick-option">
<el-radio v-model="selectedQuick" label="20:00">
<span class="option-label">每天 20:00默认</span>
<span class="option-desc">推荐的默认时间</span>
</el-radio>
</div>
</div>
<!-- 自定义模式可视化构建器 -->
<div v-if="mode === 'custom'" class="mode-content">
<el-form label-width="120px">
<el-form-item label="时间">
<el-time-select
v-model="customTime"
:start="'00:00'"
:end="'23:30'"
step="00:30"
format="HH:mm"
placeholder="选择时间"
/>
</el-form-item>
<el-form-item label="频率">
<el-select v-model="customFrequency">
<el-option label="每天" value="daily" />
<el-option label="工作日(周一-周五)" value="weekday" />
<el-option label="周末(周六-周日)" value="weekend" />
</el-select>
</el-form-item>
</el-form>
</div>
<!-- 高级模式原始 Crontab 表达式 -->
<div v-if="mode === 'advanced'" class="mode-content">
<div class="expression-input">
<el-input
v-model="advancedExpression"
type="textarea"
placeholder="输入 crontab 表达式(例如:0 20 * * *"
:rows="2"
@input="validateExpression"
/>
<div class="help-text">
格式: 分钟 小时 日期 月份 星期
<a href="https://crontab.guru" target="_blank">了解更多</a>
</div>
</div>
</div>
<!-- 预览部分 -->
<div v-if="nextExecutions.length" class="preview-section">
<h4>下一个执行时间</h4>
<ul class="execution-list">
<li v-for="(time, idx) in nextExecutions" :key="idx">{{ time }}</li>
</ul>
</div>
<!-- 验证消息 -->
<div v-if="validationMessage" :class="['validation-message', validationStatus]">
{{ validationMessage }}
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import client from '@/api/client'
const props = defineProps({
modelValue: String, // 当前 cron 表达式
})
const emit = defineEmits(['update:modelValue'])
const mode = ref('quick')
const modeLabels = {
quick: '快速',
custom: '自定义',
advanced: '高级'
}
const modes = ['quick', 'custom', 'advanced']
// 快速模式
const selectedQuick = ref('20:00')
// 自定义模式
const customTime = ref('20:00')
const customFrequency = ref('daily')
// 高级模式
const advancedExpression = ref(props.modelValue || '0 20 * * *')
const validationMessage = ref('')
const validationStatus = ref('')
// 通用
const nextExecutions = ref([])
// 切换模式 - 防止页面刷新
function switchMode(newMode) {
mode.value = newMode
}
// 监听 - 只在有效值时更新
watch(selectedQuick, () => {
const cron = buildCrontabFromQuick()
emit('update:modelValue', cron)
if (cron) validateAndPreview(cron)
})
watch(customFrequency, () => {
const cron = buildCrontabFromCustom()
emit('update:modelValue', cron)
if (cron) validateAndPreview(cron)
})
watch(customTime, () => {
const cron = buildCrontabFromCustom()
emit('update:modelValue', cron)
if (cron) validateAndPreview(cron)
})
// 工具函数
function buildCrontabFromQuick() {
if (selectedQuick.value === '20:00') {
return '0 20 * * *' // 每天 20:00
}
return null
}
function buildCrontabFromCustom() {
const [hour, minute] = customTime.value.split(':')
let dow = '*' // 星期
if (customFrequency.value === 'weekday') {
dow = '1-5' // 周一至周五
} else if (customFrequency.value === 'weekend') {
dow = '0,6' // 周六和周日
}
return `${minute} ${hour} * * ${dow}`
}
async function validateExpression() {
if (!advancedExpression.value.trim()) {
validationMessage.value = ''
nextExecutions.value = []
return
}
await validateAndPreview(advancedExpression.value)
}
async function validateAndPreview(expr) {
if (!expr) {
validationMessage.value = ''
nextExecutions.value = []
return
}
try {
const response = await client.post('/api/tasks/validate-cron', {
cron_expression: expr
})
if (response.valid) {
validationStatus.value = 'success'
validationMessage.value = `有效: ${response.description}`
nextExecutions.value = response.next_times
}
} catch (error) {
validationStatus.value = 'error'
validationMessage.value = error.message || '无效的 crontab 表达式'
nextExecutions.value = []
}
}
// 初始化
watch(() => props.modelValue, (newVal) => {
if (newVal) {
advancedExpression.value = newVal
}
}, { immediate: true })
</script>
<style scoped>
.crontab-editor {
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 16px;
background: #f5f7fa;
}
.mode-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
border-bottom: 2px solid #ebeef5;
}
.mode-tab {
padding: 8px 16px;
background: none;
border: none;
cursor: pointer;
color: #909399;
font-weight: 500;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.3s;
}
.mode-tab.active {
color: #409eff;
border-bottom-color: #409eff;
}
.mode-content {
margin: 16px 0;
}
.quick-option {
padding: 12px;
background: white;
border-radius: 4px;
}
.option-label {
font-weight: 600;
}
.option-desc {
margin-left: 12px;
color: #909399;
font-size: 12px;
}
.expression-input {
margin: 12px 0;
}
.help-text {
margin-top: 8px;
color: #909399;
font-size: 12px;
}
.help-text a {
color: #409eff;
text-decoration: none;
}
.help-text a:hover {
text-decoration: underline;
}
.preview-section {
margin: 16px 0;
padding: 12px;
background: white;
border-radius: 4px;
}
.preview-section h4 {
margin: 0 0 8px 0;
font-size: 14px;
}
.execution-list {
margin: 0;
padding-left: 20px;
font-size: 12px;
color: #606266;
}
.validation-message {
padding: 8px 12px;
border-radius: 4px;
margin-top: 12px;
font-size: 12px;
}
.validation-message.success {
background: #f0f9ff;
color: #67c23a;
border: 1px solid #c6e2ff;
}
.validation-message.error {
background: #fef0f0;
color: #f56c6c;
border: 1px solid #fde7e7;
}
.validation-message.info {
background: #f4f4f5;
color: #909399;
border: 1px solid #ebeef5;
}
</style>
@@ -0,0 +1,294 @@
<template>
<div class="field-config-editor space-y-4">
<!-- Row 1: Display Name and Field Type -->
<div class="grid grid-cols-2 gap-4">
<el-form-item label="显示名称" class="mb-0">
<el-input
:model-value="modelValue.display_name"
@update:model-value="updateField('display_name', $event)"
placeholder="在表单中显示的名称"
clearable
/>
<span class="text-xs text-gray-500 mt-1">显示名称</span>
</el-form-item>
<el-form-item label="字段类型" class="mb-0">
<el-select
:model-value="modelValue.field_type"
@update:model-value="handleFieldTypeChange"
placeholder="选择输入控件类型"
class="w-full"
>
<el-option label="📝 单行文本" value="text" />
<el-option label="📄 多行文本" value="textarea" />
<el-option label="🔢 数字输入" value="number" />
<el-option label="📋 下拉选择" value="select" />
</el-select>
<span class="text-xs text-gray-500 mt-1">用户填写时使用的输入控件</span>
</el-form-item>
</div>
<!-- Row 2: Value Type and Default Value -->
<div class="grid grid-cols-2 gap-4">
<el-form-item label="值类型" class="mb-0">
<el-select
:model-value="modelValue.value_type"
@update:model-value="updateField('value_type', $event)"
placeholder="选择数据类型"
class="w-full"
>
<el-option label="字符串 (string)" value="string">
<span class="text-xs text-gray-500">字符串 (string)</span>
</el-option>
<el-option label="整数 (int)" value="int">
<span class="text-xs text-gray-500">整数 (int)</span>
</el-option>
<el-option label="浮点数 (double)" value="double">
<span class="text-xs text-gray-500">浮点数 (double)</span>
</el-option>
<el-option label="布尔值 (bool)" value="bool">
<span class="text-xs text-gray-500">布尔值 (bool)</span>
</el-option>
<el-option label="JSON对象 (json)" value="json">
<span class="text-xs text-gray-500">JSON对象 (json) - 用于Values字段</span>
</el-option>
</el-select>
<span class="text-xs text-gray-500 mt-1">数据存储时的类型</span>
</el-form-item>
<el-form-item label="默认值" class="mb-0">
<el-input
v-if="modelValue.value_type !== 'json'"
:model-value="modelValue.default_value"
@update:model-value="updateField('default_value', $event)"
placeholder="字段的默认值"
clearable
/>
<el-input
v-else
type="textarea"
:model-value="modelValue.default_value"
@update:model-value="updateField('default_value', $event)"
placeholder="字段的默认值"
:rows="3"
clearable
/>
<span class="text-xs text-gray-500 mt-1">
<template v-if="modelValue.value_type === 'json'">
<p>输入JSON对象会自动序列化为字符串</p>
<p>{"key1":value1,"key2":value2}</p>
</template>
<template v-else>
用户未填写时使用此值
</template>
</span>
</el-form-item>
</div>
<!-- Row 3: Placeholder -->
<el-form-item label="占位符提示" class="mb-0">
<el-input
:model-value="modelValue.placeholder"
@update:model-value="updateField('placeholder', $event)"
placeholder="输入框的灰色提示文本"
clearable
/>
<span class="text-xs text-gray-500 mt-1">占位符</span>
</el-form-item>
<!-- Row 4: Switches -->
<div class="grid grid-cols-2 gap-4 p-3 bg-blue-50 rounded-lg">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700">是否必填</label>
<p class="text-xs text-gray-500">用户必须填写此字段</p>
</div>
<el-switch
:model-value="modelValue.required"
@update:model-value="handleRequiredChange"
:disabled="modelValue.hidden"
/>
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700">是否隐藏</label>
<p class="text-xs text-gray-500">直接使用默认值不在表单中显示</p>
</div>
<el-switch
:model-value="modelValue.hidden"
@update:model-value="handleHiddenChange"
/>
</div>
</div>
<el-alert
v-if="modelValue.hidden"
title="💡 提示"
type="info"
:closable="false"
class="mt-3"
>
<p class="text-xs">
隐藏字段将自动使用默认值不会在创建任务表单中显示请确保设置了合适的默认值
</p>
</el-alert>
<!-- Options for select type -->
<div v-if="modelValue.field_type === 'select'" class="border-t pt-4 mt-4">
<el-form-item label="选项列表" class="mb-0">
<div class="space-y-2">
<div
v-for="(option, index) in modelValue.options || []"
:key="index"
class="flex items-center gap-2 p-2 bg-gray-50 rounded"
>
<span class="text-xs text-gray-500 w-8">{{ index + 1 }}.</span>
<el-input
:model-value="option.label"
@update:model-value="updateOption(index, 'label', $event)"
placeholder="显示文本(如:健康)"
size="small"
class="flex-1"
/>
<el-input
:model-value="option.value"
@update:model-value="updateOption(index, 'value', $event)"
placeholder="选项值(如:healthy"
size="small"
class="flex-1"
/>
<el-button
size="small"
type="danger"
:icon="Delete"
@click="removeOption(index)"
circle
/>
</div>
<el-button size="small" type="primary" plain @click="addOption" class="w-full">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
添加选项
</el-button>
<p class="text-xs text-gray-500 mt-2">
💡 提示显示文本是用户看到的内容选项值是实际保存的数据
</p>
</div>
</el-form-item>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
import { Delete } from '@element-plus/icons-vue'
const props = defineProps({
modelValue: {
type: Object,
required: true
},
fieldKey: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
// Update single field
const updateField = (field, value) => {
emit('update:modelValue', {
...props.modelValue,
[field]: value
})
}
// Handle required change
const handleRequiredChange = (value) => {
updateField('required', value)
}
// Handle hidden change - 当隐藏时,自动设置 required 为 false
const handleHiddenChange = (value) => {
const updated = {
...props.modelValue,
hidden: value
}
// 如果设置为隐藏,则取消必填
if (value) {
updated.required = false
}
emit('update:modelValue', updated)
}
// Handle field type change
const handleFieldTypeChange = (newType) => {
const updated = {
...props.modelValue,
field_type: newType
}
if (newType === 'select' && !updated.options) {
updated.options = []
}
emit('update:modelValue', updated)
}
// Add option
const addOption = () => {
const options = [...(props.modelValue.options || [])]
options.push({ label: '', value: '' })
emit('update:modelValue', {
...props.modelValue,
options
})
}
// Update option
const updateOption = (index, field, value) => {
const options = [...(props.modelValue.options || [])]
options[index] = {
...options[index],
[field]: value
}
emit('update:modelValue', {
...props.modelValue,
options
})
}
// Remove option
const removeOption = (index) => {
const options = [...(props.modelValue.options || [])]
options.splice(index, 1)
emit('update:modelValue', {
...props.modelValue,
options
})
}
</script>
<style scoped>
.field-config-editor {
background-color: #fafafa;
padding: 20px;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
:deep(.el-form-item__label) {
font-weight: 500;
color: #374151;
}
</style>
+497
View File
@@ -0,0 +1,497 @@
<template>
<div class="field-tree-node border-2 rounded-lg p-4 bg-white shadow-sm hover:shadow-md transition-shadow">
<!-- 普通字段 -->
<div v-if="isFieldConfig" class="field-config">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-gray-200">
<div class="flex items-center gap-3">
<button
type="button"
@click="isCollapsed = !isCollapsed"
class="hover:bg-gray-100 rounded p-1 transition-colors"
>
<svg
class="w-4 h-4 text-gray-600 transition-transform"
:class="{ 'rotate-180': !isCollapsed }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<span class="font-mono text-base font-bold text-blue-700">{{ fieldKey }}</span>
<el-tag type="primary" size="small">普通字段</el-tag>
</div>
<div class="flex gap-2">
<el-button size="small" @click="handleMove('up')" title="上移">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</el-button>
<el-button size="small" @click="handleMove('down')" title="下移">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
删除
</el-button>
</div>
</div>
<div v-show="!isCollapsed" class="bg-gray-50 rounded-lg p-3">
<FieldConfigEditor v-model="localFieldConfig" :field-key="fieldKey" />
</div>
</div>
<!-- 数组字段 -->
<div v-else-if="isArray" class="array-field">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-purple-200">
<div class="flex items-center gap-3">
<button
type="button"
@click="isCollapsed = !isCollapsed"
class="hover:bg-gray-100 rounded p-1 transition-colors"
>
<svg
class="w-4 h-4 text-gray-600 transition-transform"
:class="{ 'rotate-180': !isCollapsed }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
<span class="font-mono text-base font-bold text-purple-700">{{ fieldKey }}</span>
<el-tag type="warning" size="small">数组字段</el-tag>
</div>
<div class="flex gap-2">
<el-button size="small" @click="handleMove('up')" title="上移">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</el-button>
<el-button size="small" @click="handleMove('down')" title="下移">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</el-button>
<el-button size="small" type="primary" @click="addArrayItem">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
添加元素
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
删除
</el-button>
</div>
</div>
<div v-show="!isCollapsed">
<div v-if="localFieldConfig.length === 0" class="text-center py-6 bg-purple-50 rounded-lg border border-dashed border-purple-300">
<p class="text-sm text-gray-500 mb-2">数组为空</p>
<el-button size="small" type="primary" @click="addArrayItem">添加第一个元素</el-button>
</div>
<div v-else class="space-y-3 mt-3">
<div
v-for="(item, index) in localFieldConfig"
:key="index"
class="border-2 border-purple-200 rounded-lg p-3 bg-purple-50"
>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-purple-700">元素 #{{ index + 1 }}</span>
<el-button size="small" type="danger" plain @click="removeArrayItem(index)">
删除元素
</el-button>
</div>
<!-- 如果数组元素是字段配置对象直接渲染为字段编辑器 -->
<div v-if="typeof item === 'object' && !Array.isArray(item) && 'display_name' in item" class="bg-white rounded-lg p-3">
<FieldConfigEditor :model-value="item" @update:model-value="updateArrayItemField(index, $event)" :field-key="`元素${index + 1}`" />
</div>
<!-- 如果数组元素是对象但不是字段配置递归渲染其中的字段 -->
<div v-else-if="typeof item === 'object' && !Array.isArray(item)" class="space-y-2">
<FieldTreeNode
v-for="(subConfig, subKey) in item"
:key="subKey"
:field-key="subKey"
:field-config="subConfig"
:path="[...path, index, subKey]"
@update="$emit('update', $event)"
@delete="$emit('delete', $event)"
@move="$emit('move', $event)"
/>
<el-button class="w-full" size="small" type="primary" plain @click="addFieldToArrayItem(index)">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
添加字段
</el-button>
</div>
<!-- 如果数组元素是数组递归渲染 -->
<div v-else-if="Array.isArray(item)">
<FieldTreeNode
:field-key="`元素${index + 1}`"
:field-config="item"
:path="[...path, index]"
@update="$emit('update', $event)"
@delete="$emit('delete', $event)"
@move="$emit('move', $event)"
/>
</div>
</div>
</div>
</div>
</div>
<!-- 对象字段 -->
<div v-else-if="isObject" class="object-field">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-green-200">
<div class="flex items-center gap-3">
<button
type="button"
@click="isCollapsed = !isCollapsed"
class="hover:bg-gray-100 rounded p-1 transition-colors"
>
<svg
class="w-4 h-4 text-gray-600 transition-transform"
:class="{ 'rotate-180': !isCollapsed }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="font-mono text-base font-bold text-green-700">{{ fieldKey }}</span>
<el-tag type="success" size="small">对象字段</el-tag>
</div>
<div class="flex gap-2">
<el-button size="small" @click="handleMove('up')" title="上移">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</el-button>
<el-button size="small" @click="handleMove('down')" title="下移">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</el-button>
<el-button size="small" type="primary" @click="addFieldToObject">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
添加子字段
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
删除
</el-button>
</div>
</div>
<div v-show="!isCollapsed">
<div v-if="Object.keys(localFieldConfig).length === 0" class="text-center py-6 bg-green-50 rounded-lg border border-dashed border-green-300">
<p class="text-sm text-gray-500 mb-2">对象为空</p>
<el-button size="small" type="primary" @click="addFieldToObject">添加第一个子字段</el-button>
</div>
<div v-else class="space-y-3 mt-3 pl-4 border-l-4 border-green-300">
<!-- 递归渲染对象中的字段 -->
<FieldTreeNode
v-for="(subConfig, subKey) in localFieldConfig"
:key="subKey"
:field-key="subKey"
:field-config="subConfig"
:path="[...path, subKey]"
@update="$emit('update', $event)"
@delete="$emit('delete', $event)"
@move="$emit('move', $event)"
/>
</div>
</div>
</div>
<!-- 添加字段对话框 -->
<el-dialog v-model="addFieldDialogVisible" :title="currentArrayIndex === -1 ? '添加数组元素' : '添加字段'" width="400px">
<el-form>
<el-form-item :label="currentArrayIndex === -1 ? '字段名(可选)' : '字段名'">
<el-input
v-model="newFieldName"
:placeholder="currentArrayIndex === -1 ? '留空则作为数组元素,填写则作为对象字段' : '例如: FieldId, Values, Texts'"
/>
</el-form-item>
<el-form-item label="元素类型">
<el-radio-group v-model="newFieldType">
<el-radio label="field">普通字段</el-radio>
<el-radio label="array">数组字段</el-radio>
<el-radio label="object">对象字段</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addFieldDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmAddField">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import FieldConfigEditor from './FieldConfigEditor.vue'
const props = defineProps({
fieldKey: {
type: String,
required: true
},
fieldConfig: {
type: [Object, Array],
required: true
},
path: {
type: Array,
required: true
}
})
const emit = defineEmits(['update', 'delete', 'move'])
const localFieldConfig = ref(JSON.parse(JSON.stringify(props.fieldConfig)))
const addFieldDialogVisible = ref(false)
const newFieldName = ref('')
const newFieldType = ref('field')
const currentArrayIndex = ref(null)
const isAddingToObject = ref(false)
const isCollapsed = ref(false)
// 标志位,防止循环更新
let isUpdatingFromProps = false
// 监听 props.fieldConfig 的变化,同步更新 localFieldConfig
watch(() => props.fieldConfig, (newVal) => {
isUpdatingFromProps = true
localFieldConfig.value = JSON.parse(JSON.stringify(newVal))
// 使用 nextTick 确保在下一个 tick 后重置标志
nextTick(() => {
isUpdatingFromProps = false
})
}, { deep: true })
// 判断字段类型
const isFieldConfig = computed(() => {
return typeof props.fieldConfig === 'object' &&
!Array.isArray(props.fieldConfig) &&
'display_name' in props.fieldConfig
})
const isArray = computed(() => {
return Array.isArray(props.fieldConfig)
})
const isObject = computed(() => {
return typeof props.fieldConfig === 'object' &&
!Array.isArray(props.fieldConfig) &&
!('display_name' in props.fieldConfig)
})
// 监听本地配置变化 - 只在非 props 更新时触发
watch(localFieldConfig, (newVal) => {
if (!isUpdatingFromProps) {
emit('update', { path: props.path, value: newVal })
}
}, { deep: true })
// 删除字段
const handleDelete = () => {
emit('delete', props.path)
}
// 移动字段
const handleMove = (direction) => {
emit('move', { path: props.path, direction })
}
// 添加数组元素
const addArrayItem = () => {
// 弹出对话框让用户选择添加元素类型
currentArrayIndex.value = -1 // 标记为添加数组元素
isAddingToObject.value = false
newFieldName.value = '' // 数组元素不需要字段名,但复用对话框
newFieldType.value = 'field'
addFieldDialogVisible.value = true
}
// 删除数组元素
const removeArrayItem = (index) => {
localFieldConfig.value.splice(index, 1)
}
// 更新数组元素的字段配置
const updateArrayItemField = (index, newValue) => {
localFieldConfig.value[index] = newValue
}
// 为数组元素添加字段
const addFieldToArrayItem = (index) => {
currentArrayIndex.value = index
isAddingToObject.value = false
newFieldName.value = ''
newFieldType.value = 'field'
addFieldDialogVisible.value = true
}
// 为对象添加字段
const addFieldToObject = () => {
currentArrayIndex.value = null
isAddingToObject.value = true
newFieldName.value = ''
newFieldType.value = 'field'
addFieldDialogVisible.value = true
}
// 确认添加字段
const confirmAddField = () => {
// 如果是添加数组元素(currentArrayIndex === -1
if (currentArrayIndex.value === -1) {
// 检查是否输入了字段名
if (!newFieldName.value || newFieldName.value.trim() === '') {
// 字段名为空,直接添加为数组元素
if (newFieldType.value === 'field') {
localFieldConfig.value.push({
display_name: '',
field_type: 'text',
default_value: '',
required: false,
hidden: false,
value_type: 'string',
options: []
})
} else if (newFieldType.value === 'array') {
localFieldConfig.value.push([])
} else if (newFieldType.value === 'object') {
localFieldConfig.value.push({})
}
addFieldDialogVisible.value = false
ElMessage.success('数组元素添加成功')
return
} else {
// 字段名不为空,添加为包含命名字段的对象
const newObject = {}
if (newFieldType.value === 'field') {
newObject[newFieldName.value] = {
display_name: '',
field_type: 'text',
default_value: '',
required: false,
hidden: false,
value_type: 'string',
options: []
}
} else if (newFieldType.value === 'array') {
newObject[newFieldName.value] = []
} else if (newFieldType.value === 'object') {
newObject[newFieldName.value] = {}
}
localFieldConfig.value.push(newObject)
addFieldDialogVisible.value = false
ElMessage.success('带命名字段的对象添加成功')
return
}
}
// 其他情况需要字段名
if (!newFieldName.value) {
ElMessage.warning('请输入字段名')
return
}
if (isAddingToObject.value) {
// 添加到对象字段
if (localFieldConfig.value[newFieldName.value]) {
ElMessage.warning('该字段已存在')
return
}
if (newFieldType.value === 'field') {
localFieldConfig.value[newFieldName.value] = {
display_name: '',
field_type: 'text',
default_value: '',
required: false,
hidden: false,
value_type: 'string',
options: []
}
} else if (newFieldType.value === 'array') {
localFieldConfig.value[newFieldName.value] = []
} else if (newFieldType.value === 'object') {
localFieldConfig.value[newFieldName.value] = {}
}
} else if (currentArrayIndex.value !== null) {
// 添加到数组元素
const arrayItem = localFieldConfig.value[currentArrayIndex.value]
if (arrayItem[newFieldName.value]) {
ElMessage.warning('该字段已存在')
return
}
if (newFieldType.value === 'field') {
arrayItem[newFieldName.value] = {
display_name: '',
field_type: 'text',
default_value: '',
required: false,
hidden: false,
value_type: 'string',
options: []
}
} else if (newFieldType.value === 'array') {
arrayItem[newFieldName.value] = []
} else if (newFieldType.value === 'object') {
arrayItem[newFieldName.value] = {}
}
}
addFieldDialogVisible.value = false
ElMessage.success('字段添加成功')
}
</script>
<style scoped>
.field-tree-node {
position: relative;
}
.rotate-180 {
transform: rotate(180deg);
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
<template>
<div class="layout-container">
<Navbar />
<div class="main-content">
<slot />
</div>
</div>
</template>
<script setup>
import Navbar from './Navbar.vue'
</script>
<style scoped>
.layout-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
overflow-y: auto;
background-color: #f5f5f5;
padding: 20px;
}
</style>
+270
View File
@@ -0,0 +1,270 @@
<template>
<div class="sticky top-0 z-50 glass-effect border-b border-gray-200/50 shadow-md3-2">
<nav class="max-w-7xl mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<!-- Logo and Brand -->
<div class="flex items-center space-x-8">
<router-link to="/" class="flex items-center space-x-3 group">
<div class="w-10 h-10 bg-gradient-to-br from-primary-500 to-secondary-500 rounded-md3 flex items-center justify-center transform group-hover:scale-110 transition-transform">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span class="text-xl font-bold text-gradient">接龙自动打卡</span>
</router-link>
<!-- Navigation Links -->
<div class="hidden md:flex items-center space-x-2">
<router-link
to="/dashboard"
v-slot="{ isActive }"
custom
>
<a
@click="router.push('/dashboard')"
:class="[
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 text-primary-700'
: 'text-gray-700 hover:bg-gray-100'
]"
>
<div class="flex items-center space-x-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span>仪表盘</span>
</div>
</a>
</router-link>
<router-link
to="/tasks"
v-slot="{ isActive }"
custom
>
<a
@click="router.push('/tasks')"
:class="[
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 text-primary-700'
: 'text-gray-700 hover:bg-gray-100'
]"
>
<div class="flex items-center space-x-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span>任务管理</span>
</div>
</a>
</router-link>
<router-link
to="/records"
v-slot="{ isActive }"
custom
>
<a
@click="router.push('/records')"
:class="[
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 text-primary-700'
: 'text-gray-700 hover:bg-gray-100'
]"
>
<div class="flex items-center space-x-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
<span>打卡记录</span>
</div>
</a>
</router-link>
<!-- Admin Menu -->
<div v-if="authStore.isAdmin" class="relative" @mouseenter="showAdminMenu = true" @mouseleave="showAdminMenu = false">
<button
:class="[
'px-4 py-2 rounded-full font-medium transition-all flex items-center space-x-2',
isAdminPath ? 'bg-secondary-100 text-secondary-700' : 'text-gray-700 hover:bg-gray-100'
]"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>管理后台</span>
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': showAdminMenu }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Admin Dropdown -->
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div v-show="showAdminMenu" class="absolute top-full left-0 mt-2 w-48 glass-effect rounded-md3 shadow-md3-3 py-2 border border-gray-200/50">
<router-link
to="/admin/users"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span>用户管理</span>
</div>
</router-link>
<router-link
to="/admin/templates"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>模板管理</span>
</div>
</router-link>
<router-link
to="/admin/records"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
<span>打卡记录</span>
</div>
</router-link>
<router-link
to="/admin/stats"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span>统计信息</span>
</div>
</router-link>
<router-link
to="/admin/logs"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>系统日志</span>
</div>
</router-link>
</div>
</transition>
</div>
</div>
</div>
<!-- User Menu -->
<div class="flex items-center space-x-4">
<!-- User Avatar and Menu -->
<div class="relative" @mouseenter="showUserMenu = true" @mouseleave="showUserMenu = false">
<button class="flex items-center space-x-3 px-4 py-2 rounded-full hover:bg-gray-100 transition-all">
<div class="w-8 h-8 bg-gradient-to-br from-accent-400 to-accent-600 rounded-full flex items-center justify-center text-white font-semibold">
{{ userInitial }}
</div>
<span class="hidden md:block font-medium text-gray-700">{{ authStore.user?.alias || '用户' }}</span>
<svg class="w-4 h-4 text-gray-500 transition-transform" :class="{ 'rotate-180': showUserMenu }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- User Dropdown -->
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div v-show="showUserMenu" class="absolute top-full right-0 mt-2 w-48 glass-effect rounded-md3 shadow-md3-3 py-2 border border-gray-200/50">
<div class="px-4 py-2 border-b border-gray-200/50">
<p class="text-sm font-medium text-gray-900">{{ authStore.user?.alias }}</p>
<p class="text-xs text-gray-500 mt-1">{{ authStore.isAdmin ? '管理员' : '普通用户' }}</p>
</div>
<button
@click="router.push('/settings')"
class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors flex items-center space-x-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>个人设置</span>
</button>
<button
@click="handleLogout"
class="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors flex items-center space-x-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>退出登录</span>
</button>
</div>
</transition>
</div>
</div>
</div>
</nav>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessageBox } from 'element-plus'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const showAdminMenu = ref(false)
const showUserMenu = ref(false)
const isAdminPath = computed(() => route.path.startsWith('/admin'))
const userInitial = computed(() => {
const name = authStore.user?.alias || 'U'
return name.charAt(0).toUpperCase()
})
const handleLogout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
authStore.logout()
router.push('/login')
})
.catch(() => {
// 取消操作
})
}
</script>
<style scoped>
/* Additional component-specific styles if needed */
</style>
+97
View File
@@ -0,0 +1,97 @@
<template>
<el-menu
:default-active="activeMenu"
mode="horizontal"
:ellipsis="false"
@select="handleSelect"
>
<div class="flex-grow">
<el-menu-item index="/">
<el-icon><HomeFilled /></el-icon>
<span class="logo-text">接龙自动打卡系统</span>
</el-menu-item>
<el-menu-item index="/dashboard">
<el-icon><User /></el-icon>
<span>我的仪表盘</span>
</el-menu-item>
<el-menu-item index="/records">
<el-icon><List /></el-icon>
<span>打卡记录</span>
</el-menu-item>
<!-- 管理员菜单 -->
<el-sub-menu v-if="authStore.isAdmin" index="admin">
<template #title>
<el-icon><Setting /></el-icon>
<span>管理后台</span>
</template>
<el-menu-item index="/admin/users">用户管理</el-menu-item>
<el-menu-item index="/admin/records">所有打卡记录</el-menu-item>
<el-menu-item index="/admin/stats">统计信息</el-menu-item>
<el-menu-item index="/admin/logs">系统日志</el-menu-item>
</el-sub-menu>
</div>
<div class="flex-grow" />
<el-sub-menu index="user">
<template #title>
<el-icon><Avatar /></el-icon>
<span>{{ authStore.userSignature || '用户' }}</span>
</template>
<el-menu-item @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
<span>退出登录</span>
</el-menu-item>
</el-sub-menu>
</el-menu>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessageBox } from 'element-plus'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const activeMenu = computed(() => route.path)
const handleSelect = (index) => {
if (index !== route.path) {
router.push(index)
}
}
const handleLogout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
authStore.logout()
router.push('/login')
})
.catch(() => {
// 取消操作
})
}
</script>
<style scoped>
.flex-grow {
flex-grow: 1;
display: flex;
}
.logo-text {
font-weight: bold;
font-size: 18px;
margin-left: 8px;
}
</style>
+278
View File
@@ -0,0 +1,278 @@
<template>
<el-dialog
v-model="dialogVisible"
title="QQ 扫码登录"
width="400px"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="qrcode-container">
<!-- 加载中 -->
<div v-if="status === 'loading'" class="status-container">
<el-icon class="is-loading" :size="60">
<Loading />
</el-icon>
<p class="status-text">正在获取二维码...</p>
</div>
<!-- 显示二维码 -->
<div v-else-if="status === 'pending'" class="qrcode-wrapper">
<img :src="qrcodeUrl" alt="QR Code" class="qrcode-image" />
<p class="hint-text">请使用手机 QQ 扫描二维码登录</p>
<el-progress :percentage="progress" :show-text="false" />
<p class="countdown-text">{{ countdown }}s</p>
</div>
<!-- 扫码成功 -->
<div v-else-if="status === 'success'" class="status-container">
<el-icon :size="60" color="#67c23a">
<SuccessFilled />
</el-icon>
<p class="status-text success">登录成功</p>
</div>
<!-- 二维码过期 -->
<div v-else-if="status === 'expired'" class="status-container">
<el-icon :size="60" color="#e6a23c">
<WarningFilled />
</el-icon>
<p class="status-text">二维码已过期</p>
<el-button type="primary" @click="refreshQRCode">刷新二维码</el-button>
</div>
<!-- 失败 -->
<div v-else-if="status === 'failed'" class="status-container">
<el-icon :size="60" color="#f56c6c">
<CircleCloseFilled />
</el-icon>
<p class="status-text error">{{ errorMessage }}</p>
<el-button type="primary" @click="refreshQRCode">重试</el-button>
</div>
</div>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
const props = defineProps({
visible: {
type: Boolean,
required: true,
},
alias: {
type: String,
required: true,
},
})
const emit = defineEmits(['update:visible', 'success', 'error'])
const authStore = useAuthStore()
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
})
const status = ref('loading') // loading, pending, success, expired, failed
const qrcodeUrl = ref('')
const sessionId = ref('')
const errorMessage = ref('')
const countdown = ref(180) // 倒计时 3 分钟
const progress = ref(100)
let pollingTimer = null
let countdownTimer = null
// 获取二维码
const fetchQRCode = async () => {
status.value = 'loading'
try {
const result = await authStore.loginWithQRCode(props.alias)
sessionId.value = result.session_id
qrcodeUrl.value = `data:image/png;base64,${result.qrcode_base64}`
status.value = 'pending'
// 开始轮询扫码状态
startPolling()
startCountdown()
} catch (error) {
status.value = 'failed'
errorMessage.value = error.message || '获取二维码失败'
emit('error', error)
}
}
// 开始轮询扫码状态
const startPolling = () => {
if (pollingTimer) {
clearInterval(pollingTimer)
}
pollingTimer = setInterval(async () => {
try {
const result = await authStore.checkQRCodeStatus(sessionId.value)
if (result.success) {
// 扫码成功
status.value = 'success'
stopPolling()
stopCountdown()
ElMessage.success('登录成功!')
// 延迟关闭对话框
setTimeout(() => {
emit('success', result.user)
handleClose()
}, 1500)
} else if (result.status === 'expired') {
// 二维码过期
status.value = 'expired'
stopPolling()
stopCountdown()
} else if (result.status === 'failed') {
// 扫码失败
status.value = 'failed'
errorMessage.value = result.message || '扫码失败'
stopPolling()
stopCountdown()
}
// 否则继续轮询(pending 状态)
} catch (error) {
console.error('轮询扫码状态失败:', error)
// 继续轮询,不中断
}
}, 2000) // 每 2 秒轮询一次
}
// 停止轮询
const stopPolling = () => {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
}
// 开始倒计时
const startCountdown = () => {
countdown.value = 180
if (countdownTimer) {
clearInterval(countdownTimer)
}
countdownTimer = setInterval(() => {
countdown.value--
progress.value = (countdown.value / 180) * 100
if (countdown.value <= 0) {
status.value = 'expired'
stopPolling()
stopCountdown()
}
}, 1000)
}
// 停止倒计时
const stopCountdown = () => {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
// 刷新二维码
const refreshQRCode = () => {
fetchQRCode()
}
// 关闭对话框
const handleClose = () => {
stopPolling()
stopCountdown()
dialogVisible.value = false
}
// 监听对话框显示状态
watch(
() => props.visible,
(visible) => {
if (visible) {
fetchQRCode()
} else {
stopPolling()
stopCountdown()
}
}
)
</script>
<style scoped>
.qrcode-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
min-height: 300px;
}
.status-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
}
.status-text {
margin-top: 20px;
font-size: 16px;
color: #606266;
}
.status-text.success {
color: #67c23a;
font-weight: bold;
}
.status-text.error {
color: #f56c6c;
}
.qrcode-wrapper {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.qrcode-image {
width: 240px;
height: 240px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 10px;
background-color: #fff;
}
.hint-text {
margin-top: 20px;
font-size: 14px;
color: #909399;
}
.countdown-text {
margin-top: 10px;
font-size: 12px;
color: #909399;
}
.el-progress {
width: 100%;
margin-top: 10px;
}
</style>
+54
View File
@@ -0,0 +1,54 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import {
User,
Key,
Calendar,
Refresh,
Document,
List,
Plus,
UserFilled,
DataAnalysis,
Loading,
SuccessFilled,
WarningFilled,
CircleCloseFilled
} from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
const pinia = createPinia()
// 按需注册 Element Plus 图标(仅注册使用的13个)
const icons = {
User,
Key,
Calendar,
Refresh,
Document,
List,
Plus,
UserFilled,
DataAnalysis,
Loading,
SuccessFilled,
WarningFilled,
CircleCloseFilled
}
for (const [key, component] of Object.entries(icons)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
+163
View File
@@ -0,0 +1,163 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/LoginView.vue'),
meta: { requiresAuth: false, title: '登录' },
},
{
path: '/',
redirect: '/dashboard',
},
{
path: '/pending-approval',
name: 'PendingApproval',
component: () => import('@/views/PendingApprovalView.vue'),
meta: { requiresAuth: true, title: '等待审批' },
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { requiresAuth: true, title: '我的仪表盘' },
},
{
path: '/tasks',
name: 'Tasks',
component: () => import('@/views/TasksView.vue'),
meta: { requiresAuth: true, title: '任务管理' },
},
{
path: '/tasks/:taskId/records',
name: 'TaskRecords',
component: () => import('@/views/TaskRecordsView.vue'),
meta: { requiresAuth: true, title: '任务打卡记录' },
},
{
path: '/records',
name: 'Records',
component: () => import('@/views/RecordsView.vue'),
meta: { requiresAuth: true, title: '打卡记录' },
},
{
path: '/settings',
name: 'Settings',
component: () => import('@/views/SettingsView.vue'),
meta: { requiresAuth: true, title: '个人设置' },
},
{
path: '/admin',
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{
path: 'users',
name: 'AdminUsers',
component: () => import('@/views/admin/UsersView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: '用户管理' },
},
{
path: 'records',
name: 'AdminRecords',
component: () => import('@/views/admin/RecordsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: '打卡记录' },
},
{
path: 'logs',
name: 'AdminLogs',
component: () => import('@/views/admin/LogsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: '系统日志' },
},
{
path: 'stats',
name: 'AdminStats',
component: () => import('@/views/admin/StatsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: '统计信息' },
},
{
path: 'templates',
name: 'AdminTemplates',
component: () => import('@/views/admin/TemplatesView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: '模板管理' },
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFoundView.vue'),
meta: { requiresAuth: false, title: '页面未找到' },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
// 设置页面标题
document.title = to.meta.title ? `${to.meta.title} - 接龙自动打卡系统` : '接龙自动打卡系统'
// 检查是否需要认证
if (to.meta.requiresAuth) {
if (!authStore.isAuthenticated) {
// 未登录,重定向到登录页
next({ name: 'Login', query: { redirect: to.fullPath } })
return
}
// 检查用户审批状态(除了待审批页面本身)
if (to.name !== 'PendingApproval') {
try {
const { userAPI } = await import('@/api')
const status = await userAPI.getUserStatus()
if (!status.is_approved) {
// 未审批用户只能访问待审批页面
next({ name: 'PendingApproval' })
return
}
} catch (error) {
console.error('检查审批状态失败:', error)
// 如果检查失败,允许继续访问(避免阻塞正常用户)
}
} else {
// 访问待审批页面时,检查是否已审批
try {
const { userAPI } = await import('@/api')
const status = await userAPI.getUserStatus()
if (status.is_approved) {
// 已审批用户不能访问待审批页面
next({ name: 'Dashboard' })
return
}
} catch (error) {
console.error('检查审批状态失败:', error)
}
}
// 检查是否需要管理员权限
if (to.meta.requiresAdmin && !authStore.isAdmin) {
// 非管理员,重定向到仪表盘
next({ name: 'Dashboard' })
return
}
} else {
// 不需要认证的页面,如果已登录则重定向到仪表盘
if (to.name === 'Login' && authStore.isAuthenticated) {
next({ name: 'Dashboard' })
return
}
}
next()
})
export default router
+72
View File
@@ -0,0 +1,72 @@
import { defineStore } from 'pinia'
import { adminAPI } from '@/api'
export const useAdminStore = defineStore('admin', {
state: () => ({
stats: null, // 系统统计信息
logs: [],
logsTotal: 0,
loading: false,
}),
getters: {
totalUsers: (state) => state.stats?.users?.total || 0,
activeUsers: (state) => {
// Active users = 已审批的用户(is_approved=true
return state.stats?.users?.active || 0
},
totalRecords: (state) => state.stats?.check_in_records?.total || 0,
todayRecords: (state) => state.stats?.check_in_records?.today || 0,
},
actions: {
// 获取系统统计信息
async fetchStats() {
this.loading = true
try {
const stats = await adminAPI.getStats()
this.stats = stats
return stats
} catch (error) {
throw new Error(error.message || '获取统计信息失败')
} finally {
this.loading = false
}
},
// 批量启用/禁用用户
async batchToggleActive(userIds, isActive) {
try {
const result = await adminAPI.batchToggleActive(userIds, isActive)
return result
} catch (error) {
throw new Error(error.message || '批量操作失败')
}
},
// 批量触发打卡
async batchCheckIn(userIds) {
try {
const result = await adminAPI.batchCheckIn(userIds)
return result
} catch (error) {
throw new Error(error.message || '批量打卡失败')
}
},
// 获取系统日志
async fetchLogs(params = {}) {
this.loading = true
try {
const data = await adminAPI.getLogs(params)
this.logs = data.logs || data
this.logsTotal = data.total || this.logs.length
return data
} catch (error) {
throw new Error(error.message || '获取日志失败')
} finally {
this.loading = false
}
},
},
})
+124
View File
@@ -0,0 +1,124 @@
import { defineStore } from 'pinia'
import { authAPI, userAPI } from '@/api'
export const useAuthStore = defineStore('auth', {
state: () => {
// 安全地解析 localStorage 中的用户数据
let user = null
try {
const userStr = localStorage.getItem('user')
if (userStr && userStr !== 'undefined' && userStr !== 'null') {
user = JSON.parse(userStr)
}
} catch (e) {
console.warn('Failed to parse user from localStorage:', e)
localStorage.removeItem('user')
}
return {
token: localStorage.getItem('token') || null,
user,
}
},
getters: {
// 将 isAuthenticated 改为 getter,这样它会实时反应 state.token 的变化
isAuthenticated: (state) => !!state.token,
isAdmin: (state) => state.user?.role === 'admin',
},
actions: {
// 设置认证信息
setAuth(token, user) {
// 清理 token:移除 URL 编码的 Bearer 前缀
let cleanToken = token
if (cleanToken) {
// URL 解码
cleanToken = decodeURIComponent(cleanToken)
// 移除 Bearer 前缀(如果存在)
if (cleanToken.toLowerCase().startsWith('bearer ')) {
cleanToken = cleanToken.substring(7)
}
}
this.token = cleanToken
this.user = user
localStorage.setItem('token', cleanToken)
localStorage.setItem('user', JSON.stringify(user))
},
// 清除认证信息
clearAuth() {
this.token = null
this.user = null
localStorage.removeItem('token')
localStorage.removeItem('user')
},
// QR 码登录流程
async loginWithQRCode(alias) {
try {
// 1. 请求 QR 码
const qrData = await authAPI.requestQRCode(alias)
const { session_id, qrcode_base64 } = qrData
// 2. 返回 session_id 和 qrcode,由组件处理轮询
return { session_id, qrcode_base64 }
} catch (error) {
throw new Error(error.message || '请求二维码失败')
}
},
// 检查扫码状态
async checkQRCodeStatus(sessionId) {
try {
const result = await authAPI.getQRCodeStatus(sessionId)
if (result.status === 'success') {
// 扫码成功,保存 Token 和用户信息
this.setAuth(result.token, result.user)
return { success: true, user: result.user }
} else if (result.status === 'failed') {
return { success: false, message: result.message }
} else {
// pending 或 expired
return { success: false, status: result.status }
}
} catch (error) {
throw new Error(error.message || '检查扫码状态失败')
}
},
// 验证 Token
async verifyToken(token) {
try {
const userData = await authAPI.verifyToken(token)
this.setAuth(token, userData)
return userData
} catch (error) {
this.clearAuth()
throw new Error(error.message || 'Token 验证失败')
}
},
// 获取当前用户信息
async fetchCurrentUser() {
try {
const userData = await userAPI.getCurrentUser()
// 更新本地用户信息
this.user = userData
localStorage.setItem('user', JSON.stringify(userData))
return userData
} catch (error) {
throw new Error(error.message || '获取用户信息失败')
}
},
// 登出
logout() {
this.clearAuth()
},
},
})
+94
View File
@@ -0,0 +1,94 @@
import { defineStore } from 'pinia'
import { checkInAPI } from '@/api'
export const useCheckInStore = defineStore('checkIn', {
state: () => ({
myRecords: [],
allRecords: [], // 管理员查看所有记录
currentPage: 1,
pageSize: 20,
total: 0,
loading: false,
}),
getters: {
todayRecords: (state) => {
const today = new Date().toISOString().split('T')[0]
return state.myRecords.filter((record) => {
const recordDate = new Date(record.check_in_time).toISOString().split('T')[0]
return recordDate === today
})
},
successRate: (state) => {
if (state.myRecords.length === 0) return 0
const successCount = state.myRecords.filter((r) => r.status === 'success').length
return ((successCount / state.myRecords.length) * 100).toFixed(2)
},
},
actions: {
// 手动打卡
async manualCheckIn() {
this.loading = true
try {
const result = await checkInAPI.manualCheckIn()
// 刷新打卡记录
await this.fetchMyRecords()
return result
} catch (error) {
throw new Error(error.message || '打卡失败')
} finally {
this.loading = false
}
},
// 获取我的打卡记录
async fetchMyRecords(params = {}) {
this.loading = true
try {
const data = await checkInAPI.getMyRecords({
skip: (this.currentPage - 1) * this.pageSize,
limit: this.pageSize,
...params,
})
this.myRecords = data.records || data
this.total = data.total || this.myRecords.length
return data
} catch (error) {
throw new Error(error.message || '获取打卡记录失败')
} finally {
this.loading = false
}
},
// 获取所有打卡记录(管理员)
async fetchAllRecords(params = {}) {
this.loading = true
try {
const data = await checkInAPI.getAllRecords({
skip: (this.currentPage - 1) * this.pageSize,
limit: this.pageSize,
...params,
})
this.allRecords = data.records || data
this.total = data.total || this.allRecords.length
return data
} catch (error) {
throw new Error(error.message || '获取打卡记录失败')
} finally {
this.loading = false
}
},
// 统计打卡记录
async getRecordsCount(params = {}) {
try {
const count = await checkInAPI.getRecordsCount(params)
return count
} catch (error) {
throw new Error(error.message || '获取统计信息失败')
}
},
},
})
+180
View File
@@ -0,0 +1,180 @@
import { defineStore } from 'pinia'
import api from '@/api'
export const useTaskStore = defineStore('task', {
state: () => ({
tasks: [], // 当前用户的任务列表
currentTask: null, // 当前选中的任务
loading: false,
error: null,
}),
getters: {
// 启用的任务
activeTasks: (state) => state.tasks.filter(t => t.is_active),
// 禁用的任务
inactiveTasks: (state) => state.tasks.filter(t => !t.is_active),
// 任务数量统计
taskStats: (state) => ({
total: state.tasks.length,
active: state.tasks.filter(t => t.is_active).length,
inactive: state.tasks.filter(t => !t.is_active).length,
}),
// 根据 ID 获取任务
getTaskById: (state) => (taskId) => {
return state.tasks.find(t => t.id === taskId)
},
},
actions: {
// 获取当前用户的所有任务
async fetchMyTasks(includeInactive = true) {
this.loading = true
this.error = null
try {
const tasks = await api.task.getMyTasks({ include_inactive: includeInactive })
this.tasks = tasks
return tasks
} catch (error) {
this.error = error.message || '获取任务列表失败'
throw error
} finally {
this.loading = false
}
},
// 创建新任务
async createTask(taskData) {
this.loading = true
this.error = null
try {
const newTask = await api.task.createTask(taskData)
this.tasks.unshift(newTask) // 添加到列表开头
return newTask
} catch (error) {
// 解析后端错误信息
let errorMsg = error.message || '创建任务失败'
this.error = errorMsg
throw new Error(errorMsg)
} finally {
this.loading = false
}
},
// 更新任务
async updateTask(taskId, taskData) {
this.loading = true
this.error = null
try {
const updatedTask = await api.task.updateTask(taskId, taskData)
const index = this.tasks.findIndex(t => t.id === taskId)
if (index !== -1) {
this.tasks[index] = updatedTask
}
return updatedTask
} catch (error) {
this.error = error.message || '更新任务失败'
throw error
} finally {
this.loading = false
}
},
// 删除任务
async deleteTask(taskId) {
this.loading = true
this.error = null
try {
await api.task.deleteTask(taskId)
this.tasks = this.tasks.filter(t => t.id !== taskId)
} catch (error) {
this.error = error.message || '删除任务失败'
throw error
} finally {
this.loading = false
}
},
// 切换任务启用状态
async toggleTask(taskId) {
this.loading = true
this.error = null
try {
const updatedTask = await api.task.toggleTask(taskId)
const index = this.tasks.findIndex(t => t.id === taskId)
if (index !== -1) {
this.tasks[index] = updatedTask
}
return updatedTask
} catch (error) {
this.error = error.message || '切换任务状态失败'
throw error
} finally {
this.loading = false
}
},
// 获取任务详情
async fetchTask(taskId) {
this.loading = true
this.error = null
try {
const task = await api.task.getTask(taskId)
this.currentTask = task
return task
} catch (error) {
this.error = error.message || '获取任务详情失败'
throw error
} finally {
this.loading = false
}
},
// 手动触发任务打卡(异步方式,立即返回 record_id)
async checkInTask(taskId) {
// Don't set global loading state to avoid blocking UI during long check-in operations
this.error = null
try {
const result = await api.task.checkInTask(taskId)
return result
} catch (error) {
this.error = error.message || '打卡失败'
throw error
}
},
// 查询打卡记录状态
async getCheckInRecordStatus(recordId) {
try {
const result = await api.task.getCheckInRecordStatus(recordId)
return result
} catch (error) {
throw error
}
},
// 获取任务的打卡记录
async fetchTaskRecords(taskId, params = {}) {
this.loading = true
this.error = null
try {
const records = await api.task.getTaskRecords(taskId, params)
return records
} catch (error) {
this.error = error.message || '获取打卡记录失败'
throw error
} finally {
this.loading = false
}
},
// 清空当前任务
clearCurrentTask() {
this.currentTask = null
},
},
})
+162
View File
@@ -0,0 +1,162 @@
import { defineStore } from 'pinia'
import { templateAPI } from '@/api'
export const useTemplateStore = defineStore('template', {
state: () => ({
templates: [],
currentTemplate: null,
loading: false,
error: null,
}),
getters: {
activeTemplates: (state) => state.templates.filter((t) => t.is_active),
getTemplateById: (state) => (id) => {
return state.templates.find((t) => t.id === id)
},
},
actions: {
async fetchTemplates(isActive = null) {
this.loading = true
this.error = null
try {
const params = {}
if (isActive !== null) {
params.is_active = isActive
}
this.templates = await templateAPI.getTemplates(params)
return this.templates
} catch (error) {
this.error = error.message || '获取模板列表失败'
throw error
} finally {
this.loading = false
}
},
async fetchActiveTemplates() {
this.loading = true
this.error = null
try {
this.templates = await templateAPI.getActiveTemplates()
return this.templates
} catch (error) {
this.error = error.message || '获取启用模板失败'
throw error
} finally {
this.loading = false
}
},
async fetchTemplate(id) {
this.loading = true
this.error = null
try {
this.currentTemplate = await templateAPI.getTemplate(id)
return this.currentTemplate
} catch (error) {
this.error = error.message || '获取模板详情失败'
throw error
} finally {
this.loading = false
}
},
async previewTemplate(id) {
this.loading = true
this.error = null
try {
const preview = await templateAPI.previewTemplate(id)
return preview
} catch (error) {
this.error = error.message || '预览模板失败'
throw error
} finally {
this.loading = false
}
},
async createTemplate(templateData) {
this.loading = true
this.error = null
try {
const newTemplate = await templateAPI.createTemplate(templateData)
this.templates.unshift(newTemplate)
return newTemplate
} catch (error) {
this.error = error.message || '创建模板失败'
throw error
} finally {
this.loading = false
}
},
async updateTemplate(id, templateData) {
this.loading = true
this.error = null
try {
const updatedTemplate = await templateAPI.updateTemplate(id, templateData)
const index = this.templates.findIndex((t) => t.id === id)
if (index !== -1) {
this.templates[index] = updatedTemplate
}
if (this.currentTemplate && this.currentTemplate.id === id) {
this.currentTemplate = updatedTemplate
}
return updatedTemplate
} catch (error) {
this.error = error.message || '更新模板失败'
throw error
} finally {
this.loading = false
}
},
async deleteTemplate(id) {
this.loading = true
this.error = null
try {
await templateAPI.deleteTemplate(id)
this.templates = this.templates.filter((t) => t.id !== id)
if (this.currentTemplate && this.currentTemplate.id === id) {
this.currentTemplate = null
}
return true
} catch (error) {
this.error = error.message || '删除模板失败'
throw error
} finally {
this.loading = false
}
},
async createTaskFromTemplate(templateId, threadId, fieldValues, taskName = null) {
this.loading = true
this.error = null
try {
const task = await templateAPI.createTaskFromTemplate({
template_id: templateId,
thread_id: threadId,
field_values: fieldValues,
task_name: taskName,
})
return task
} catch (error) {
this.error = error.message || '从模板创建任务失败'
throw error
} finally {
this.loading = false
}
},
clearCurrentTemplate() {
this.currentTemplate = null
},
clearError() {
this.error = null
},
},
})
+90
View File
@@ -0,0 +1,90 @@
import { defineStore } from 'pinia'
import { userAPI } from '@/api'
export const useUserStore = defineStore('user', {
state: () => ({
tokenStatus: null, // Token 状态信息
users: [], // 用户列表(管理员)
currentPage: 1,
pageSize: 20,
total: 0,
}),
getters: {
isTokenExpiring: (state) => {
if (!state.tokenStatus) return false
return state.tokenStatus.expiring_soon || false
},
tokenExpireTime: (state) => {
if (!state.tokenStatus || !state.tokenStatus.expires_at) return null
return new Date(state.tokenStatus.expires_at * 1000)
},
},
actions: {
// 获取 Token 状态
async fetchTokenStatus() {
try {
const status = await userAPI.getTokenStatus()
this.tokenStatus = status
return status
} catch (error) {
throw new Error(error.message || '获取 Token 状态失败')
}
},
// 获取用户列表(管理员)
async fetchUsers(params = {}) {
try {
const data = await userAPI.getUsers(params)
this.users = data.users || data
this.total = data.total || this.users.length
return data
} catch (error) {
throw new Error(error.message || '获取用户列表失败')
}
},
// 创建用户(管理员)
async createUser(userData) {
try {
const newUser = await userAPI.createUser(userData)
// 刷新用户列表
await this.fetchUsers()
return newUser
} catch (error) {
throw new Error(error.message || '创建用户失败')
}
},
// 更新用户
async updateUser(userId, userData) {
try {
// 过滤空密码字段
const cleanedData = { ...userData }
if (cleanedData.password === '' || cleanedData.password === null || cleanedData.password === undefined) {
delete cleanedData.password
}
const updatedUser = await userAPI.updateUser(userId, cleanedData)
// 刷新用户列表
await this.fetchUsers()
return updatedUser
} catch (error) {
throw new Error(error.message || '更新用户失败')
}
},
// 删除用户
async deleteUser(userId) {
try {
await userAPI.deleteUser(userId)
// 刷新用户列表
await this.fetchUsers()
} catch (error) {
throw new Error(error.message || '删除用户失败')
}
},
},
})
+155
View File
@@ -0,0 +1,155 @@
/* TailwindCSS directives */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Global styles */
@layer base {
:root {
font-family: 'Inter', 'Segoe UI', 'Roboto', system-ui, -apple-system, sans-serif;
line-height: 1.6;
font-weight: 400;
color-scheme: light;
/* Material Design 3 color tokens */
--md-sys-color-primary: #4caf50;
--md-sys-color-on-primary: #ffffff;
--md-sys-color-secondary: #2196f3;
--md-sys-color-on-secondary: #ffffff;
--md-sys-color-surface: #fafafa;
--md-sys-color-on-surface: #1c1b1f;
--md-sys-color-background: #ffffff;
--md-sys-color-on-background: #1c1b1f;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #e8eef5 100%);
color: var(--md-sys-color-on-background);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
width: 100%;
min-height: 100vh;
}
}
/* Custom utility classes */
@layer components {
/* Material Design 3 Card */
.md3-card {
@apply bg-white rounded-md3 shadow-md3-2 overflow-hidden transition-all duration-300;
}
.md3-card:hover {
@apply shadow-md3-3;
}
.md3-card-elevated {
@apply bg-white rounded-md3-lg shadow-md3-3;
}
/* Material Design 3 Button */
.md3-button {
@apply px-6 py-2.5 rounded-full font-medium transition-all duration-200;
@apply inline-flex items-center justify-center gap-2;
}
.md3-button-filled {
@apply md3-button bg-primary-600 text-white hover:bg-primary-700 hover:shadow-md3-2;
}
.md3-button-outlined {
@apply md3-button border-2 border-primary-600 text-primary-600 hover:bg-primary-50;
}
.md3-button-text {
@apply md3-button text-primary-600 hover:bg-primary-50;
}
/* Fluent Design elements */
.fluent-card {
@apply bg-white/80 backdrop-blur-xl rounded-lg border border-gray-200/50 shadow-lg;
@apply transition-all duration-300 hover:shadow-xl hover:border-gray-300/50;
}
.fluent-acrylic {
@apply bg-white/70 backdrop-blur-2xl backdrop-saturate-150;
}
/* Status badges */
.status-badge {
@apply inline-flex items-center px-3 py-1 rounded-full text-xs font-medium;
}
.status-success {
@apply status-badge bg-green-100 text-green-800;
}
.status-warning {
@apply status-badge bg-yellow-100 text-yellow-800;
}
.status-error {
@apply status-badge bg-red-100 text-red-800;
}
.status-info {
@apply status-badge bg-blue-100 text-blue-800;
}
/* Loading skeleton */
.skeleton {
@apply animate-pulse bg-gray-200 rounded;
}
}
/* Custom animations */
@layer utilities {
.transition-smooth {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-effect {
@apply bg-white/60 backdrop-blur-md backdrop-saturate-150;
}
.text-gradient {
@apply bg-gradient-to-r from-primary-600 to-secondary-600 bg-clip-text text-transparent;
}
}
/* Element Plus customization to work with Tailwind */
.el-button {
@apply transition-smooth;
}
.el-card {
@apply transition-smooth;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-gray-100 rounded;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-300 rounded hover:bg-gray-400;
}
+145
View File
@@ -0,0 +1,145 @@
/**
* 格式化日期时间
* @param {string|Date} date - 日期
* @param {boolean} includeTime - 是否包含时间
* @returns {string}
*/
export function formatDateTime(date, includeTime = true) {
if (!date) return '-'
const d = new Date(date)
if (isNaN(d.getTime())) return '-'
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
if (!includeTime) {
return `${year}-${month}-${day}`
}
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
const seconds = String(d.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
/**
* 格式化相对时间(多久之前)
* @param {string|Date} date - 日期
* @returns {string}
*/
export function formatRelativeTime(date) {
if (!date) return '-'
const d = new Date(date)
if (isNaN(d.getTime())) return '-'
const now = new Date()
const diff = now - d // 毫秒差
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (seconds < 60) return '刚刚'
if (minutes < 60) return `${minutes} 分钟前`
if (hours < 24) return `${hours} 小时前`
if (days < 7) return `${days} 天前`
return formatDateTime(date, false)
}
/**
* 格式化文件大小
* @param {number} bytes - 字节数
* @returns {string}
*/
export function formatFileSize(bytes) {
if (!bytes || bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
}
/**
* 防抖函数
* @param {Function} fn - 要防抖的函数
* @param {number} delay - 延迟时间(毫秒)
* @returns {Function}
*/
export function debounce(fn, delay = 300) {
let timer = null
return function (...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}
/**
* 节流函数
* @param {Function} fn - 要节流的函数
* @param {number} delay - 延迟时间(毫秒)
* @returns {Function}
*/
export function throttle(fn, delay = 300) {
let timer = null
let lastTime = 0
return function (...args) {
const now = Date.now()
if (now - lastTime < delay) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
lastTime = now
fn.apply(this, args)
}, delay)
} else {
lastTime = now
fn.apply(this, args)
}
}
}
/**
* 复制文本到剪贴板
* @param {string} text - 要复制的文本
* @returns {Promise<boolean>}
*/
export async function copyToClipboard(text) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
return true
} else {
// 降级方案
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
textArea.remove()
return true
} catch (error) {
console.error('复制失败', error)
textArea.remove()
return false
}
}
} catch (error) {
console.error('复制失败', error)
return false
}
}
+374
View File
@@ -0,0 +1,374 @@
<template>
<Layout>
<div class="dashboard-container">
<el-row :gutter="20">
<!-- Token 状态卡片 -->
<el-col :span="24">
<el-card class="status-card">
<template #header>
<div class="card-header">
<el-icon><Key /></el-icon>
<span>Token 状态</span>
</div>
</template>
<div v-if="tokenStatusLoading" class="loading-container">
<el-skeleton :rows="3" animated />
</div>
<div v-else-if="tokenStatus" class="token-status">
<el-descriptions :column="2" border>
<el-descriptions-item label="Token 状态">
<el-tag :type="tokenStatus.is_valid ? 'success' : 'danger'">
{{ tokenStatus.is_valid ? '有效' : '无效' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="过期时间">
{{ formatExpireTime }}
</el-descriptions-item>
<el-descriptions-item label="剩余时间">
<el-tag v-if="tokenStatus.is_valid" :type="tokenStatus.expiring_soon ? 'warning' : 'success'">
{{ formatRemainTime }}
</el-tag>
<el-tag v-else type="danger">已过期</el-tag>
</el-descriptions-item>
<el-descriptions-item label="即将过期">
<el-tag :type="tokenStatus.expiring_soon ? 'warning' : 'success'">
{{ tokenStatus.expiring_soon ? '是' : '否' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<el-alert
v-if="tokenStatus.expiring_soon"
title="Token 即将过期"
type="warning"
:closable="false"
show-icon
style="margin-top: 15px"
>
您的 Token 将在 30 分钟内过期请及时重新登录
</el-alert>
</div>
</el-card>
</el-col>
<!-- 手动打卡卡片 -->
<el-col :span="24" style="margin-top: 20px">
<el-card>
<template #header>
<div class="card-header">
<el-icon><Calendar /></el-icon>
<span>手动打卡</span>
</div>
</template>
<div class="check-in-container">
<p class="hint">选择任务并点击下方按钮立即执行打卡操作</p>
<!-- 任务选择 -->
<el-select
v-model="selectedTaskId"
placeholder="请选择要打卡的任务"
style="width: 100%; max-width: 400px; margin-bottom: 20px"
>
<el-option
v-for="task in taskStore.activeTasks"
:key="task.id"
:label="task.name"
:value="task.id"
>
<div style="display: flex; justify-content: space-between">
<span>{{ task.name }}</span>
<el-tag size="small" type="success">启用</el-tag>
</div>
</el-option>
</el-select>
<el-button
type="primary"
size="large"
:loading="checkInLoading"
:disabled="!selectedTaskId"
:icon="Calendar"
@click="handleCheckIn"
>
{{ checkInLoading ? '打卡中...' : '立即打卡' }}
</el-button>
<div v-if="lastCheckIn" class="last-check-in">
<el-divider />
<p class="label">上次打卡</p>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="时间">
{{ formatDateTime(lastCheckIn.check_in_time) }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag
:type="lastCheckIn.status === 'success' ? 'success' :
lastCheckIn.status === 'out_of_time' ? 'info' :
lastCheckIn.status === 'unknown' ? 'warning' : 'danger'"
>
{{
lastCheckIn.status === 'success' ? '成功' :
lastCheckIn.status === 'out_of_time' ? '时间范围外' :
lastCheckIn.status === 'unknown' ? '异常' : '失败'
}}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="打卡响应" :span="2">
{{ lastCheckIn.response_text || lastCheckIn.error_message || '-' }}
</el-descriptions-item>
</el-descriptions>
</div>
</div>
</el-card>
</el-col>
<!-- 用户信息卡片 -->
<el-col :span="24" style="margin-top: 20px">
<el-card>
<template #header>
<div class="card-header">
<el-icon><User /></el-icon>
<span>个人信息</span>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="用户名">
{{ authStore.user?.alias }}
</el-descriptions-item>
<el-descriptions-item label="角色">
<el-tag :type="authStore.isAdmin ? 'danger' : 'primary'">
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="邮箱" :span="2">
{{ authStore.user?.email || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="注册时间" :span="2">
{{ formatDateTime(authStore.user?.created_at, false) }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</div>
</Layout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Calendar, Key, User } from '@element-plus/icons-vue'
import Layout from '@/components/Layout.vue'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { useTaskStore } from '@/stores/task'
import { useCheckInStore } from '@/stores/checkIn'
import { formatDateTime } from '@/utils/helpers'
const authStore = useAuthStore()
const userStore = useUserStore()
const taskStore = useTaskStore()
const checkInStore = useCheckInStore()
const tokenStatusLoading = ref(false)
const checkInLoading = ref(false)
const selectedTaskId = ref(null)
const tokenStatus = computed(() => userStore.tokenStatus)
const lastCheckIn = computed(() => {
if (checkInStore.myRecords.length > 0) {
return checkInStore.myRecords[0]
}
return null
})
const formatExpireTime = computed(() => {
if (!tokenStatus.value || !tokenStatus.value.expires_at) return '-'
return formatDateTime(tokenStatus.value.expires_at * 1000)
})
const formatRemainTime = computed(() => {
if (!tokenStatus.value || !tokenStatus.value.expires_at) return '-'
const now = Date.now()
const expireTime = tokenStatus.value.expires_at * 1000
const diff = expireTime - now
if (diff <= 0) return '已过期'
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
if (days > 0) return `${days}${hours} 小时`
if (hours > 0) return `${hours} 小时 ${minutes} 分钟`
return `${minutes} 分钟`
})
// 获取 Token 状态
const fetchTokenStatus = async () => {
tokenStatusLoading.value = true
try {
await userStore.fetchTokenStatus()
} catch (error) {
ElMessage.error(error.message || '获取 Token 状态失败')
} finally {
tokenStatusLoading.value = false
}
}
// 手动打卡
const handleCheckIn = async () => {
if (!selectedTaskId.value) {
ElMessage.warning('请先选择要打卡的任务')
return
}
checkInLoading.value = true
try {
// 调用异步打卡接口,立即返回 record_id
const result = await taskStore.checkInTask(selectedTaskId.value)
// 获取 record_id
const recordId = result.record_id
if (!recordId) {
ElMessage.error('打卡请求失败:未获取到记录ID')
checkInLoading.value = false
return
}
// 如果初始状态就是失败,显示错误并刷新记录
if (result.status === 'failure') {
ElMessage.error(result.message || '打卡失败')
checkInLoading.value = false
checkInStore.fetchMyRecords({ limit: 1 })
return
}
// 显示提示消息
ElMessage.info('打卡任务已启动,正在后台处理...')
// 用于存储 interval ID,以便在超时时清理
let pollIntervalId = null
// 开始轮询检查打卡状态
pollIntervalId = setInterval(async () => {
try {
const status = await taskStore.getCheckInRecordStatus(recordId)
// 只要状态不是 pending,说明打卡请求已经处理完成
if (status.status !== 'pending') {
clearInterval(pollIntervalId)
checkInLoading.value = false
if (status.status === 'success') {
// 打卡成功
ElMessage.success('打卡成功!')
checkInStore.fetchMyRecords({ limit: 1 })
} else {
// 打卡失败或其他状态 (failure, out_of_time, unknown 等)
const errorMsg = status.error_message || status.response_text || '打卡失败'
ElMessage.error(errorMsg)
checkInStore.fetchMyRecords({ limit: 1 })
}
}
// status === 'pending' 时继续轮询
} catch (error) {
// 查询状态失败,停止轮询
console.error('轮询状态失败:', error)
clearInterval(pollIntervalId)
checkInLoading.value = false
ElMessage.error('查询打卡状态失败')
}
}, 2000) // 每 2 秒查询一次
// 设置超时保护(30 秒后停止轮询)
setTimeout(() => {
if (checkInLoading.value) {
clearInterval(pollIntervalId)
checkInLoading.value = false
ElMessage.warning('打卡处理时间较长,请稍后查看打卡记录')
}
}, 30000)
} catch (error) {
console.error('启动打卡失败:', error)
checkInLoading.value = false
ElMessage.error(error.message || '启动打卡任务失败')
}
}
onMounted(async () => {
fetchTokenStatus()
checkInStore.fetchMyRecords({ limit: 1 })
// 加载任务列表
try {
await taskStore.fetchMyTasks()
// 如果只有一个启用的任务,自动选中
if (taskStore.activeTasks.length === 1) {
selectedTaskId.value = taskStore.activeTasks[0].id
}
} catch (error) {
ElMessage.error('加载任务列表失败')
}
})
</script>
<style scoped>
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: bold;
}
.loading-container {
padding: 20px;
}
.token-status {
padding: 10px 0;
}
.check-in-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.hint {
margin-bottom: 20px;
color: #909399;
font-size: 14px;
}
.last-check-in {
width: 100%;
margin-top: 20px;
}
.label {
font-weight: bold;
margin-bottom: 10px;
color: #606266;
}
.status-card {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
</style>
+379
View File
@@ -0,0 +1,379 @@
<template>
<div class="login-container">
<el-card class="login-card">
<template #header>
<div class="card-header">
<h2>接龙自动打卡系统</h2>
<p class="subtitle">{{ loginMode === 'qrcode' ? 'QQ 扫码登录/注册' : '用户名密码登录' }}</p>
</div>
</template>
<!-- 登录模式切换 -->
<div class="mode-switch">
<el-segmented v-model="loginMode" :options="loginModeOptions" block />
</div>
<!-- QR码登录表单 -->
<el-form
v-if="loginMode === 'qrcode'"
:model="qrcodeForm"
:rules="qrcodeRules"
ref="qrcodeFormRef"
label-width="0"
@submit.prevent="handleQRCodeLogin"
>
<el-form-item prop="alias">
<el-input
v-model="qrcodeForm.alias"
placeholder="请输入您的用户名"
size="large"
clearable
@keyup.enter="handleQRCodeLogin"
>
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="login-button"
:loading="loading"
@click="handleQRCodeLogin"
>
{{ loading ? '正在登录...' : '扫码登录/注册' }}
</el-button>
</el-form-item>
</el-form>
<!-- 别名+密码登录表单 -->
<el-form
v-else
:model="passwordForm"
:rules="passwordRules"
ref="passwordFormRef"
label-width="0"
>
<el-form-item prop="alias">
<el-input
v-model="passwordForm.alias"
placeholder="请输入您的用户名"
size="large"
clearable
>
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="passwordForm.password"
type="password"
placeholder="请输入密码"
size="large"
show-password
clearable
@keyup.enter="handlePasswordLogin"
>
<template #prefix>
<el-icon><Key /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="login-button"
:loading="loading"
@click="handlePasswordLogin"
>
{{ loading ? '登录中...' : '登录' }}
</el-button>
</el-form-item>
<div class="tips-link">
<el-link type="info" @click="loginMode = 'qrcode'">
没有密码使用扫码登录
</el-link>
</div>
</el-form>
<div class="tips">
<el-alert
:title="loginMode === 'qrcode' ? '扫码登录提示' : '密码登录提示'"
type="info"
:closable="false"
show-icon
>
<template v-if="loginMode === 'qrcode'">
<p>1. 输入您的用户名用于标识身份</p>
<p>2. 点击"扫码登录/注册"按钮</p>
<p>3. 使用手机 QQ 扫描弹出的二维码</p>
<p>4. 扫码成功后即可登录系统</p>
<p class="tip-note">💡 新用户首次扫码将自动注册账户</p>
</template>
<template v-else>
<p>1. 输入您的用户名和密码</p>
<p>2. 点击"登录"按钮直接登录</p>
<p>3. 首次使用请先扫码登录/注册然后在设置中设置密码</p>
</template>
</el-alert>
</div>
</el-card>
<!-- QR 码弹窗 -->
<QRCodeModal
v-model:visible="qrcodeVisible"
:alias="qrcodeForm.alias"
@success="handleLoginSuccess"
@error="handleLoginError"
/>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Key } from '@element-plus/icons-vue'
import { authAPI } from '@/api'
import { useAuthStore } from '@/stores/auth'
import QRCodeModal from '@/components/QRCodeModal.vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const qrcodeFormRef = ref(null)
const passwordFormRef = ref(null)
const loading = ref(false)
const qrcodeVisible = ref(false)
// 登录模式
const loginMode = ref('qrcode')
const loginModeOptions = [
{ label: '扫码登录', value: 'qrcode' },
{ label: '密码登录', value: 'password' }
]
// 监听登录模式切换,同步用户名
watch(loginMode, () => {
// 从密码登录切换到扫码登录
if (loginMode.value === 'qrcode' && passwordForm.value.alias) {
qrcodeForm.value.alias = passwordForm.value.alias
}
// 从扫码登录切换到密码登录
else if (loginMode.value === 'password' && qrcodeForm.value.alias) {
passwordForm.value.alias = qrcodeForm.value.alias
}
})
// QR码登录表单
const qrcodeForm = ref({
alias: '',
})
const qrcodeRules = {
alias: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
],
}
// 密码登录表单
const passwordForm = ref({
alias: '',
password: '',
})
const passwordRules = {
alias: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6个字符', trigger: 'blur' },
],
}
// QR码登录
const handleQRCodeLogin = async () => {
if (!qrcodeFormRef.value) return
try {
const valid = await qrcodeFormRef.value.validate()
if (!valid) return
// 显示 QR 码弹窗
qrcodeVisible.value = true
} catch (error) {
console.error('表单验证失败:', error)
}
}
// 密码登录
const handlePasswordLogin = async () => {
if (!passwordFormRef.value) return
try {
const valid = await passwordFormRef.value.validate()
if (!valid) return
loading.value = true
const response = await authAPI.aliasLogin(
passwordForm.value.alias,
passwordForm.value.password
)
if (response.success) {
// 使用 authStore 保存认证信息
const user = {
id: response.user_id,
alias: response.alias,
role: response.role || 'user',
is_approved: response.is_approved !== false,
}
authStore.setAuth(response.authorization, user)
// 如果有 Token 警告,显示提示
if (response.token_warning && response.warning_message) {
ElMessage({
type: 'warning',
duration: 5000,
showClose: true,
message: response.warning_message,
})
} else {
ElMessage.success(`欢迎回来,${response.alias}`)
}
// 跳转到重定向页面或仪表盘
const redirect = route.query.redirect || '/dashboard'
router.push(redirect)
} else {
// 根据不同错误类型提供友好提示
handlePasswordLoginError(response.message)
}
} catch (error) {
console.error('密码登录失败:', error)
const errorMsg = error.response?.data?.detail || error.message || '登录失败,请稍后重试'
handlePasswordLoginError(errorMsg)
} finally {
loading.value = false
}
}
// 处理密码登录错误
const handlePasswordLoginError = (message) => {
if (!message) {
ElMessage.error('登录失败,请稍后重试')
return
}
// 用户不存在或密码错误
if (message.includes('用户名或密码错误')) {
ElMessage.error('用户名或密码错误')
return
}
// 未设置密码
if (message.includes('未设置密码')) {
ElMessage.warning('该账户未设置密码,请使用扫码登录')
return
}
// 用户不存在
if (message.includes('用户不存在')) {
ElMessage.error('用户不存在,请检查用户名或使用扫码登录注册')
return
}
// 其他错误
ElMessage.error(message || '登录失败,请稍后重试')
}
const handleLoginSuccess = (user) => {
ElMessage.success(`欢迎回来,${user.alias}`)
// 跳转到重定向页面或仪表盘
const redirect = route.query.redirect || '/dashboard'
router.push(redirect)
}
const handleLoginError = (error) => {
ElMessage.error(error.message || '登录失败')
}
</script>
<style scoped>
.login-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 450px;
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.card-header {
text-align: center;
}
.card-header h2 {
margin: 0;
font-size: 24px;
color: #303133;
}
.subtitle {
margin: 10px 0 0 0;
font-size: 14px;
color: #909399;
}
.mode-switch {
margin-bottom: 20px;
}
.login-button {
width: 100%;
}
.tips-link {
text-align: center;
margin-top: 10px;
}
.tips {
margin-top: 20px;
}
.tips p {
margin: 5px 0;
font-size: 14px;
line-height: 1.5;
}
.tip-note {
margin-top: 12px !important;
padding-top: 8px;
border-top: 1px dashed #e0e0e0;
color: #606266;
font-weight: 500;
}
</style>
+30
View File
@@ -0,0 +1,30 @@
<template>
<div class="not-found-container">
<el-result icon="warning" title="404" sub-title="抱歉您访问的页面不存在">
<template #extra>
<el-button type="primary" @click="goHome">返回首页</el-button>
</template>
</el-result>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const goHome = () => {
router.push('/')
}
</script>
<style scoped>
.not-found-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
}
</style>
+269
View File
@@ -0,0 +1,269 @@
<template>
<div class="pending-container">
<div class="pending-card">
<div class="card-header">
<h2>🕐 等待审批</h2>
</div>
<div class="pending-content">
<div class="result-icon">
<span class="info-icon"></span>
</div>
<h3 class="result-title">您的账户正在等待管理员审批</h3>
<div class="result-subtitle">
<p>您已成功注册账户信息如下</p>
</div>
<div class="info-table">
<div class="info-row">
<div class="info-label">用户名</div>
<div class="info-value">{{ user?.alias || '加载中...' }}</div>
</div>
<div class="info-row">
<div class="info-label">注册时间</div>
<div class="info-value">{{ formatDate(user?.created_at) }}</div>
</div>
<div class="info-row">
<div class="info-label">审批状态</div>
<div class="info-value">
<span class="status-tag warning">待审批</span>
</div>
</div>
</div>
<div class="alert-box">
<div class="alert-title"> 审批说明</div>
<ul class="tips-list">
<li>管理员将在 <strong>24 小时内</strong> 审核您的注册申请</li>
<li>审核通过后您将可以使用所有功能</li>
<li>如超过 24 小时未审批账户将被自动删除</li>
<li>您可以随时刷新此页面查看最新状态</li>
</ul>
</div>
<div class="actions">
<button class="btn btn-primary" @click="checkStatus">
刷新状态
</button>
<button class="btn btn-default" @click="logout">
退出登录
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { userAPI } from '@/api'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const user = ref(null)
const checkStatus = async () => {
try {
const response = await userAPI.getUserStatus()
user.value = response
if (response.is_approved) {
alert('恭喜!您的账户已通过审批')
router.push('/dashboard')
} else {
alert('仍在等待审批中')
}
} catch (error) {
console.error('获取状态失败:', error)
alert('获取状态失败:' + (error.message || '未知错误'))
}
}
const logout = () => {
authStore.logout()
router.push('/login')
}
const formatDate = (dateStr) => {
if (!dateStr) return '未知'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN')
}
onMounted(() => {
checkStatus()
})
</script>
<style scoped>
.pending-container {
width: 100%;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.pending-card {
width: 100%;
max-width: 700px;
background: white;
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.card-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.card-header h2 {
margin: 0;
font-size: 28px;
font-weight: 600;
}
.pending-content {
padding: 40px;
}
.result-icon {
text-align: center;
margin-bottom: 20px;
}
.info-icon {
font-size: 64px;
display: inline-block;
}
.result-title {
text-align: center;
font-size: 24px;
font-weight: 600;
color: #303133;
margin: 0 0 10px 0;
}
.result-subtitle {
text-align: center;
color: #606266;
margin-bottom: 30px;
}
.info-table {
background: #f9f9f9;
border: 1px solid #ddd;
border-radius: 5px;
overflow: hidden;
margin-bottom: 30px;
}
.info-row {
display: flex;
border-bottom: 1px solid #ddd;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
flex: 0 0 120px;
padding: 15px 20px;
background: #f5f5f5;
font-weight: bold;
color: #303133;
border-right: 1px solid #ddd;
}
.info-value {
flex: 1;
padding: 15px 20px;
color: #606266;
}
.status-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
}
.status-tag.warning {
background: #fff3cd;
color: #856404;
border: 1px solid #ffc107;
}
.alert-box {
background: #e7f3ff;
border-left: 4px solid #409eff;
padding: 20px;
margin-bottom: 30px;
border-radius: 4px;
}
.alert-title {
font-weight: bold;
margin-bottom: 10px;
color: #303133;
}
.tips-list {
text-align: left;
padding-left: 20px;
line-height: 1.8;
margin: 0;
color: #606266;
}
.tips-list li {
margin: 8px 0;
}
.actions {
display: flex;
gap: 15px;
justify-content: center;
}
.btn {
padding: 12px 30px;
border: none;
border-radius: 5px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: #409eff;
color: white;
}
.btn-primary:hover {
background: #66b1ff;
}
.btn-default {
background: #f5f5f5;
color: #606266;
border: 1px solid #dcdfe6;
}
.btn-default:hover {
background: #e8e8e8;
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<template>
<Layout>
<div class="records-container">
<el-card>
<template #header>
<div class="card-header">
<div>
<el-icon><List /></el-icon>
<span>我的打卡记录</span>
</div>
<el-button type="primary" :icon="Refresh" @click="handleRefresh">
刷新
</el-button>
</div>
</template>
<!-- 统计信息 -->
<div class="stats-container">
<el-row :gutter="20">
<el-col :span="8">
<el-statistic title="总打卡次数" :value="total" />
</el-col>
<el-col :span="8">
<el-statistic
title="成功次数"
:value="successCount"
value-style="color: #67c23a"
/>
</el-col>
<el-col :span="8">
<el-statistic
title="成功率"
:value="parseFloat(checkInStore.successRate)"
suffix="%"
:precision="2"
/>
</el-col>
</el-row>
</div>
<el-divider />
<!-- 记录表格 -->
<el-table
:data="checkInStore.myRecords"
v-loading="checkInStore.loading"
stripe
border
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="check_in_time" label="打卡时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.check_in_time) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag v-if="row.status === 'success'" type="success"> 打卡成功</el-tag>
<el-tag v-else-if="row.status === 'out_of_time'" type="info">🕐 时间范围外</el-tag>
<el-tag v-else-if="row.status === 'unknown'" type="warning"> 打卡异常</el-tag>
<el-tag v-else type="danger"> 打卡失败</el-tag>
</template>
</el-table-column>
<el-table-column prop="trigger_type" label="触发方式" width="120">
<template #default="{ row }">
<el-tag v-if="row.trigger_type === 'manual'" type="primary">手动</el-tag>
<el-tag v-else-if="row.trigger_type === 'scheduled'" type="info">定时</el-tag>
<el-tag v-else-if="row.trigger_type === 'admin'" type="warning">管理员</el-tag>
<el-tag v-else>{{ row.trigger_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="response_text" label="消息" min-width="200" show-overflow-tooltip />
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="checkInStore.currentPage"
v-model:page-size="checkInStore.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</el-card>
</div>
</Layout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { List, Refresh } from '@element-plus/icons-vue'
import Layout from '@/components/Layout.vue'
import { useCheckInStore } from '@/stores/checkIn'
import { formatDateTime } from '@/utils/helpers'
const checkInStore = useCheckInStore()
const total = computed(() => checkInStore.total)
const successCount = computed(() => {
return checkInStore.myRecords.filter((r) => r.status === 'success').length
})
// 刷新数据
const handleRefresh = async () => {
try {
await checkInStore.fetchMyRecords()
ElMessage.success('刷新成功')
} catch (error) {
ElMessage.error(error.message || '刷新失败')
}
}
// 页码改变
const handlePageChange = () => {
checkInStore.fetchMyRecords()
}
// 每页数量改变
const handleSizeChange = () => {
checkInStore.currentPage = 1
checkInStore.fetchMyRecords()
}
onMounted(() => {
checkInStore.fetchMyRecords()
})
</script>
<style scoped>
.records-container {
max-width: 1400px;
margin: 0 auto;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header > div {
display: flex;
align-items: center;
gap: 8px;
font-weight: bold;
}
.stats-container {
padding: 20px 0;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
+312
View File
@@ -0,0 +1,312 @@
<template>
<Layout>
<div class="min-h-screen bg-gradient-to-br from-blue-50 via-white to-green-50 p-6">
<div class="max-w-4xl mx-auto">
<h1 class="text-3xl font-bold text-gray-800 mb-6">个人设置</h1>
<!-- 基本信息卡片 -->
<div class="md3-card p-6 mb-6">
<h2 class="text-xl font-bold text-gray-800 mb-4 flex items-center">
<el-icon class="mr-2"><User /></el-icon>
基本信息
</h2>
<el-descriptions :column="1" border>
<el-descriptions-item label="用户ID">{{ user?.id }}</el-descriptions-item>
<el-descriptions-item label="当前别名">{{ user?.alias }}</el-descriptions-item>
<el-descriptions-item label="角色">
<el-tag :type="user?.role === 'admin' ? 'danger' : 'success'">
{{ user?.role === 'admin' ? '管理员' : '普通用户' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="密码状态">
<el-tag :type="hasPassword ? 'success' : 'warning'">
{{ hasPassword ? '已设置' : '未设置' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDate(user?.created_at) }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 修改邮箱 -->
<div class="md3-card p-6 mb-6">
<h2 class="text-xl font-bold text-gray-800 mb-4 flex items-center">
<el-icon class="mr-2"><Edit /></el-icon>
修改个人信息
</h2>
<el-form
:model="profileForm"
:rules="profileRules"
ref="profileFormRef"
label-width="100px"
>
<el-form-item label="邮箱" prop="email">
<el-input
v-model="profileForm.email"
placeholder="请输入邮箱地址(可选)"
clearable
/>
</el-form-item>
<el-alert
title="用户名无法修改"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
>
<p>用户名只能由管理员修改如需修改请联系管理员</p>
</el-alert>
<el-form-item>
<el-button
type="primary"
:loading="profileLoading"
@click="handleUpdateProfile"
>
保存
</el-button>
<el-button @click="resetProfileForm">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 设置/修改密码 -->
<div class="md3-card p-6">
<h2 class="text-xl font-bold text-gray-800 mb-4 flex items-center">
<el-icon class="mr-2"><Key /></el-icon>
{{ hasPassword ? '修改密码' : '设置密码' }}
</h2>
<el-alert
v-if="!hasPassword"
title="您还未设置密码"
type="warning"
description="设置密码后,您可以使用别名+密码的方式快速登录"
class="mb-4"
show-icon
:closable="false"
/>
<el-form
:model="passwordForm"
label-width="120px"
>
<el-form-item
v-if="hasPassword"
label="当前密码"
>
<el-input
v-model="passwordForm.currentPassword"
type="password"
placeholder="请输入当前密码"
show-password
clearable
/>
</el-form-item>
<el-form-item label="新密码">
<el-input
v-model="passwordForm.newPassword"
type="password"
placeholder="请输入新密码(至少6个字符)"
show-password
clearable
/>
</el-form-item>
<el-form-item label="确认新密码">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
placeholder="请再次输入新密码"
show-password
clearable
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="passwordLoading"
@click="handleUpdatePassword"
>
{{ hasPassword ? '修改密码' : '设置密码' }}
</el-button>
<el-button @click="resetPasswordForm">重置</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
</Layout>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { User, Edit, Key } from '@element-plus/icons-vue'
import { userAPI } from '@/api'
import Layout from '@/components/Layout.vue'
const profileFormRef = ref(null)
const profileLoading = ref(false)
const passwordLoading = ref(false)
const user = ref(null)
const hasPassword = ref(false)
// 个人信息表单
const profileForm = ref({
email: '',
})
const profileRules = {
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
],
}
// 密码表单
const passwordForm = ref({
currentPassword: '',
newPassword: '',
confirmPassword: '',
})
// 加载用户信息
const loadUserInfo = async () => {
try {
user.value = await userAPI.getCurrentUser()
profileForm.value.email = user.value.email || ''
// 从后端返回的数据中获取密码状态
hasPassword.value = user.value.has_password || false
} catch (error) {
ElMessage.error(error.message || '加载用户信息失败')
}
}
// 更新个人信息
const handleUpdateProfile = async () => {
if (!profileFormRef.value) return
try {
await profileFormRef.value.validate()
profileLoading.value = true
await userAPI.updateProfile({
email: profileForm.value.email || null,
})
ElMessage.success('个人信息修改成功')
await loadUserInfo()
} catch (error) {
if (error.errors) return // 验证错误
const errorMsg = error.response?.data?.detail || error.message || '修改失败'
ElMessage.error(errorMsg)
} finally {
profileLoading.value = false
}
}
// 重置个人信息表单
const resetProfileForm = () => {
profileForm.value.email = user.value?.email || ''
profileFormRef.value?.clearValidate()
}
// 更新密码
const handleUpdatePassword = async () => {
try {
// 手动验证
if (hasPassword.value && !passwordForm.value.currentPassword) {
ElMessage.error('请输入当前密码')
return
}
if (!passwordForm.value.newPassword) {
ElMessage.error('请输入新密码')
return
}
if (passwordForm.value.newPassword.length < 6) {
ElMessage.error('密码至少需要6个字符')
return
}
if (!passwordForm.value.confirmPassword) {
ElMessage.error('请再次输入新密码')
return
}
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
ElMessage.error('两次输入的密码不一致')
return
}
passwordLoading.value = true
const updateData = {
new_password: passwordForm.value.newPassword,
}
if (hasPassword.value) {
updateData.current_password = passwordForm.value.currentPassword
}
await userAPI.updateProfile(updateData)
ElMessage.success(hasPassword.value ? '密码修改成功' : '密码设置成功')
hasPassword.value = true
resetPasswordForm()
} catch (error) {
const errorMsg = error.response?.data?.detail || error.message || '操作失败'
ElMessage.error(errorMsg)
} finally {
passwordLoading.value = false
}
}
// 重置密码表单
const resetPasswordForm = () => {
passwordForm.value = {
currentPassword: '',
newPassword: '',
confirmPassword: '',
}
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
onMounted(() => {
loadUserInfo()
})
</script>
<style scoped>
.md3-card {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px 1px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
}
.md3-card:hover {
box-shadow: 0 4px 8px 3px rgba(0, 0, 0, 0.10);
}
</style>
+358
View File
@@ -0,0 +1,358 @@
<template>
<Layout>
<div class="min-h-screen bg-gradient-to-br from-purple-50 via-white to-blue-50 p-6">
<div class="max-w-7xl mx-auto">
<!-- Header -->
<div class="mb-8 animate-fade-in">
<button
@click="router.back()"
class="mb-4 flex items-center text-gray-600 hover:text-gray-900 transition-colors"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
返回任务列表
</button>
<div v-if="currentTask" class="fluent-card p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<h1 class="text-3xl font-bold text-gradient mb-2">{{ currentTask.name || '未命名任务' }}</h1>
<div class="flex items-center gap-4 text-sm text-gray-600">
<span class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
接龙 ID: {{ currentTask.thread_id }}
</span>
<span :class="currentTask.is_active ? 'status-success' : 'status-info'">
{{ currentTask.is_active ? '启用中' : '已禁用' }}
</span>
</div>
</div>
<button
@click="handleManualCheckIn"
:disabled="checkInLoading"
class="md3-button-filled"
>
{{ checkInLoading ? '打卡中...' : '立即打卡' }}
</button>
</div>
</div>
</div>
<!-- Stats Summary -->
<div class="grid grid-cols-1 md:grid-cols-6 gap-4 mb-6">
<div class="fluent-card p-5 animate-slide-up">
<p class="text-sm text-gray-600 mb-1">总打卡次数</p>
<p class="text-2xl font-bold text-gray-800">{{ recordStats.total }}</p>
</div>
<div class="fluent-card p-5 animate-slide-up" style="animation-delay: 0.05s">
<p class="text-sm text-gray-600 mb-1">成功次数</p>
<p class="text-2xl font-bold text-green-600">{{ recordStats.success }}</p>
</div>
<div class="fluent-card p-5 animate-slide-up" style="animation-delay: 0.1s">
<p class="text-sm text-gray-600 mb-1">时间范围外</p>
<p class="text-2xl font-bold text-blue-600">{{ recordStats.outOfTime }}</p>
</div>
<div class="fluent-card p-5 animate-slide-up" style="animation-delay: 0.15s">
<p class="text-sm text-gray-600 mb-1">失败次数</p>
<p class="text-2xl font-bold text-red-600">{{ recordStats.failure }}</p>
</div>
<div class="fluent-card p-5 animate-slide-up" style="animation-delay: 0.2s">
<p class="text-sm text-gray-600 mb-1">异常次数</p>
<p class="text-2xl font-bold text-orange-600">{{ recordStats.unknown }}</p>
</div>
<div class="fluent-card p-5 animate-slide-up" style="animation-delay: 0.25s">
<p class="text-sm text-gray-600 mb-1">成功率</p>
<p class="text-2xl font-bold text-purple-600">{{ recordStats.successRate }}%</p>
</div>
</div>
<!-- Filters -->
<div class="fluent-card p-4 mb-6">
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700">状态筛选:</span>
<el-radio-group v-model="filterStatus" size="small" @change="handleFilterChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="success">成功</el-radio-button>
<el-radio-button label="out_of_time">时间范围外</el-radio-button>
<el-radio-button label="failure">失败</el-radio-button>
<el-radio-button label="unknown">异常</el-radio-button>
</el-radio-group>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700">触发方式:</span>
<el-radio-group v-model="filterTrigger" size="small" @change="handleFilterChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="scheduler">自动</el-radio-button>
<el-radio-button label="manual">手动</el-radio-button>
</el-radio-group>
</div>
<div class="flex-1"></div>
<el-button size="small" @click="fetchRecords">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
刷新
</el-button>
</div>
</div>
<!-- Records List -->
<div v-if="loading" class="space-y-4">
<div v-for="i in 5" :key="i" class="fluent-card p-6">
<div class="skeleton h-6 w-1/4 mb-3"></div>
<div class="skeleton h-4 w-full mb-2"></div>
<div class="skeleton h-4 w-3/4"></div>
</div>
</div>
<div v-else-if="records.length === 0" class="fluent-card p-12 text-center">
<svg class="w-20 h-20 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="text-xl font-semibold text-gray-700 mb-2">暂无打卡记录</h3>
<p class="text-gray-500">当前筛选条件下没有找到任何打卡记录</p>
</div>
<div v-else class="space-y-4">
<div
v-for="record in records"
:key="record.id"
class="fluent-card p-6 hover:shadow-xl transition-all animate-slide-up"
>
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-lg font-semibold text-gray-800">
打卡记录 #{{ record.id }}
</h3>
<span
v-if="record.status === 'success'"
class="status-success"
> 打卡成功</span>
<span
v-else-if="record.status === 'out_of_time'"
class="status-info"
>🕐 时间范围外</span>
<span
v-else-if="record.status === 'unknown'"
class="status-warning"
> 打卡异常</span>
<span
v-else
class="status-error"
> 打卡失败</span>
<span :class="record.trigger_type === 'scheduler' ? 'status-info' : 'status-warning'">
{{ record.trigger_type === 'scheduler' ? '自动触发' : '手动触发' }}
</span>
</div>
<div class="flex items-center text-sm text-gray-600">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ formatDateTime(record.check_in_time) }}
</div>
</div>
</div>
<!-- Record Details -->
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
<div v-if="record.response_text" class="flex items-start">
<span class="text-sm font-medium text-gray-700 w-20">响应:</span>
<span class="text-sm text-gray-900 flex-1">{{ record.response_text }}</span>
</div>
<div v-if="record.error_message" class="flex items-start">
<span class="text-sm font-medium text-red-700 w-20">错误:</span>
<span class="text-sm text-red-600 flex-1">{{ record.error_message }}</span>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div v-if="!loading && records.length > 0" class="mt-6 flex justify-center">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</div>
</Layout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import Layout from '@/components/Layout.vue'
import { useTaskStore } from '@/stores/task'
import { formatDateTime } from '@/utils/helpers'
const route = useRoute()
const router = useRouter()
const taskStore = useTaskStore()
const taskId = computed(() => parseInt(route.params.taskId))
const currentTask = ref(null)
const records = ref([])
const loading = ref(false)
const checkInLoading = ref(false)
// Pagination
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
// Filters
const filterStatus = ref('')
const filterTrigger = ref('')
// Stats
const recordStats = computed(() => {
const success = records.value.filter(r => r.status === 'success').length
const outOfTime = records.value.filter(r => r.status === 'out_of_time').length
const failure = records.value.filter(r => r.status === 'failure').length
const unknown = records.value.filter(r => r.status === 'unknown').length
const totalRecords = records.value.length
const successRate = totalRecords > 0 ? Math.round((success / totalRecords) * 100) : 0
return {
total: totalRecords,
success,
outOfTime,
failure,
unknown,
successRate,
}
})
// 获取任务详情
const fetchTaskDetail = async () => {
try {
currentTask.value = await taskStore.fetchTask(taskId.value)
} catch (error) {
ElMessage.error(error.message || '获取任务详情失败')
router.push('/tasks')
}
}
// 获取打卡记录
const fetchRecords = async () => {
loading.value = true
try {
const params = {
skip: (currentPage.value - 1) * pageSize.value,
limit: pageSize.value,
}
if (filterStatus.value) {
params.status = filterStatus.value
}
if (filterTrigger.value) {
params.trigger_type = filterTrigger.value
}
const response = await taskStore.fetchTaskRecords(taskId.value, params)
// API 可能返回数组或对象
if (Array.isArray(response)) {
records.value = response
total.value = response.length
} else if (response.items) {
records.value = response.items
total.value = response.total || response.items.length
} else {
records.value = []
total.value = 0
}
} catch (error) {
ElMessage.error(error.message || '获取打卡记录失败')
} finally {
loading.value = false
}
}
// 手动打卡
const handleManualCheckIn = async () => {
checkInLoading.value = true
// 显示持久化通知
const loadingMessage = ElMessage({
message: '正在打卡中,请稍候... 您可以继续浏览其他页面',
type: 'info',
duration: 0,
showClose: false
})
try {
const result = await taskStore.checkInTask(taskId.value)
loadingMessage.close()
if (result.success) {
ElMessage.success('打卡成功')
// 刷新记录列表
await fetchRecords()
} else {
ElMessage.warning(result.message || '打卡失败')
}
} catch (error) {
loadingMessage.close()
ElMessage.error(error.message || '打卡失败')
} finally {
checkInLoading.value = false
}
}
// 筛选变化
const handleFilterChange = () => {
currentPage.value = 1
fetchRecords()
}
// 分页变化
const handlePageChange = () => {
fetchRecords()
}
const handleSizeChange = () => {
currentPage.value = 1
fetchRecords()
}
// 格式化响应数据
const formatResponse = (data) => {
if (!data) return '-'
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data)
return JSON.stringify(parsed, null, 2).substring(0, 200) + (data.length > 200 ? '...' : '')
} catch {
return data.substring(0, 200) + (data.length > 200 ? '...' : '')
}
}
return JSON.stringify(data, null, 2).substring(0, 200)
}
onMounted(async () => {
await fetchTaskDetail()
await fetchRecords()
})
</script>
<style scoped>
/* Additional component-specific styles if needed */
</style>
+779
View File
@@ -0,0 +1,779 @@
<template>
<Layout>
<div class="min-h-screen bg-gradient-to-br from-blue-50 via-white to-green-50 p-6">
<div class="max-w-7xl mx-auto">
<!-- Header Section -->
<div class="mb-8 animate-fade-in">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-4xl font-bold text-gradient mb-2">任务管理</h1>
<p class="text-gray-600">管理您的自动打卡任务</p>
</div>
<button
@click="showCreateDialog = true"
class="md3-button-filled shadow-md3-3 hover:scale-105 transform transition-all"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
创建任务
</button>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="fluent-card p-6 animate-slide-up">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 mb-1">总任务数</p>
<p class="text-3xl font-bold text-primary-600">{{ taskStore.taskStats.total }}</p>
</div>
<div class="w-12 h-12 bg-primary-100 rounded-md3 flex items-center justify-center">
<svg class="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
</div>
</div>
<div class="fluent-card p-6 animate-slide-up" style="animation-delay: 0.1s">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 mb-1">启用中</p>
<p class="text-3xl font-bold text-green-600">{{ taskStore.taskStats.active }}</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-md3 flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
</div>
<div class="fluent-card p-6 animate-slide-up" style="animation-delay: 0.2s">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 mb-1">已禁用</p>
<p class="text-3xl font-bold text-gray-600">{{ taskStore.taskStats.inactive }}</p>
</div>
<div class="w-12 h-12 bg-gray-100 rounded-md3 flex items-center justify-center">
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</div>
</div>
</div>
</div>
</div>
<!-- Tasks List -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="i in 6" :key="i" class="fluent-card p-6">
<div class="skeleton h-6 w-3/4 mb-4"></div>
<div class="skeleton h-4 w-full mb-2"></div>
<div class="skeleton h-4 w-2/3"></div>
</div>
</div>
<div v-else-if="taskStore.tasks.length === 0" class="fluent-card p-12 text-center">
<svg class="w-20 h-20 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="text-xl font-semibold text-gray-700 mb-2">暂无任务</h3>
<p class="text-gray-500 mb-6">点击右上角的"创建任务"按钮开始添加您的第一个打卡任务</p>
<button @click="showCreateDialog = true" class="md3-button-outlined">
创建第一个任务
</button>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="task in taskStore.tasks"
:key="task.id"
class="fluent-card p-6 hover:scale-105 transform transition-all cursor-pointer animate-slide-up"
@click="viewTask(task)"
>
<!-- Task Header -->
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-800 mb-1">{{ task.name || '未命名任务' }}</h3>
<p class="text-sm text-gray-500">任务 ID: {{ task.id }}</p>
</div>
<span :class="task.is_active ? 'status-success' : 'status-info'">
{{ task.is_active ? '启用' : '禁用' }}
</span>
</div>
<!-- Task Details -->
<div class="space-y-2 mb-4">
<div class="flex items-center text-sm text-gray-600">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
接龙ID: {{ task.thread_id || '未知' }}
</div>
<div class="flex items-center text-sm text-gray-600">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
最后打卡: {{ task.last_check_in_time ? formatDateTime(task.last_check_in_time) : '未打卡' }}
</div>
<div class="flex items-center text-sm">
<svg class="w-4 h-4 mr-2 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span v-if="task.last_check_in_status" :class="{
'text-green-600 font-medium': task.last_check_in_status === 'success',
'text-blue-600 font-medium': task.last_check_in_status === 'out_of_time',
'text-red-600 font-medium': task.last_check_in_status === 'failure',
'text-yellow-600 font-medium': task.last_check_in_status === 'unknown'
}">
{{
task.last_check_in_status === 'success' ? '✅ 打卡成功' :
task.last_check_in_status === 'out_of_time' ? '🕐 时间范围外' :
task.last_check_in_status === 'failure' ? '❌ 打卡失败' :
'❗ 打卡异常'
}}
</span>
<span v-else class="text-gray-500">暂无打卡记录</span>
</div>
</div>
<!-- Task Actions -->
<div class="flex gap-2 pt-4 border-t border-gray-100">
<button
@click.stop="handleCheckIn(task.id)"
:disabled="checkInLoading[task.id]"
class="flex-1 py-2 px-4 bg-primary-50 text-primary-600 rounded-lg hover:bg-primary-100 transition-colors text-sm font-medium disabled:opacity-50"
>
{{ checkInLoading[task.id] ? '打卡中...' : '立即打卡' }}
</button>
<button
@click.stop="toggleTaskStatus(task)"
class="flex-1 py-2 px-4 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
>
{{ task.is_active ? '禁用' : '启用' }}
</button>
<button
@click.stop="editTask(task)"
class="p-2 bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
@click.stop="deleteTask(task)"
class="p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Create/Edit Task Dialog -->
<el-dialog
v-model="showCreateDialog"
:title="editingTask ? '编辑任务' : '从模板创建任务'"
width="700px"
:close-on-click-modal="false"
>
<!-- 只显示从模板创建 -->
<div v-if="!editingTask">
<div v-if="loadingTemplates" class="text-center py-8">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
<p class="text-gray-500 mt-2">加载模板中...</p>
</div>
<div v-else-if="activeTemplates.length === 0" class="text-center py-8">
<p class="text-gray-500">暂无可用模板</p>
<p class="text-sm text-gray-400 mt-2">请联系管理员创建模板</p>
</div>
<div v-else>
<!-- Template Selection -->
<el-form-item label="选择模板" label-width="100px" v-if="!selectedTemplate">
<div class="grid grid-cols-1 gap-3">
<div
v-for="template in activeTemplates"
:key="template.id"
@click="selectTemplate(template)"
class="border rounded-lg p-4 cursor-pointer hover:border-primary-500 hover:bg-primary-50 transition-all"
>
<h4 class="font-semibold text-gray-800 mb-1">{{ template.name }}</h4>
<p class="text-sm text-gray-600">{{ template.description || '无描述' }}</p>
</div>
</div>
</el-form-item>
<!-- Template Form -->
<el-form v-if="selectedTemplate" :model="templateTaskForm" ref="templateFormRef" label-width="120px">
<div class="mb-4 p-3 bg-blue-50 rounded-lg flex items-center justify-between">
<div class="flex items-center">
<svg class="w-5 h-5 text-blue-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="text-sm font-medium text-blue-900">使用模板{{ selectedTemplate.name }}</span>
</div>
<el-button size="small" text @click="selectedTemplate = null">更换模板</el-button>
</div>
<el-form-item label="任务名称" prop="task_name">
<el-input v-model="templateTaskForm.task_name" placeholder="可选,留空则自动生成" />
</el-form-item>
<el-form-item label="接龙 ID" prop="thread_id" required>
<el-input v-model="templateTaskForm.thread_id" placeholder="请输入接龙项目 ID" />
</el-form-item>
<el-divider content-position="left">填写字段信息</el-divider>
<!-- Dynamic Fields -->
<div v-for="(fieldConfig, key) in visibleFields" :key="key">
<el-form-item
:label="fieldConfig.display_name"
:required="fieldConfig.required"
>
<!-- Text Input -->
<el-input
v-if="fieldConfig.field_type === 'text'"
v-model="templateTaskForm.field_values[key]"
:placeholder="fieldConfig.placeholder || `请输入${fieldConfig.display_name}`"
/>
<!-- Textarea -->
<el-input
v-else-if="fieldConfig.field_type === 'textarea'"
v-model="templateTaskForm.field_values[key]"
type="textarea"
:rows="3"
:placeholder="fieldConfig.placeholder || `请输入${fieldConfig.display_name}`"
/>
<!-- Number Input -->
<el-input-number
v-else-if="fieldConfig.field_type === 'number'"
v-model="templateTaskForm.field_values[key]"
:placeholder="fieldConfig.placeholder || `请输入${fieldConfig.display_name}`"
style="width: 100%"
/>
<!-- Select -->
<el-select
v-else-if="fieldConfig.field_type === 'select'"
v-model="templateTaskForm.field_values[key]"
:placeholder="fieldConfig.placeholder || `请选择${fieldConfig.display_name}`"
style="width: 100%"
>
<el-option
v-for="option in fieldConfig.options"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<span v-if="fieldConfig.default_value" class="text-xs text-gray-500 mt-1">
默认值: {{ fieldConfig.default_value }}
</span>
</el-form-item>
</div>
</el-form>
</div>
</div>
<!-- Edit Mode Form - 简化版只显示任务名称和启用状态 -->
<el-form v-if="editingTask" :model="taskForm" :rules="taskRules" ref="taskFormRef" label-width="100px">
<el-form-item label="任务名称" prop="name">
<el-input v-model="taskForm.name" placeholder="请输入任务名称(例如:公司打卡)" />
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="taskForm.is_active" />
<span class="ml-2 text-sm text-gray-500">
{{ taskForm.is_active ? '启用自动打卡' : '禁用自动打卡(仍可手动打卡)' }}
</span>
</el-form-item>
<!-- 新增Crontab 编辑器 -->
<el-form-item label="打卡时间表">
<CrontabEditor v-model="taskForm.cron_expression" />
</el-form-item>
<el-divider content-position="left">任务 Payload 配置只读</el-divider>
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-600">完整的打卡请求配置</span>
<button
@click="copyPayload"
type="button"
class="px-3 py-1 text-xs bg-blue-50 text-blue-600 rounded hover:bg-blue-100 transition-colors flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
复制
</button>
</div>
<el-input
v-model="formattedPayload"
type="textarea"
:rows="12"
readonly
class="font-mono text-xs"
style="resize: vertical; min-height: 200px; max-height: 400px;"
/>
<p class="text-xs text-gray-500 mt-1">
💡 此配置由模板自动生成如需修改请删除任务后从模板重新创建
</p>
</div>
</el-form>
<template #footer>
<div class="flex gap-3 justify-end">
<button @click="showCreateDialog = false" class="md3-button-text">取消</button>
<button @click="handleSubmit" :disabled="submitting" class="md3-button-filled">
{{ submitting ? '提交中...' : (editingTask ? '保存修改' : '创建任务') }}
</button>
</div>
</template>
</el-dialog>
</Layout>
</template>
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import Layout from '@/components/Layout.vue'
import CrontabEditor from '@/components/CrontabEditor.vue'
import { useTaskStore } from '@/stores/task'
import { useTemplateStore } from '@/stores/template'
import { copyToClipboard, formatDateTime } from '@/utils/helpers'
const router = useRouter()
const taskStore = useTaskStore()
const templateStore = useTemplateStore()
const loading = ref(false)
const showCreateDialog = ref(false)
const submitting = ref(false)
const editingTask = ref(null)
const taskFormRef = ref(null)
const templateFormRef = ref(null)
const checkInLoading = ref({})
// Template mode
const createMode = ref('template') // 'template' or 'manual'
const loadingTemplates = ref(false)
const activeTemplates = ref([])
const selectedTemplate = ref(null)
const templatePreview = ref(null) // 存储从 preview 接口获取的合并后配置
// Manual create form
const taskForm = reactive({
name: '',
thread_id: '',
is_active: true,
payload_config: '',
cron_expression: '0 20 * * *', // 新增:Crontab 表达式,默认每天 20:00
})
// Template create form
const templateTaskForm = reactive({
task_name: '',
thread_id: '',
field_values: {}
})
const taskRules = {
name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
thread_id: [{ required: true, message: '请输入接龙 ID', trigger: 'blur' }],
}
// Compute visible fields from selected template (using merged config)
const visibleFields = computed(() => {
if (!templatePreview.value) return {}
// 使用合并后的完整字段配置(包含从父模板继承的字段)
const fieldConfig = templatePreview.value.field_config
const visible = {}
// 递归函数:提取所有可见的普通字段
const extractVisibleFields = (config, parentPath = '') => {
for (const [key, value] of Object.entries(config)) {
const currentPath = parentPath ? `${parentPath}.${key}` : key
// 判断是否为字段配置对象(包含 display_name
if (value && typeof value === 'object' && 'display_name' in value) {
// 这是一个普通字段配置
if (!value.hidden) {
visible[currentPath] = value
}
}
// 判断是否为数组字段
else if (Array.isArray(value)) {
// 数组字段:遍历每个元素
if (value.length > 0) {
const firstElement = value[0]
// 如果数组元素是字段配置对象,直接提取
if (firstElement && typeof firstElement === 'object' && 'display_name' in firstElement) {
if (!firstElement.hidden) {
visible[`${currentPath}[0]`] = firstElement
}
}
// 如果数组元素是对象(但不是字段配置),递归处理
else if (firstElement && typeof firstElement === 'object') {
extractVisibleFields(firstElement, `${currentPath}[0]`)
}
}
}
// 判断是否为对象字段(不包含 display_name 的对象)
else if (value && typeof value === 'object' && !('display_name' in value)) {
// 递归处理对象字段
extractVisibleFields(value, currentPath)
}
}
}
extractVisibleFields(fieldConfig)
return visible
})
// Formatted payload for display in edit mode
const formattedPayload = computed(() => {
if (!taskForm.payload_config) return '{}'
try {
const payload = JSON.parse(taskForm.payload_config)
return JSON.stringify(payload, null, 2)
} catch (e) {
return taskForm.payload_config
}
})
// Copy payload to clipboard
const copyPayload = async () => {
const success = await copyToClipboard(formattedPayload.value)
if (success) {
ElMessage.success('Payload 已复制到剪贴板')
} else {
ElMessage.error('复制失败')
}
}
// Initialize field values with defaults when template is selected
watch(selectedTemplate, async (newTemplate) => {
if (!newTemplate) {
templatePreview.value = null
return
}
// 获取模板的合并后配置(包含父模板的字段)
try {
templatePreview.value = await templateStore.previewTemplate(newTemplate.id)
} catch (error) {
ElMessage.error('获取模板配置失败')
templatePreview.value = null
return
}
const fieldConfig = templatePreview.value.field_config
const fieldValues = {}
// 递归函数:提取所有字段的默认值
const extractDefaultValues = (config, parentPath = '') => {
for (const [key, value] of Object.entries(config)) {
const currentPath = parentPath ? `${parentPath}.${key}` : key
// 判断是否为字段配置对象(包含 display_name
if (value && typeof value === 'object' && 'display_name' in value) {
fieldValues[currentPath] = value.default_value || ''
}
// 判断是否为数组字段
else if (Array.isArray(value)) {
// 数组字段:处理第一个元素的默认值
if (value.length > 0) {
const firstElement = value[0]
// 如果数组元素是字段配置对象,直接提取默认值
if (firstElement && typeof firstElement === 'object' && 'display_name' in firstElement) {
fieldValues[`${currentPath}[0]`] = firstElement.default_value || ''
}
// 如果数组元素是对象(但不是字段配置),递归处理
else if (firstElement && typeof firstElement === 'object') {
extractDefaultValues(firstElement, `${currentPath}[0]`)
}
}
}
// 判断是否为对象字段(不包含 display_name 的对象)
else if (value && typeof value === 'object' && !('display_name' in value)) {
// 递归处理对象字段
extractDefaultValues(value, currentPath)
}
}
}
extractDefaultValues(fieldConfig)
templateTaskForm.field_values = fieldValues
})
// Load templates
const loadTemplates = async () => {
loadingTemplates.value = true
try {
activeTemplates.value = await templateStore.fetchActiveTemplates()
} catch (error) {
ElMessage.error(error.message || '加载模板失败')
} finally {
loadingTemplates.value = false
}
}
// Select template
const selectTemplate = (template) => {
selectedTemplate.value = template
}
// Handle mode change
const handleModeChange = (mode) => {
selectedTemplate.value = null
templateTaskForm.task_name = ''
templateTaskForm.thread_id = ''
templateTaskForm.field_values = {}
}
// 加载任务列表
const fetchTasks = async () => {
loading.value = true
try {
await taskStore.fetchMyTasks()
} catch (error) {
ElMessage.error(error.message || '加载任务列表失败')
} finally {
loading.value = false
}
}
// 查看任务详情
const viewTask = (task) => {
router.push(`/tasks/${task.id}/records`)
}
// 编辑任务
const editTask = (task) => {
editingTask.value = task
Object.assign(taskForm, {
name: task.name,
thread_id: task.thread_id,
is_active: task.is_active,
payload_config: task.payload_config || '{}',
cron_expression: task.cron_expression || '0 20 * * *', // 新增:加载 cron_expression
})
showCreateDialog.value = true
}
// 删除任务
const deleteTask = async (task) => {
try {
await ElMessageBox.confirm(
`确定要删除任务"${task.name || '未命名任务'}"吗?此操作不可恢复。`,
'删除确认',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
}
)
await taskStore.deleteTask(task.id)
ElMessage.success('任务删除成功')
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '删除任务失败')
}
}
}
// 切换任务状态
const toggleTaskStatus = async (task) => {
try {
await taskStore.toggleTask(task.id)
ElMessage.success(task.is_active ? '任务已禁用' : '任务已启用')
} catch (error) {
ElMessage.error(error.message || '切换任务状态失败')
}
}
// 手动打卡 (异步轮询方式)
const handleCheckIn = async (taskId) => {
checkInLoading.value[taskId] = true
try {
// 调用异步打卡接口,立即返回 record_id
const result = await taskStore.checkInTask(taskId)
// 获取 record_id
const recordId = result.record_id
if (!recordId) {
ElMessage.error('打卡请求失败:未获取到记录ID')
checkInLoading.value[taskId] = false
return
}
// 如果初始状态就是失败,显示错误并刷新任务列表
if (result.status === 'failure') {
ElMessage.error(result.message || '打卡失败')
checkInLoading.value[taskId] = false
await fetchTasks()
return
}
// 显示提示消息
ElMessage.info('打卡任务已启动,正在后台处理...')
// 用于存储 interval ID,以便在超时时清理
let pollIntervalId = null
// 开始轮询检查打卡状态
pollIntervalId = setInterval(async () => {
try {
const status = await taskStore.getCheckInRecordStatus(recordId)
// 只要状态不是 pending,说明打卡请求已经处理完成
if (status.status !== 'pending') {
clearInterval(pollIntervalId)
checkInLoading.value[taskId] = false
if (status.status === 'success') {
// 打卡成功
ElMessage.success('打卡成功!')
await fetchTasks()
} else {
// 打卡失败或其他状态 (failure, out_of_time, unknown 等)
const errorMsg = status.error_message || status.response_text || '打卡失败'
ElMessage.error(errorMsg)
await fetchTasks()
}
}
// status === 'pending' 时继续轮询
} catch (error) {
// 查询状态失败,停止轮询
console.error('轮询状态失败:', error)
clearInterval(pollIntervalId)
checkInLoading.value[taskId] = false
ElMessage.error('查询打卡状态失败')
}
}, 2000) // 每 2 秒查询一次
// 设置超时保护(30 秒后停止轮询)
setTimeout(() => {
if (checkInLoading.value[taskId]) {
clearInterval(pollIntervalId)
checkInLoading.value[taskId] = false
ElMessage.warning('打卡处理时间较长,请稍后查看打卡记录')
}
}, 30000)
} catch (error) {
console.error('启动打卡失败:', error)
checkInLoading.value[taskId] = false
ElMessage.error(error.message || '启动打卡任务失败')
}
}
// 提交表单
const handleSubmit = async () => {
submitting.value = true
try {
// Edit mode
if (editingTask.value) {
if (!taskFormRef.value) return
await taskFormRef.value.validate()
await taskStore.updateTask(editingTask.value.id, taskForm)
ElMessage.success('任务更新成功')
}
// Create from template
else if (createMode.value === 'template') {
if (!selectedTemplate.value) {
ElMessage.warning('请选择一个模板')
return
}
if (!templateTaskForm.thread_id) {
ElMessage.warning('请输入接龙 ID')
return
}
await templateStore.createTaskFromTemplate(
selectedTemplate.value.id,
templateTaskForm.thread_id,
templateTaskForm.field_values,
templateTaskForm.task_name || null
)
ElMessage.success('任务创建成功')
}
// Create manually
else {
if (!taskFormRef.value) return
await taskFormRef.value.validate()
await taskStore.createTask(taskForm)
ElMessage.success('任务创建成功')
}
showCreateDialog.value = false
resetForm()
await fetchTasks()
} catch (error) {
ElMessage.error(error.message || '操作失败')
} finally {
submitting.value = false
}
}
// 重置表单
const resetForm = () => {
editingTask.value = null
selectedTemplate.value = null
createMode.value = 'template'
Object.assign(taskForm, {
name: '',
thread_id: '',
is_active: true,
payload_config: '',
})
templateTaskForm.task_name = ''
templateTaskForm.thread_id = ''
templateTaskForm.field_values = {}
taskFormRef.value?.resetFields()
}
// Watch dialog open to load templates
watch(showCreateDialog, (isOpen) => {
if (isOpen && !editingTask.value) {
loadTemplates()
}
})
onMounted(() => {
fetchTasks()
})
</script>
<style scoped>
/* Additional component-specific styles if needed */
</style>
+733
View File
@@ -0,0 +1,733 @@
<template>
<Layout>
<div class="min-h-screen bg-gradient-to-br from-blue-50 via-white to-green-50 p-6">
<div class="max-w-7xl mx-auto">
<!-- Header Section -->
<div class="mb-8 animate-fade-in">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-4xl font-bold text-gradient mb-2">任务管理</h1>
<p class="text-gray-600">管理您的自动打卡任务</p>
</div>
<button
@click="showCreateDialog = true"
class="md3-button-filled shadow-md3-3 hover:scale-105 transform transition-all"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
创建任务
</button>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="fluent-card p-6 animate-slide-up">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 mb-1">总任务数</p>
<p class="text-3xl font-bold text-primary-600">{{ taskStore.taskStats.total }}</p>
</div>
<div class="w-12 h-12 bg-primary-100 rounded-md3 flex items-center justify-center">
<svg class="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
</div>
</div>
<div class="fluent-card p-6 animate-slide-up" style="animation-delay: 0.1s">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 mb-1">启用中</p>
<p class="text-3xl font-bold text-green-600">{{ taskStore.taskStats.active }}</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-md3 flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
</div>
<div class="fluent-card p-6 animate-slide-up" style="animation-delay: 0.2s">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 mb-1">已禁用</p>
<p class="text-3xl font-bold text-gray-600">{{ taskStore.taskStats.inactive }}</p>
</div>
<div class="w-12 h-12 bg-gray-100 rounded-md3 flex items-center justify-center">
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</div>
</div>
</div>
</div>
</div>
<!-- Tasks List -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="i in 6" :key="i" class="fluent-card p-6">
<div class="skeleton h-6 w-3/4 mb-4"></div>
<div class="skeleton h-4 w-full mb-2"></div>
<div class="skeleton h-4 w-2/3"></div>
</div>
</div>
<div v-else-if="taskStore.tasks.length === 0" class="fluent-card p-12 text-center">
<svg class="w-20 h-20 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="text-xl font-semibold text-gray-700 mb-2">暂无任务</h3>
<p class="text-gray-500 mb-6">点击右上角的"创建任务"按钮开始添加您的第一个打卡任务</p>
<button @click="showCreateDialog = true" class="md3-button-outlined">
创建第一个任务
</button>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="task in taskStore.tasks"
:key="task.id"
class="fluent-card p-6 hover:scale-105 transform transition-all cursor-pointer animate-slide-up"
@click="viewTask(task)"
>
<!-- Task Header -->
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-800 mb-1">{{ task.name || task.signature }}</h3>
<p class="text-sm text-gray-500">任务 ID: {{ task.id }}</p>
</div>
<span :class="task.is_active ? 'status-success' : 'status-info'">
{{ task.is_active ? '启用' : '禁用' }}
</span>
</div>
<!-- Task Details -->
<div class="space-y-2 mb-4">
<div class="flex items-center text-sm text-gray-600">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
接龙ID: {{ task.thread_id || '未知' }}
</div>
<div class="flex items-center text-sm text-gray-600">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
最后打卡: {{ task.last_check_in_time ? formatDateTime(task.last_check_in_time) : '未打卡' }}
</div>
<div class="flex items-center text-sm">
<svg class="w-4 h-4 mr-2 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span v-if="task.last_check_in_status" :class="{
'text-green-600 font-medium': task.last_check_in_status === 'success',
'text-blue-600 font-medium': task.last_check_in_status === 'out_of_time',
'text-red-600 font-medium': task.last_check_in_status === 'failure',
'text-yellow-600 font-medium': task.last_check_in_status === 'unknown'
}">
{{
task.last_check_in_status === 'success' ? '✅ 打卡成功' :
task.last_check_in_status === 'out_of_time' ? '🕐 时间范围外' :
task.last_check_in_status === 'failure' ? '❌ 打卡失败' :
'❗ 打卡异常'
}}
</span>
<span v-else class="text-gray-500">暂无打卡记录</span>
</div>
</div>
<!-- Task Actions -->
<div class="flex gap-2 pt-4 border-t border-gray-100">
<button
@click.stop="handleCheckIn(task.id)"
:disabled="checkInLoading[task.id]"
class="flex-1 py-2 px-4 bg-primary-50 text-primary-600 rounded-lg hover:bg-primary-100 transition-colors text-sm font-medium disabled:opacity-50"
>
{{ checkInLoading[task.id] ? '打卡中...' : '立即打卡' }}
</button>
<button
@click.stop="toggleTaskStatus(task)"
class="flex-1 py-2 px-4 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
>
{{ task.is_active ? '禁用' : '启用' }}
</button>
<button
@click.stop="editTask(task)"
class="p-2 bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
@click.stop="deleteTask(task)"
class="p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Create/Edit Task Dialog -->
<el-dialog
v-model="showCreateDialog"
:title="editingTask ? '编辑任务' : '从模板创建任务'"
width="700px"
:close-on-click-modal="false"
>
<!-- 只显示从模板创建 -->
<div v-if="!editingTask">
<div v-if="loadingTemplates" class="text-center py-8">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
<p class="text-gray-500 mt-2">加载模板中...</p>
</div>
<div v-else-if="activeTemplates.length === 0" class="text-center py-8">
<p class="text-gray-500">暂无可用模板</p>
<p class="text-sm text-gray-400 mt-2">请联系管理员创建模板</p>
</div>
<div v-else>
<!-- Template Selection -->
<el-form-item label="选择模板" label-width="100px" v-if="!selectedTemplate">
<div class="grid grid-cols-1 gap-3">
<div
v-for="template in activeTemplates"
:key="template.id"
@click="selectTemplate(template)"
class="border rounded-lg p-4 cursor-pointer hover:border-primary-500 hover:bg-primary-50 transition-all"
>
<h4 class="font-semibold text-gray-800 mb-1">{{ template.name }}</h4>
<p class="text-sm text-gray-600">{{ template.description || '无描述' }}</p>
</div>
</div>
</el-form-item>
<!-- Template Form -->
<el-form v-if="selectedTemplate" :model="templateTaskForm" ref="templateFormRef" label-width="120px">
<div class="mb-4 p-3 bg-blue-50 rounded-lg flex items-center justify-between">
<div class="flex items-center">
<svg class="w-5 h-5 text-blue-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="text-sm font-medium text-blue-900">使用模板:{{ selectedTemplate.name }}</span>
</div>
<el-button size="small" text @click="selectedTemplate = null">更换模板</el-button>
</div>
<el-form-item label="任务名称" prop="task_name">
<el-input v-model="templateTaskForm.task_name" placeholder="可选,留空则自动生成" />
</el-form-item>
<el-form-item label="接龙 ID" prop="thread_id" required>
<el-input v-model="templateTaskForm.thread_id" placeholder="请输入接龙项目 ID" />
</el-form-item>
<el-divider content-position="left">填写字段信息</el-divider>
<!-- Dynamic Fields -->
<div v-for="(fieldConfig, key) in visibleFields" :key="key">
<el-form-item
:label="fieldConfig.display_name"
:required="fieldConfig.required"
>
<!-- Text Input -->
<el-input
v-if="fieldConfig.field_type === 'text'"
v-model="templateTaskForm.field_values[key]"
:placeholder="fieldConfig.placeholder || `请输入${fieldConfig.display_name}`"
/>
<!-- Textarea -->
<el-input
v-else-if="fieldConfig.field_type === 'textarea'"
v-model="templateTaskForm.field_values[key]"
type="textarea"
:rows="3"
:placeholder="fieldConfig.placeholder || `请输入${fieldConfig.display_name}`"
/>
<!-- Number Input -->
<el-input-number
v-else-if="fieldConfig.field_type === 'number'"
v-model="templateTaskForm.field_values[key]"
:placeholder="fieldConfig.placeholder || `请输入${fieldConfig.display_name}`"
style="width: 100%"
/>
<!-- Select -->
<el-select
v-else-if="fieldConfig.field_type === 'select'"
v-model="templateTaskForm.field_values[key]"
:placeholder="fieldConfig.placeholder || `请选择${fieldConfig.display_name}`"
style="width: 100%"
>
<el-option
v-for="option in fieldConfig.options"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<span v-if="fieldConfig.default_value" class="text-xs text-gray-500 mt-1">
默认值: {{ fieldConfig.default_value }}
</span>
</el-form-item>
</div>
</el-form>
</div>
</div>
<!-- Edit Mode Form - 简化版,只显示任务名称和启用状态 -->
<el-form v-if="editingTask" :model="taskForm" :rules="taskRules" ref="taskFormRef" label-width="100px">
<el-form-item label="任务名称" prop="name">
<el-input v-model="taskForm.name" placeholder="请输入任务名称(例如:公司打卡)" />
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="taskForm.is_active" />
<span class="ml-2 text-sm text-gray-500">
{{ taskForm.is_active ? '启用自动打卡' : '禁用自动打卡' }}
</span>
</el-form-item>
<el-divider content-position="left">任务 Payload 配置(只读)</el-divider>
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-600">完整的打卡请求配置</span>
<button
@click="copyPayload"
type="button"
class="px-3 py-1 text-xs bg-blue-50 text-blue-600 rounded hover:bg-blue-100 transition-colors flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
复制
</button>
</div>
<el-input
v-model="formattedPayload"
type="textarea"
:rows="12"
readonly
class="font-mono text-xs"
style="resize: vertical; min-height: 200px; max-height: 400px;"
/>
<p class="text-xs text-gray-500 mt-1">
💡 此配置由模板自动生成,如需修改请删除任务后从模板重新创建
</p>
</div>
</el-form>
<template #footer>
<div class="flex gap-3 justify-end">
<button @click="showCreateDialog = false" class="md3-button-text">取消</button>
<button @click="handleSubmit" :disabled="submitting" class="md3-button-filled">
{{ submitting ? '提交中...' : (editingTask ? '保存修改' : '创建任务') }}
</button>
</div>
</template>
</el-dialog>
</Layout>
</template>
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import Layout from '@/components/Layout.vue'
import { useTaskStore } from '@/stores/task'
import { useTemplateStore } from '@/stores/template'
import { copyToClipboard, formatDateTime } from '@/utils/helpers'
const router = useRouter()
const taskStore = useTaskStore()
const templateStore = useTemplateStore()
const loading = ref(false)
const showCreateDialog = ref(false)
const submitting = ref(false)
const editingTask = ref(null)
const taskFormRef = ref(null)
const templateFormRef = ref(null)
const checkInLoading = ref({})
// Template mode
const createMode = ref('template') // 'template' or 'manual'
const loadingTemplates = ref(false)
const activeTemplates = ref([])
const selectedTemplate = ref(null)
const templatePreview = ref(null) // 存储从 preview 接口获取的合并后配置
// Manual create form
const taskForm = reactive({
name: '',
thread_id: '',
signature: '',
texts: '',
values: '{}',
is_active: true,
payload_config: '',
})
// Template create form
const templateTaskForm = reactive({
task_name: '',
thread_id: '',
field_values: {}
})
const taskRules = {
name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
thread_id: [{ required: true, message: '请输入接龙 ID', trigger: 'blur' }],
signature: [{ required: true, message: '请输入 Signature', trigger: 'blur' }],
}
// Compute visible fields from selected template (using merged config)
const visibleFields = computed(() => {
if (!templatePreview.value) return {}
// 使用合并后的完整字段配置(包含从父模板继承的字段)
const fieldConfig = templatePreview.value.field_config
const visible = {}
// 递归函数:提取所有可见的普通字段
const extractVisibleFields = (config, parentPath = '') => {
for (const [key, value] of Object.entries(config)) {
const currentPath = parentPath ? `${parentPath}.${key}` : key
// 判断是否为字段配置对象(包含 display_name
if (value && typeof value === 'object' && 'display_name' in value) {
// 这是一个普通字段配置
if (!value.hidden) {
visible[currentPath] = value
}
}
// 判断是否为数组字段
else if (Array.isArray(value)) {
// 数组字段:遍历每个元素
if (value.length > 0) {
const firstElement = value[0]
// 如果数组元素是字段配置对象,直接提取
if (firstElement && typeof firstElement === 'object' && 'display_name' in firstElement) {
if (!firstElement.hidden) {
visible[`${currentPath}[0]`] = firstElement
}
}
// 如果数组元素是对象(但不是字段配置),递归处理
else if (firstElement && typeof firstElement === 'object') {
extractVisibleFields(firstElement, `${currentPath}[0]`)
}
}
}
// 判断是否为对象字段(不包含 display_name 的对象)
else if (value && typeof value === 'object' && !('display_name' in value)) {
// 递归处理对象字段
extractVisibleFields(value, currentPath)
}
}
}
extractVisibleFields(fieldConfig)
return visible
})
// Formatted payload for display in edit mode
const formattedPayload = computed(() => {
if (!taskForm.payload_config) return '{}'
try {
const payload = JSON.parse(taskForm.payload_config)
return JSON.stringify(payload, null, 2)
} catch (e) {
return taskForm.payload_config
}
})
// Copy payload to clipboard
const copyPayload = async () => {
const success = await copyToClipboard(formattedPayload.value)
if (success) {
ElMessage.success('Payload 已复制到剪贴板')
} else {
ElMessage.error('复制失败')
}
}
// Initialize field values with defaults when template is selected
watch(selectedTemplate, async (newTemplate) => {
if (!newTemplate) {
templatePreview.value = null
return
}
// 获取模板的合并后配置(包含父模板的字段)
try {
templatePreview.value = await templateStore.previewTemplate(newTemplate.id)
} catch (error) {
ElMessage.error('获取模板配置失败')
templatePreview.value = null
return
}
const fieldConfig = templatePreview.value.field_config
const fieldValues = {}
// 递归函数:提取所有字段的默认值
const extractDefaultValues = (config, parentPath = '') => {
for (const [key, value] of Object.entries(config)) {
const currentPath = parentPath ? `${parentPath}.${key}` : key
// 判断是否为字段配置对象(包含 display_name
if (value && typeof value === 'object' && 'display_name' in value) {
fieldValues[currentPath] = value.default_value || ''
}
// 判断是否为数组字段
else if (Array.isArray(value)) {
// 数组字段:处理第一个元素的默认值
if (value.length > 0) {
const firstElement = value[0]
// 如果数组元素是字段配置对象,直接提取默认值
if (firstElement && typeof firstElement === 'object' && 'display_name' in firstElement) {
fieldValues[`${currentPath}[0]`] = firstElement.default_value || ''
}
// 如果数组元素是对象(但不是字段配置),递归处理
else if (firstElement && typeof firstElement === 'object') {
extractDefaultValues(firstElement, `${currentPath}[0]`)
}
}
}
// 判断是否为对象字段(不包含 display_name 的对象)
else if (value && typeof value === 'object' && !('display_name' in value)) {
// 递归处理对象字段
extractDefaultValues(value, currentPath)
}
}
}
extractDefaultValues(fieldConfig)
templateTaskForm.field_values = fieldValues
})
// Load templates
const loadTemplates = async () => {
loadingTemplates.value = true
try {
activeTemplates.value = await templateStore.fetchActiveTemplates()
} catch (error) {
ElMessage.error(error.message || '加载模板失败')
} finally {
loadingTemplates.value = false
}
}
// Select template
const selectTemplate = (template) => {
selectedTemplate.value = template
}
// Handle mode change
const handleModeChange = (mode) => {
selectedTemplate.value = null
templateTaskForm.task_name = ''
templateTaskForm.thread_id = ''
templateTaskForm.field_values = {}
}
// 加载任务列表
const fetchTasks = async () => {
loading.value = true
try {
await taskStore.fetchMyTasks()
} catch (error) {
ElMessage.error(error.message || '加载任务列表失败')
} finally {
loading.value = false
}
}
// 查看任务详情
const viewTask = (task) => {
router.push(`/tasks/${task.id}/records`)
}
// 编辑任务
const editTask = (task) => {
editingTask.value = task
Object.assign(taskForm, {
name: task.name,
thread_id: task.thread_id,
signature: task.signature,
texts: task.texts || '',
values: task.values || '{}',
is_active: task.is_active,
payload_config: task.payload_config || '{}',
})
showCreateDialog.value = true
}
// 删除任务
const deleteTask = async (task) => {
try {
await ElMessageBox.confirm(
`确定要删除任务"${task.name || task.signature}"吗?此操作不可恢复。`,
'删除确认',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
}
)
await taskStore.deleteTask(task.id)
ElMessage.success('任务删除成功')
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '删除任务失败')
}
}
}
// 切换任务状态
const toggleTaskStatus = async (task) => {
try {
await taskStore.toggleTask(task.id)
ElMessage.success(task.is_active ? '任务已禁用' : '任务已启用')
} catch (error) {
ElMessage.error(error.message || '切换任务状态失败')
}
}
// 手动打卡
const handleCheckIn = async (taskId) => {
checkInLoading.value[taskId] = true
// 显示持久化通知
const loadingMessage = ElMessage({
message: '正在打卡中,请稍候... 您可以继续浏览其他页面',
type: 'info',
duration: 0,
showClose: false
})
try {
const result = await taskStore.checkInTask(taskId)
loadingMessage.close()
if (result.success) {
ElMessage.success('打卡成功')
} else {
ElMessage.warning(result.message || '打卡失败')
}
} catch (error) {
loadingMessage.close()
ElMessage.error(error.message || '打卡失败')
} finally {
checkInLoading.value[taskId] = false
}
}
// 提交表单
const handleSubmit = async () => {
submitting.value = true
try {
// Edit mode
if (editingTask.value) {
if (!taskFormRef.value) return
await taskFormRef.value.validate()
await taskStore.updateTask(editingTask.value.id, taskForm)
ElMessage.success('任务更新成功')
}
// Create from template
else if (createMode.value === 'template') {
if (!selectedTemplate.value) {
ElMessage.warning('请选择一个模板')
return
}
if (!templateTaskForm.thread_id) {
ElMessage.warning('请输入接龙 ID')
return
}
await templateStore.createTaskFromTemplate(
selectedTemplate.value.id,
templateTaskForm.thread_id,
templateTaskForm.field_values,
templateTaskForm.task_name || null
)
ElMessage.success('任务创建成功')
}
// Create manually
else {
if (!taskFormRef.value) return
await taskFormRef.value.validate()
await taskStore.createTask(taskForm)
ElMessage.success('任务创建成功')
}
showCreateDialog.value = false
resetForm()
await fetchTasks()
} catch (error) {
ElMessage.error(error.message || '操作失败')
} finally {
submitting.value = false
}
}
// 重置表单
const resetForm = () => {
editingTask.value = null
selectedTemplate.value = null
createMode.value = 'template'
Object.assign(taskForm, {
name: '',
thread_id: '',
signature: '',
texts: '',
values: '{}',
is_active: true,
payload_config: '',
})
templateTaskForm.task_name = ''
templateTaskForm.thread_id = ''
templateTaskForm.field_values = {}
taskFormRef.value?.resetFields()
}
// Watch dialog open to load templates
watch(showCreateDialog, (isOpen) => {
if (isOpen && !editingTask.value) {
loadTemplates()
}
})
onMounted(() => {
fetchTasks()
})
</script>
<style scoped>
/* Additional component-specific styles if needed */
</style>
+134
View File
@@ -0,0 +1,134 @@
<template>
<Layout>
<div class="admin-logs-container">
<el-card>
<template #header>
<div class="card-header">
<div>
<el-icon><Document /></el-icon>
<span>系统日志</span>
</div>
<el-button type="primary" :icon="Refresh" @click="handleRefresh">
刷新
</el-button>
</div>
</template>
<el-alert
title="日志查看"
type="info"
:closable="false"
show-icon
style="margin-bottom: 20px"
>
<p>显示最新的系统日志信息默认显示最近 200 </p>
</el-alert>
<div v-if="adminStore.loading" class="loading-container">
<el-skeleton :rows="10" animated />
</div>
<div v-else class="logs-content">
<el-input
v-model="logContent"
type="textarea"
:rows="25"
readonly
placeholder="暂无日志内容"
/>
<div class="log-info">
<span> {{ logLines }} </span>
<span>最后更新: {{ lastUpdate }}</span>
</div>
</div>
</el-card>
</div>
</Layout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Document, Refresh } from '@element-plus/icons-vue'
import Layout from '@/components/Layout.vue'
import { useAdminStore } from '@/stores/admin'
import { formatDateTime } from '@/utils/helpers'
const adminStore = useAdminStore()
const logContent = ref('')
const lastUpdate = ref('')
const logLines = computed(() => {
if (!logContent.value) return 0
const content = typeof logContent.value === 'string' ? logContent.value : String(logContent.value)
return content.split('\n').length
})
const handleRefresh = async () => {
try {
const data = await adminStore.fetchLogs({ lines: 200 })
if (data.logs) {
// 确保是字符串
logContent.value = typeof data.logs === 'string' ? data.logs : String(data.logs)
lastUpdate.value = formatDateTime(new Date())
ElMessage.success('刷新成功')
} else {
logContent.value = '无日志内容'
}
} catch (error) {
ElMessage.error(error.message || '刷新失败')
}
}
onMounted(() => {
handleRefresh()
})
</script>
<style scoped>
.admin-logs-container {
max-width: 1400px;
margin: 0 auto;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header > div {
display: flex;
align-items: center;
gap: 8px;
font-weight: bold;
}
.loading-container {
padding: 20px;
}
.logs-content {
font-family: 'Courier New', Courier, monospace;
}
.log-info {
display: flex;
justify-content: space-between;
margin-top: 10px;
font-size: 12px;
color: #909399;
}
:deep(.el-textarea__inner) {
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre;
overflow-x: auto;
word-break: normal;
overflow-wrap: normal;
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<template>
<Layout>
<div class="admin-records-container">
<el-card>
<template #header>
<div class="card-header">
<div>
<el-icon><List /></el-icon>
<span>所有打卡记录</span>
</div>
<el-button type="primary" :icon="Refresh" @click="handleRefresh">
刷新
</el-button>
</div>
</template>
<!-- 记录表格 -->
<el-table
:data="checkInStore.allRecords"
v-loading="checkInStore.loading"
stripe
border
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="user_id" label="用户ID" width="100" />
<el-table-column prop="user_email" label="用户邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="task_name" label="任务名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="thread_id" label="接龙ID" width="150" show-overflow-tooltip />
<el-table-column prop="check_in_time" label="打卡时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.check_in_time) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag v-if="row.status === 'success'" type="success"> 打卡成功</el-tag>
<el-tag v-else-if="row.status === 'out_of_time'" type="info">🕐 时间范围外</el-tag>
<el-tag v-else-if="row.status === 'unknown'" type="warning"> 打卡异常</el-tag>
<el-tag v-else type="danger"> 打卡失败</el-tag>
</template>
</el-table-column>
<el-table-column prop="trigger_type" label="触发方式" width="120">
<template #default="{ row }">
<el-tag v-if="row.trigger_type === 'manual'" type="primary">手动</el-tag>
<el-tag v-else-if="row.trigger_type === 'scheduled'" type="info">定时</el-tag>
<el-tag v-else-if="row.trigger_type === 'admin'" type="warning">管理员</el-tag>
<el-tag v-else>{{ row.trigger_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="response_text" label="消息" min-width="200" show-overflow-tooltip />
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="checkInStore.currentPage"
v-model:page-size="checkInStore.pageSize"
:total="checkInStore.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</el-card>
</div>
</Layout>
</template>
<script setup>
import { onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { List, Refresh } from '@element-plus/icons-vue'
import Layout from '@/components/Layout.vue'
import { useCheckInStore } from '@/stores/checkIn'
import { formatDateTime } from '@/utils/helpers'
const checkInStore = useCheckInStore()
const handleRefresh = async () => {
try {
await checkInStore.fetchAllRecords()
ElMessage.success('刷新成功')
} catch (error) {
ElMessage.error(error.message || '刷新失败')
}
}
const handlePageChange = () => {
checkInStore.fetchAllRecords()
}
const handleSizeChange = () => {
checkInStore.currentPage = 1
checkInStore.fetchAllRecords()
}
onMounted(() => {
checkInStore.fetchAllRecords()
})
</script>
<style scoped>
.admin-records-container {
max-width: 1600px;
margin: 0 auto;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header > div {
display: flex;
align-items: center;
gap: 8px;
font-weight: bold;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<template>
<Layout>
<div class="admin-stats-container">
<el-row :gutter="20">
<el-col :span="24">
<el-card>
<template #header>
<div class="card-header">
<el-icon><DataAnalysis /></el-icon>
<span>系统统计信息</span>
<el-button type="primary" :icon="Refresh" @click="handleRefresh">
刷新
</el-button>
</div>
</template>
<div v-if="adminStore.loading" class="loading-container">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="adminStore.stats" class="stats-content">
<el-row :gutter="20">
<el-col :span="6">
<el-statistic
title="总用户数"
:value="adminStore.totalUsers"
prefix-icon="User"
/>
</el-col>
<el-col :span="6">
<el-statistic
title="已审批用户数"
:value="adminStore.activeUsers"
prefix-icon="Check"
value-style="color: #67c23a"
/>
</el-col>
<el-col :span="6">
<el-statistic
title="总打卡次数"
:value="adminStore.totalRecords"
prefix-icon="List"
/>
</el-col>
<el-col :span="6">
<el-statistic
title="今日打卡"
:value="adminStore.todayRecords"
prefix-icon="Calendar"
value-style="color: #409eff"
/>
</el-col>
</el-row>
<el-divider />
<el-descriptions title="详细信息" :column="2" border>
<el-descriptions-item label="管理员数量">
{{ adminStore.stats?.users?.admin || 0 }}
</el-descriptions-item>
<el-descriptions-item label="普通用户数量">
{{ adminStore.stats?.users?.regular || 0 }}
</el-descriptions-item>
<el-descriptions-item label="今日成功打卡">
<el-tag type="success">{{ adminStore.stats?.check_in_records?.today_success || 0 }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="今日失败打卡">
<el-tag type="danger">{{ adminStore.stats?.check_in_records?.today_failure || 0 }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="今日时间范围外">
<el-tag type="info">{{ adminStore.stats?.check_in_records?.today_out_of_time || 0 }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="今日异常打卡">
<el-tag type="warning">{{ adminStore.stats?.check_in_records?.today_unknown || 0 }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="总成功率" :span="2">
<el-progress
:percentage="calculateSuccessRate()"
:color="getProgressColor"
/>
</el-descriptions-item>
</el-descriptions>
</div>
</el-card>
</el-col>
</el-row>
</div>
</Layout>
</template>
<script setup>
import { onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { DataAnalysis, Refresh } from '@element-plus/icons-vue'
import Layout from '@/components/Layout.vue'
import { useAdminStore } from '@/stores/admin'
const adminStore = useAdminStore()
const getProgressColor = (percentage) => {
if (percentage >= 90) return '#67c23a'
if (percentage >= 70) return '#e6a23c'
return '#f56c6c'
}
const calculateSuccessRate = () => {
const total = adminStore.stats?.check_in_records?.total || 0
const todaySuccess = adminStore.stats?.check_in_records?.today_success || 0
if (total === 0) return 0
// Calculate success rate based on all records (not just today)
// We need to get success count from backend or calculate differently
// For now, use today's success rate as approximation
const todayTotal = adminStore.stats?.check_in_records?.today || 0
if (todayTotal === 0) return 0
return Math.round((todaySuccess / todayTotal) * 100)
}
const handleRefresh = async () => {
try {
await adminStore.fetchStats()
ElMessage.success('刷新成功')
} catch (error) {
ElMessage.error(error.message || '刷新失败')
}
}
onMounted(() => {
adminStore.fetchStats()
})
</script>
<style scoped>
.admin-stats-container {
max-width: 1200px;
margin: 0 auto;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: bold;
}
.card-header .el-button {
margin-left: auto;
}
.loading-container {
padding: 20px;
}
.stats-content {
padding: 20px 0;
}
</style>
+592
View File
@@ -0,0 +1,592 @@
<template>
<Layout>
<div class="min-h-screen bg-gradient-to-br from-purple-50 via-white to-blue-50 p-6">
<div class="max-w-7xl mx-auto">
<!-- Header -->
<div class="mb-8 animate-fade-in">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold text-gradient mb-2">任务模板管理</h1>
<p class="text-gray-600">JSON 映射架构 - 配置即结构字段名保持原样</p>
</div>
<button @click="showCreateDialog" class="md3-button-filled">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
新建模板
</button>
</div>
</div>
<!-- Templates List -->
<div v-if="loading && templates.length === 0" class="space-y-4">
<div v-for="i in 3" :key="i" class="fluent-card p-6">
<div class="skeleton h-6 w-1/3 mb-3"></div>
<div class="skeleton h-4 w-full mb-2"></div>
<div class="skeleton h-4 w-2/3"></div>
</div>
</div>
<div v-else-if="templates.length === 0" class="fluent-card p-12 text-center">
<svg class="w-20 h-20 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="text-xl font-semibold text-gray-700 mb-2">暂无模板</h3>
<p class="text-gray-500 mb-4">创建第一个模板让用户更轻松地创建打卡任务</p>
<button @click="showCreateDialog" class="md3-button-filled">新建模板</button>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="template in templates"
:key="template.id"
class="fluent-card p-6 hover:shadow-xl transition-all animate-slide-up"
>
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-800 mb-2">{{ template.name }}</h3>
<p class="text-sm text-gray-600 mb-3">{{ template.description || '无描述' }}</p>
<span :class="template.is_active ? 'status-success' : 'status-info'">
{{ template.is_active ? '已启用' : '已禁用' }}
</span>
</div>
</div>
<div class="flex items-center gap-2 mt-4">
<button @click="previewTemplate(template)" class="md3-button-outlined text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
预览
</button>
<button @click="editTemplate(template)" class="md3-button-outlined text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
编辑
</button>
<button @click="deleteTemplate(template)" class="md3-button-text text-sm text-red-600">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
删除
</button>
</div>
</div>
</div>
<!-- Create/Edit Dialog -->
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? '新建模板' : '编辑模板'"
width="95%"
:close-on-click-modal="false"
class="template-editor-dialog"
>
<el-form :model="formData" label-width="120px" ref="formRef">
<el-form-item label="模板名称" required>
<el-input v-model="formData.name" placeholder="请输入模板名称" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="模板描述">
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入模板描述" />
</el-form-item>
<el-form-item label="父模板">
<el-select v-model="formData.parent_id" placeholder="可选,继承父模板的字段配置" clearable class="w-full">
<el-option
v-for="template in availableParentTemplates"
:key="template.id"
:label="template.name"
:value="template.id"
:disabled="template.id === currentTemplateId"
/>
</el-select>
</el-form-item>
<el-form-item label="是否启用">
<el-switch v-model="formData.is_active" />
</el-form-item>
<el-divider content-position="left">
<span class="text-lg font-bold">Payload 配置 (JSON 映射)</span>
</el-divider>
<el-alert
title="💡 JSON 映射架构"
type="info"
:closable="false"
class="mb-4"
>
<p class="text-sm mb-2">
<strong>配置即结构</strong>模板配置完全映射到生成的 Payload 结构
</p>
<p class="text-sm mb-2">
<strong>字段名保持原样</strong>不进行任何大小写转换
</p>
<p class="text-sm">
<strong>ThreadId</strong> 由用户填写无需在模板中配置
</p>
</el-alert>
<!-- 字段配置编辑器 -->
<div class="field-config-editor">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-gray-800">字段配置</h3>
<el-dropdown @command="handleAddField">
<el-button type="primary">
添加字段
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="field">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
普通字段
</el-dropdown-item>
<el-dropdown-item command="array">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
数组字段
</el-dropdown-item>
<el-dropdown-item command="object">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
对象字段
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<!-- 递归渲染字段树 -->
<div v-if="Object.keys(formData.field_config).length === 0" class="text-center py-12 border-2 border-dashed border-gray-300 rounded-lg bg-gray-50">
<svg class="w-16 h-16 mx-auto text-gray-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="text-lg font-semibold text-gray-700 mb-2">暂无字段配置</h3>
<p class="text-sm text-gray-500">点击上方"添加字段"开始配置模板</p>
</div>
<div v-else class="space-y-3">
<FieldTreeNode
v-for="(config, key) in formData.field_config"
:key="key"
:field-key="key"
:field-config="config"
:path="[key]"
@update="(event) => updateField(event.path, event.value)"
@delete="(path) => deleteField(path)"
@move="(event) => moveField(event.path, event.direction)"
/>
</div>
</div>
<!-- JSON 预览 -->
<el-divider content-position="left">
<span class="text-lg font-bold">JSON 预览</span>
</el-divider>
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm overflow-auto max-h-96">
<pre>{{ JSON.stringify(formData.field_config, null, 2) }}</pre>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
{{ dialogMode === 'create' ? '创建' : '更新' }}
</el-button>
</template>
</el-dialog>
<!-- Add Field Dialog -->
<el-dialog v-model="addFieldDialogVisible" :title="`添加${fieldTypeLabel}`" width="500px">
<el-form @submit.prevent="confirmAddField">
<el-form-item label="字段名">
<el-input
v-model="newFieldName"
placeholder="例如: Id, Group1, DateTarget"
@keyup.enter="confirmAddField"
/>
<span class="text-xs text-gray-500 mt-1">
💡 字段名将保持原样不会进行大小写转换
</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addFieldDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmAddField">确定</el-button>
</template>
</el-dialog>
<!-- Preview Dialog -->
<el-dialog v-model="previewDialogVisible" title="模板预览" width="90%">
<div v-if="previewData" class="space-y-4">
<div class="bg-gray-50 rounded p-4">
<h4 class="font-semibold mb-2">生成的 Payload使用默认值</h4>
<pre class="text-xs bg-white p-3 rounded border overflow-auto max-h-96">{{ JSON.stringify(previewData.preview_payload, null, 2) }}</pre>
</div>
<div class="bg-gray-50 rounded p-4">
<h4 class="font-semibold mb-2">字段配置</h4>
<pre class="text-xs bg-white p-3 rounded border overflow-auto max-h-96">{{ JSON.stringify(previewData.field_config, null, 2) }}</pre>
</div>
</div>
<template #footer>
<el-button @click="previewDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</div>
</Layout>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox, ElIcon } from 'element-plus'
import { ArrowDown } from '@element-plus/icons-vue'
import Layout from '@/components/Layout.vue'
import FieldTreeNode from '@/components/FieldTreeNode.vue'
import { useTemplateStore } from '@/stores/template'
const templateStore = useTemplateStore()
const templates = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const dialogMode = ref('create')
const currentTemplateId = ref(null)
const submitting = ref(false)
const previewDialogVisible = ref(false)
const previewData = ref(null)
const addFieldDialogVisible = ref(false)
const newFieldName = ref('')
const newFieldType = ref('field')
const formData = ref({
name: '',
description: '',
parent_id: null,
is_active: true,
field_config: {}
})
const availableParentTemplates = computed(() => {
if (dialogMode.value === 'create') {
return templates.value
}
return templates.value.filter(t => t.id !== currentTemplateId.value)
})
const fieldTypeLabel = computed(() => {
const labels = {
field: '普通字段',
array: '数组字段',
object: '对象字段'
}
return labels[newFieldType.value] || '字段'
})
function createDefaultFieldConfig() {
return {
display_name: '',
field_type: 'text',
default_value: '',
required: false,
hidden: false,
placeholder: '',
value_type: 'string',
options: []
}
}
const fetchTemplates = async () => {
loading.value = true
try {
templates.value = await templateStore.fetchTemplates()
} catch (error) {
ElMessage.error(error.message || '获取模板列表失败')
} finally {
loading.value = false
}
}
const showCreateDialog = () => {
dialogMode.value = 'create'
currentTemplateId.value = null
formData.value = {
name: '',
description: '',
parent_id: null,
is_active: true,
field_config: {}
}
dialogVisible.value = true
}
const editTemplate = (template) => {
dialogMode.value = 'edit'
currentTemplateId.value = template.id
const fieldConfig = JSON.parse(template.field_config)
formData.value = {
name: template.name,
description: template.description || '',
parent_id: template.parent_id || null,
is_active: template.is_active,
field_config: fieldConfig
}
dialogVisible.value = true
}
const handleSubmit = async () => {
if (!formData.value.name) {
ElMessage.warning('请输入模板名称')
return
}
submitting.value = true
try {
const templateData = {
name: formData.value.name,
description: formData.value.description,
parent_id: formData.value.parent_id,
is_active: formData.value.is_active,
field_config: JSON.stringify(formData.value.field_config)
}
if (dialogMode.value === 'create') {
await templateStore.createTemplate(templateData)
ElMessage.success('模板创建成功')
} else {
await templateStore.updateTemplate(currentTemplateId.value, templateData)
ElMessage.success('模板更新成功')
}
dialogVisible.value = false
await fetchTemplates()
} catch (error) {
ElMessage.error(error.message || '操作失败')
} finally {
submitting.value = false
}
}
const deleteTemplate = async (template) => {
try {
await ElMessageBox.confirm(
`确定要删除模板"${template.name}"吗?此操作不可撤销。`,
'确认删除',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
}
)
await templateStore.deleteTemplate(template.id)
ElMessage.success('模板删除成功')
await fetchTemplates()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '删除失败')
}
}
}
const previewTemplate = async (template) => {
try {
previewData.value = await templateStore.previewTemplate(template.id)
previewDialogVisible.value = true
} catch (error) {
ElMessage.error(error.message || '预览失败')
}
}
const handleAddField = (type) => {
newFieldType.value = type
newFieldName.value = ''
addFieldDialogVisible.value = true
}
const confirmAddField = () => {
if (!newFieldName.value) {
ElMessage.warning('请输入字段名')
return
}
if (formData.value.field_config[newFieldName.value]) {
ElMessage.warning('该字段已存在')
return
}
// 创建对应类型的字段
if (newFieldType.value === 'field') {
formData.value.field_config[newFieldName.value] = createDefaultFieldConfig()
} else if (newFieldType.value === 'array') {
formData.value.field_config[newFieldName.value] = []
} else if (newFieldType.value === 'object') {
formData.value.field_config[newFieldName.value] = {}
}
addFieldDialogVisible.value = false
ElMessage.success('字段添加成功')
}
const updateField = (path, newValue) => {
// 通过路径更新嵌套字段
let target = formData.value.field_config
for (let i = 0; i < path.length - 1; i++) {
target = target[path[i]]
}
target[path[path.length - 1]] = newValue
}
const deleteField = (path) => {
// 通过路径删除嵌套字段
console.log('🗑️ 删除字段 - 路径:', path)
if (!path || path.length === 0) return
// 创建一个新的 field_config 副本以触发响应性
const newConfig = JSON.parse(JSON.stringify(formData.value.field_config))
let target = newConfig
// 导航到父对象/数组
for (let i = 0; i < path.length - 1; i++) {
if (!target || typeof target !== 'object') {
console.error('❌ 删除失败:路径无效', path, 'at index', i)
return
}
target = target[path[i]]
}
if (!target || typeof target !== 'object') {
console.error('❌ 删除失败:父对象不存在', path)
return
}
const lastKey = path[path.length - 1]
// 如果父容器是数组,使用 splice;如果是对象,使用 delete
if (Array.isArray(target)) {
target.splice(lastKey, 1)
} else {
delete target[lastKey]
}
// 替换整个 field_config 以触发 Vue 响应性
formData.value.field_config = newConfig
console.log('✅ 字段已删除:', path)
}
const moveField = (path, direction) => {
// 通过路径移动字段
if (!path || path.length === 0) return
// 创建一个新的 field_config 副本以触发响应性
const newConfig = JSON.parse(JSON.stringify(formData.value.field_config))
let parent = newConfig
// 导航到父对象/数组
for (let i = 0; i < path.length - 1; i++) {
if (!parent || typeof parent !== 'object') {
console.error('移动失败:路径无效', path, 'at index', i)
return
}
parent = parent[path[i]]
}
if (!parent || typeof parent !== 'object') {
console.error('移动失败:父对象不存在', path)
return
}
const fieldKey = path[path.length - 1]
if (Array.isArray(parent)) {
// 数组:使用索引移动
const index = fieldKey
if (direction === 'up' && index > 0) {
// 向上移动
const temp = parent[index]
parent[index] = parent[index - 1]
parent[index - 1] = temp
} else if (direction === 'down' && index < parent.length - 1) {
// 向下移动
const temp = parent[index]
parent[index] = parent[index + 1]
parent[index + 1] = temp
} else {
// 已经在边界,无需移动
return
}
} else {
// 对象:需要重建对象以改变键的顺序
const keys = Object.keys(parent)
const currentIndex = keys.indexOf(fieldKey)
if (currentIndex === -1) return
let newIndex = currentIndex
if (direction === 'up' && currentIndex > 0) {
newIndex = currentIndex - 1
} else if (direction === 'down' && currentIndex < keys.length - 1) {
newIndex = currentIndex + 1
} else {
// 已经在边界,无需移动
return
}
if (newIndex !== currentIndex) {
// 交换键的位置
const temp = keys[currentIndex]
keys[currentIndex] = keys[newIndex]
keys[newIndex] = temp
// 重建对象
const newParent = {}
keys.forEach(key => {
newParent[key] = parent[key]
})
// 更新父对象的所有键
Object.keys(parent).forEach(key => delete parent[key])
Object.assign(parent, newParent)
}
}
// 替换整个 field_config 以触发 Vue 响应性
formData.value.field_config = newConfig
console.log('✅ 字段已移动:', path, direction)
}
onMounted(() => {
fetchTemplates()
})
</script>
<style scoped>
.field-config-editor {
min-height: 200px;
}
.template-editor-dialog :deep(.el-dialog__body) {
max-height: 70vh;
overflow-y: auto;
}
</style>
+543
View File
@@ -0,0 +1,543 @@
<template>
<Layout>
<div class="admin-users-container">
<el-card>
<template #header>
<div class="card-header">
<div>
<el-icon><UserFilled /></el-icon>
<span>用户管理</span>
</div>
<div class="actions">
<el-button type="success" :icon="Plus" @click="handleCreate">
创建用户
</el-button>
<el-button type="primary" :icon="Refresh" @click="handleRefresh">
刷新
</el-button>
</div>
</div>
</template>
<!-- Tab 切换 -->
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<!-- 待审批用户 Tab -->
<el-tab-pane label="待审批用户" name="pending">
<el-table
:data="pendingUsers"
v-loading="loading"
stripe
border
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="alias" label="用户名" min-width="150" />
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="registered_ip" label="注册IP" width="150" />
<el-table-column prop="created_at" label="注册时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="success" size="small" @click="handleApprove(row)">
通过
</el-button>
<el-button type="danger" size="small" @click="handleReject(row)">
拒绝
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && pendingUsers.length === 0" description="暂无待审批用户" />
</el-tab-pane>
<!-- 所有用户 Tab -->
<el-tab-pane label="所有用户" name="all">
<!-- 用户表格 -->
<el-table
:data="userStore.users"
v-loading="loading"
stripe
border
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="alias" label="用户名" min-width="150" show-overflow-tooltip />
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="role" label="角色" width="100">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'danger' : 'primary'">
{{ row.role === 'admin' ? '管理员' : '用户' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_approved" label="审批状态" width="100">
<template #default="{ row }">
<el-tag :type="row.is_approved ? 'success' : 'warning'">
{{ row.is_approved ? '已审批' : '待审批' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="registered_ip" label="注册IP" width="150" />
<el-table-column prop="jwt_exp" label="Token 过期时间" width="180">
<template #default="{ row }">
{{ row.jwt_exp && row.jwt_exp !== '0' ? formatDateTime(parseInt(row.jwt_exp) * 1000) : '-' }}
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 批量操作 -->
<div class="batch-actions" v-if="selectedUsers.length > 0">
<el-alert
:title="`已选择 ${selectedUsers.length} 个用户`"
type="info"
:closable="false"
>
<template #default>
<div style="margin-top: 10px;">
<el-button type="success" size="small" @click="handleBatchApprove">
批量审批
</el-button>
<el-button type="danger" size="small" @click="handleBatchDelete">
批量删除
</el-button>
</div>
</template>
</el-alert>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 创建/编辑用户对话框 -->
<el-dialog
:title="dialogMode === 'create' ? '创建用户' : '编辑用户'"
v-model="dialogVisible"
width="600px"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<el-form-item label="用户名" prop="alias">
<el-input v-model="formData.alias" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="formData.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="角色" prop="role">
<el-select v-model="formData.role" placeholder="请选择角色">
<el-option label="用户" value="user" />
<el-option label="管理员" value="admin" />
</el-select>
</el-form-item>
<el-form-item label="审批状态" prop="is_approved">
<el-switch v-model="formData.is_approved" />
<span class="form-hint">是否已审批通过</span>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="formData.password"
type="password"
:placeholder="dialogMode === 'create' ? '请输入密码' : '留空则不修改密码'"
show-password
/>
<span class="form-hint" v-if="dialogMode === 'edit'">
留空则不修改密码
</span>
</el-form-item>
<el-form-item label="重置密码" v-if="dialogMode === 'edit'">
<el-switch v-model="formData.reset_password" />
<span class="form-hint-danger" v-if="formData.reset_password">
将重置为默认密码
</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
确定
</el-button>
</template>
</el-dialog>
</div>
</Layout>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { UserFilled, Plus, Refresh } from '@element-plus/icons-vue'
import Layout from '@/components/Layout.vue'
import { useUserStore } from '@/stores/user'
import { useAdminStore } from '@/stores/admin'
import adminAPI from '@/api/index'
const userStore = useUserStore()
const adminStore = useAdminStore()
// 状态
const loading = ref(false)
const activeTab = ref('all') // 默认展示所有用户
const pendingUsers = ref([])
const selectedUsers = ref([])
const dialogVisible = ref(false)
const dialogMode = ref('create')
const submitting = ref(false)
// 表单
const formRef = ref(null)
const formData = ref({
alias: '',
role: 'user',
is_approved: true,
email: '',
password: '',
reset_password: false,
})
// 表单验证规则
const formRules = {
alias: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
],
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
],
}
// 时间格式化
const formatDateTime = (timestamp) => {
if (!timestamp) return '-'
const date = new Date(timestamp)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
// 获取待审批用户
const fetchPendingUsers = async () => {
loading.value = true
try {
pendingUsers.value = await adminAPI.getPendingUsers()
} catch (error) {
ElMessage.error(error.message || '获取待审批用户失败')
} finally {
loading.value = false
}
}
// Tab 切换
const handleTabChange = (tab) => {
if (tab === 'pending') {
fetchPendingUsers()
} else {
handleRefresh()
}
}
// 审批通过用户
const handleApprove = async (user) => {
try {
await ElMessageBox.confirm(
`确认通过用户 "${user.alias}" 的审批吗?`,
'审批确认',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'success',
}
)
await adminAPI.approveUser(user.id)
ElMessage.success('审批成功')
fetchPendingUsers()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '审批失败')
}
}
}
// 拒绝用户
const handleReject = async (user) => {
try {
await ElMessageBox.confirm(
`确认拒绝用户 "${user.alias}" 的申请吗?拒绝后将删除该用户。`,
'拒绝确认',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
}
)
await adminAPI.rejectUser(user.id)
ElMessage.success('已拒绝并删除用户')
fetchPendingUsers()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '操作失败')
}
}
}
// 刷新数据
const handleRefresh = async () => {
if (activeTab.value === 'pending') {
await fetchPendingUsers()
} else {
loading.value = true
try {
await userStore.fetchUsers()
ElMessage.success('刷新成功')
} catch (error) {
ElMessage.error(error.message || '刷新失败')
} finally {
loading.value = false
}
}
}
// 创建用户
const handleCreate = () => {
dialogMode.value = 'create'
formData.value = {
alias: '',
role: 'user',
is_approved: true,
email: '',
password: '',
reset_password: false,
}
dialogVisible.value = true
}
// 编辑用户
const handleEdit = (user) => {
dialogMode.value = 'edit'
formData.value = {
id: user.id,
alias: user.alias,
role: user.role,
is_approved: user.is_approved,
email: user.email || '',
password: '',
reset_password: false,
}
dialogVisible.value = true
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
submitting.value = true
// 检查密码设置冲突
if (dialogMode.value === 'edit' && formData.value.password && formData.value.reset_password) {
ElMessage.warning('不能同时设置新密码和重置密码,请选择其一')
submitting.value = false
return
}
if (dialogMode.value === 'create') {
await userStore.createUser(formData.value)
ElMessage.success('创建成功')
} else {
await userStore.updateUser(formData.value.id, formData.value)
ElMessage.success('更新成功')
}
dialogVisible.value = false
await handleRefresh()
} catch (error) {
ElMessage.error(error.message || '操作失败')
} finally {
submitting.value = false
}
}
// 删除用户
const handleDelete = (user) => {
ElMessageBox.confirm(`确定要删除用户 "${user.alias}" 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
await userStore.deleteUser(user.id)
ElMessage.success('删除成功')
await handleRefresh()
} catch (error) {
ElMessage.error(error.message || '删除失败')
}
})
.catch(() => {})
}
// 选择改变
const handleSelectionChange = (selection) => {
selectedUsers.value = selection
}
// 批量审批
const handleBatchApprove = async () => {
try {
await ElMessageBox.confirm(
`确认批量审批 ${selectedUsers.value.length} 个用户吗?`,
'批量审批确认',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'success',
}
)
const userIds = selectedUsers.value.map((u) => u.id)
let successCount = 0
let failureCount = 0
for (const userId of userIds) {
try {
await adminAPI.approveUser(userId)
successCount++
} catch (error) {
failureCount++
}
}
ElMessage.success(`批量审批完成:成功 ${successCount},失败 ${failureCount}`)
await handleRefresh()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '批量审批失败')
}
}
}
// 批量删除
const handleBatchDelete = async () => {
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedUsers.value.length} 个用户吗?此操作不可恢复!`,
'批量删除警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
const userIds = selectedUsers.value.map((u) => u.id)
let successCount = 0
let failureCount = 0
for (const userId of userIds) {
try {
await userStore.deleteUser(userId)
successCount++
} catch (error) {
failureCount++
}
}
ElMessage.success(`批量删除完成:成功 ${successCount},失败 ${failureCount}`)
await handleRefresh()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '批量删除失败')
}
}
}
onMounted(() => {
// 默认加载所有用户
handleRefresh()
})
</script>
<style scoped>
.admin-users-container {
max-width: 1600px;
margin: 0 auto;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header > div {
display: flex;
align-items: center;
gap: 8px;
}
.actions {
gap: 10px;
}
.batch-actions {
margin-top: 15px;
}
.form-hint {
margin-left: 10px;
font-size: 12px;
color: #909399;
}
.form-hint-danger {
color: #f56c6c;
font-weight: 500;
display: block;
margin-left: 0;
margin-top: 4px;
}
</style>
@@ -0,0 +1,370 @@
<template>
<Layout>
<div class="admin-users-container">
<el-card>
<template #header>
<div class="card-header">
<div>
<el-icon><UserFilled /></el-icon>
<span>用户管理</span>
</div>
<div class="actions">
<el-button type="success" :icon="Plus" @click="handleCreate">
创建用户
</el-button>
<el-button type="primary" :icon="Refresh" @click="handleRefresh">
刷新
</el-button>
</div>
</div>
</template>
<!-- Tab 切换 -->
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<!-- 待审批用户 Tab -->
<el-tab-pane label="待审批用户" name="pending">
<el-table
:data="pendingUsers"
v-loading="loading"
stripe
border
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="alias" label="用户名" min-width="150" />
<el-table-column prop="registered_ip" label="注册IP" width="150" />
<el-table-column prop="created_at" label="注册时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="success" size="small" @click="handleApprove(row)">
通过
</el-button>
<el-button type="danger" size="small" @click="handleReject(row)">
拒绝
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && pendingUsers.length === 0" description="暂无待审批用户" />
</el-tab-pane>
<!-- 所有用户 Tab -->
<el-tab-pane label="所有用户" name="all">
<!-- 用户表格 -->
<el-table
:data="userStore.users"
v-loading="loading"
stripe
border
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="alias" label="用户名" min-width="150" show-overflow-tooltip />
<el-table-column prop="role" label="角色" width="100">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'danger' : 'primary'">
{{ row.role === 'admin' ? '管理员' : '用户' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="jwt_exp" label="Token 过期时间" width="180">
<template #default="{ row }">
{{ row.jwt_exp && row.jwt_exp !== '0' ? formatDateTime(parseInt(row.jwt_exp) * 1000) : '-' }}
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 批量操作 -->
<div class="batch-actions" v-if="selectedUsers.length > 0">
<el-alert
:title="`已选择 ${selectedUsers.length} 个用户`"
type="info"
:closable="false"
>
<template #default>
const formRules = {
alias: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
],
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
],
}
// 获取待审批用户
const fetchPendingUsers = async () => {
loading.value = true
try {
pendingUsers.value = await adminAPI.getPendingUsers()
} catch (error) {
ElMessage.error(error.message || '获取待审批用户失败')
} finally {
loading.value = false
}
}
// Tab 切换
const handleTabChange = (tab) => {
if (tab === 'pending') {
fetchPendingUsers()
} else {
handleRefresh()
}
}
// 审批通过用户
const handleApprove = async (user) => {
try {
await ElMessageBox.confirm(
`确认通过用户 "${user.alias}" 的审批吗?`,
'审批确认',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'success',
}
)
await adminAPI.approveUser(user.id)
ElMessage.success('审批成功')
fetchPendingUsers()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '审批失败')
}
}
}
// 拒绝用户
const handleReject = async (user) => {
try {
await ElMessageBox.confirm(
`确认拒绝用户 "${user.alias}" 的申请吗?拒绝后将删除该用户。`,
'拒绝确认',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
}
)
await adminAPI.rejectUser(user.id)
ElMessage.success('已拒绝并删除用户')
fetchPendingUsers()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '操作失败')
}
}
}
// 刷新数据
const handleRefresh = async () => {
if (activeTab.value === 'pending') {
await fetchPendingUsers()
} else {
loading.value = true
try {
await userStore.fetchUsers()
ElMessage.success('刷新成功')
} catch (error) {
ElMessage.error(error.message || '刷新失败')
} finally {
loading.value = false
}
}
}
// 创建用户
const handleCreate = () => {
dialogMode.value = 'create'
formData.value = {
alias: '',
role: 'user',
is_approved: true,
email: '',
password: '',
reset_password: false,
}
dialogVisible.value = true
}
// 编辑用户
const handleEdit = (user) => {
dialogMode.value = 'edit'
formData.value = {
id: user.id,
alias: user.alias,
role: user.role,
is_approved: user.is_approved,
email: user.email || '',
password: '',
reset_password: false,
}
dialogVisible.value = true
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
submitting.value = true
// 检查密码设置冲突
if (dialogMode.value === 'edit' && formData.value.password && formData.value.reset_password) {
ElMessage.warning('不能同时设置新密码和重置密码,请选择其一')
submitting.value = false
return
}
if (dialogMode.value === 'create') {
await userStore.createUser(formData.value)
ElMessage.success('创建成功')
} else {
await userStore.updateUser(formData.value.id, formData.value)
ElMessage.success('更新成功')
}
dialogVisible.value = false
await handleRefresh()
} catch (error) {
ElMessage.error(error.message || '操作失败')
} finally {
submitting.value = false
}
}
// 删除用户
const handleDelete = (user) => {
ElMessageBox.confirm(`确定要删除用户 "${user.alias}" 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
await userStore.deleteUser(user.id)
ElMessage.success('删除成功')
await handleRefresh()
} catch (error) {
ElMessage.error(error.message || '删除失败')
}
})
.catch(() => {})
}
// 选择改变
const handleSelectionChange = (selection) => {
selectedUsers.value = selection
}
// 批量启用/禁用
// 批量打卡
const handleBatchCheckIn = async () => {
const userIds = selectedUsers.value.map((u) => u.id)
try {
const result = await adminStore.batchCheckIn(userIds)
ElMessage.success(`批量打卡完成:成功 ${result.success_count},失败 ${result.failure_count}`)
await handleRefresh()
} catch (error) {
ElMessage.error(error.message || '批量打卡失败')
}
}
// 页码改变
const handlePageChange = () => {
handleRefresh()
}
// 每页数量改变
const handleSizeChange = () => {
userStore.currentPage = 1
handleRefresh()
}
onMounted(() => {
fetchPendingUsers() // 默认加载待审批用户
})
</script>
<style scoped>
.admin-users-container {
max-width: 1600px;
margin: 0 auto;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header > div {
display: flex;
align-items: center;
gap: 8px;
}
.actions {
gap: 10px;
}
.batch-actions {
margin-top: 15px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.form-hint {
margin-left: 10px;
font-size: 12px;
color: #909399;
}
.form-hint-danger {
color: #f56c6c;
font-weight: 500;
display: block;
margin-left: 0;
margin-top: 4px;
}
</style>
+94
View File
@@ -0,0 +1,94 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// Material Design 3 color palette
primary: {
50: '#e8f5e9',
100: '#c8e6c9',
200: '#a5d6a7',
300: '#81c784',
400: '#66bb6a',
500: '#4caf50',
600: '#43a047',
700: '#388e3c',
800: '#2e7d32',
900: '#1b5e20',
},
secondary: {
50: '#e3f2fd',
100: '#bbdefb',
200: '#90caf9',
300: '#64b5f6',
400: '#42a5f5',
500: '#2196f3',
600: '#1e88e5',
700: '#1976d2',
800: '#1565c0',
900: '#0d47a1',
},
accent: {
50: '#fff3e0',
100: '#ffe0b2',
200: '#ffcc80',
300: '#ffb74d',
400: '#ffa726',
500: '#ff9800',
600: '#fb8c00',
700: '#f57c00',
800: '#ef6c00',
900: '#e65100',
},
surface: {
50: '#fafafa',
100: '#f5f5f5',
200: '#eeeeee',
300: '#e0e0e0',
400: '#bdbdbd',
500: '#9e9e9e',
600: '#757575',
700: '#616161',
800: '#424242',
900: '#212121',
},
},
borderRadius: {
'md3': '12px',
'md3-lg': '16px',
'md3-xl': '28px',
},
boxShadow: {
'md3-1': '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
'md3-2': '0 1px 3px 1px rgba(0, 0, 0, 0.08)',
'md3-3': '0 4px 8px 3px rgba(0, 0, 0, 0.10)',
'md3-4': '0 6px 10px 4px rgba(0, 0, 0, 0.12)',
'md3-5': '0 8px 12px 6px rgba(0, 0, 0, 0.14)',
},
animation: {
'fade-in': 'fadeIn 0.3s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
slideDown: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [],
}
+37
View File
@@ -0,0 +1,37 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
'element-plus': ['element-plus'],
'vue-vendor': ['vue', 'vue-router', 'pinia'],
},
},
},
},
})

Some files were not shown because too many files have changed in this diff Show More