mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
refactor: v2
backend & frontend
This commit is contained in:
@@ -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 使用 465,STARTTLS 使用 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/TLS(True/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
@@ -1,9 +1,55 @@
|
|||||||
__pycache__
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# 项目特定
|
||||||
chromedriver
|
chromedriver
|
||||||
chrome-linux64
|
chromedriver.exe
|
||||||
|
chrome-linux64/
|
||||||
|
chrome-win64/
|
||||||
debug_page_source.html
|
debug_page_source.html
|
||||||
debug_screenshot.png
|
debug_screenshot.png
|
||||||
sessions
|
|
||||||
|
# 运行时文件
|
||||||
|
sessions/
|
||||||
*.lock
|
*.lock
|
||||||
*.log
|
*.log
|
||||||
*.pid
|
*.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
|
||||||
@@ -1,35 +1,790 @@
|
|||||||
# Jielong
|
# 接龙自动打卡系统 V2
|
||||||
|
|
||||||
Run: `sh start.sh` and get usage(Linux only).
|
[](https://fastapi.tiangolo.com/)
|
||||||
|
[](https://vuejs.org/)
|
||||||
|
[](https://www.python.org/)
|
||||||
|
[](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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
本项目仅供学习和研究使用。
|
||||||
@@ -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)}"
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
# 设置限流 Cookie(10 分钟)
|
||||||
|
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)}"
|
||||||
|
)
|
||||||
@@ -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)}"
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)}"
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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",
|
||||||
|
)
|
||||||
@@ -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"]
|
||||||
@@ -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})>"
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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})>"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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="用户别名")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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="字段配置(用于前端渲染表单)")
|
||||||
@@ -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分钟内)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
# 清洗 Token:URL 解码 + 去除 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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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}")
|
||||||
@@ -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)}")
|
||||||
@@ -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)}"
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
import time
|
import time
|
||||||
import csv
|
import logging
|
||||||
import os
|
|
||||||
import configparser
|
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>
|
<h1>注意!</h1>
|
||||||
<div class="message">
|
<div class="message">
|
||||||
<p>{name},请注意!</p>
|
<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>
|
<p><strong>到期时间:</strong> {exp_time}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="footer">邮件发送时间: {send_time}</p>
|
<p class="footer">邮件发送时间: {send_time}</p>
|
||||||
@@ -72,7 +72,7 @@ FAILURE_HTML_TEMPLATE = """
|
|||||||
<title>打卡失败通知</title>
|
<title>打卡失败通知</title>
|
||||||
<style>
|
<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); }}
|
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; }}
|
.message {{ background-color: #fff; padding: 15px; border: 1px solid #ddd; border-radius: 5px; margin-bottom: 20px; }}
|
||||||
.important {{ font-weight: bold; color: #d9534f; }}
|
.important {{ font-weight: bold; color: #d9534f; }}
|
||||||
.footer {{ font-size: 0.9em; color: #666; }}
|
.footer {{ font-size: 0.9em; color: #666; }}
|
||||||
@@ -84,14 +84,53 @@ FAILURE_HTML_TEMPLATE = """
|
|||||||
<p>{name},您好!</p>
|
<p>{name},您好!</p>
|
||||||
<p>系统于 <span class="important">{send_time}</span> 尝试为您自动打卡时失败。</p>
|
<p>系统于 <span class="important">{send_time}</span> 尝试为您自动打卡时失败。</p>
|
||||||
<p><strong>失败原因:</strong> 服务器返回 "需要登录",这通常意味着您的 <span class="important">Token 已失效</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>
|
</div>
|
||||||
<p class="footer">感谢您的使用!</p>
|
<p class="footer">感谢您的使用!</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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:
|
try:
|
||||||
msg = MIMEMultipart()
|
msg = MIMEMultipart()
|
||||||
msg["From"] = email_settings['senderemail']
|
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())
|
server.sendmail(msg["From"], msg["To"], msg.as_string())
|
||||||
|
|
||||||
logger.info(f"已成功向 {to_email} 发送邮件,主题: {subject}")
|
logger.info(f"已成功向 {to_email} 发送邮件,主题: {subject}")
|
||||||
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"向 {to_email} 发送邮件时失败: {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(
|
html = EXPIRATION_HTML_TEMPLATE.format(
|
||||||
name=user_config["email"],
|
name=email,
|
||||||
exp_time=time.strftime("%Y年%m月%d日 %H:%M:%S", time.localtime(float(user_config["jwt_exp"]))),
|
exp_time=exp_time,
|
||||||
send_time=time.strftime("%Y年%m月%d日 %H:%M:%S", time.localtime())
|
send_time=send_time
|
||||||
)
|
)
|
||||||
_send_email(user_config["email"], "接龙管家Token到期通知", html, email_settings)
|
|
||||||
|
|
||||||
def send_success_notification(user_config, email_settings):
|
return _send_email(email, "接龙管家Token到期通知", html, 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)
|
|
||||||
|
|
||||||
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:
|
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
|
||||||
@@ -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}")
|
||||||
@@ -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 +0,0 @@
|
|||||||
ThreadId,Signature,Texts,Values,jwt_sub,Authorization,jwt_exp,email
|
|
||||||
|
@@ -1,6 +0,0 @@
|
|||||||
[Email]
|
|
||||||
SmtpServer = __YOUR_EMAIL_PROVIDER__
|
|
||||||
SmtpPort = 465
|
|
||||||
SenderEmail = __YOUR_EMAIL__
|
|
||||||
# 重要提示:这里通常不是你的邮箱登录密码,而是邮箱服务商提供的“应用专用密码”或“授权码”
|
|
||||||
SenderPassword = __YOUR_EMAIL_AUTH_CODE__
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
# }
|
||||||
|
# }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# API Base URL (Development)
|
||||||
|
VITE_API_BASE_URL=http://localhost:8000
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# API Base URL (Production)
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
@@ -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?
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
Generated
+3008
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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 新增
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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 || '获取统计信息失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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 || '删除用户失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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: [],
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user