mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
refactor(structure): reorganize app layout
BREAKING CHANGE: root backend/frontend directories and old run/manage entrypoints were removed. Use apps/backend, apps/frontend, and python main.py commands instead.
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
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天内)
|
||||
# 使用 SQL 直接查询,避免 N+1 问题
|
||||
from backend.utils.time_helpers import now_timestamp
|
||||
|
||||
current_timestamp = now_timestamp()
|
||||
expiring_soon_timestamp = current_timestamp + (7 * 24 * 60 * 60) # 7天后
|
||||
|
||||
# 直接在数据库层面筛选即将过期的用户
|
||||
# 条件:authorization 不为空、jwt_exp 不为 "0"、且在未来 7 天内过期
|
||||
from sqlalchemy import cast, Integer, and_
|
||||
|
||||
expiring_users = db.query(User).filter(
|
||||
and_(
|
||||
User.authorization.isnot(None),
|
||||
User.authorization != "",
|
||||
User.jwt_exp.isnot(None),
|
||||
User.jwt_exp != "0",
|
||||
cast(User.jwt_exp, Integer) > current_timestamp, # 未过期
|
||||
cast(User.jwt_exp, Integer) < expiring_soon_timestamp # 7天内过期
|
||||
)
|
||||
).count()
|
||||
|
||||
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,192 @@
|
||||
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
|
||||
from backend.exceptions import BusinessLogicError
|
||||
from backend.limiter import limiter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/request_qrcode", response_model=dict, summary="请求 QQ 扫码二维码")
|
||||
@limiter.limit("10/minute") # 每分钟最多10次请求
|
||||
async def request_qrcode(
|
||||
request_obj: QRCodeRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
请求 QQ 扫码二维码
|
||||
|
||||
- **alias**: 用户别名
|
||||
|
||||
返回会话 ID,用于后续查询扫码状态
|
||||
"""
|
||||
from backend.services.registration_manager import registration_manager
|
||||
import secrets
|
||||
|
||||
# 检查注册限流 Cookie
|
||||
reg_cookie = request.cookies.get("reg_limit")
|
||||
|
||||
if reg_cookie:
|
||||
if not registration_manager.check_registration_cookie(reg_cookie):
|
||||
raise BusinessLogicError(
|
||||
message="注册过于频繁,请 10 分钟后再试",
|
||||
error_code="RATE_LIMIT_EXCEEDED",
|
||||
status_code=429
|
||||
)
|
||||
else:
|
||||
# 生成新的 Cookie
|
||||
reg_cookie = secrets.token_urlsafe(16)
|
||||
|
||||
# 获取客户端 IP
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
# 如果有代理,尝试从 X-Forwarded-For 获取真实 IP
|
||||
forwarded_for = request.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: 扫描成功(包含 JWT token 和 user 信息)
|
||||
- error: 发生错误
|
||||
|
||||
认证架构说明:
|
||||
- 扫码成功后返回 JWT token(用于网站登录,21天有效期)
|
||||
- 同时更新数据库中的 authorization token(用于打卡业务)
|
||||
- 两种 token 分别管理,互不影响
|
||||
"""
|
||||
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.delete("/qrcode_session/{session_id}", response_model=dict, summary="取消二维码登录会话")
|
||||
async def cancel_qrcode_session(
|
||||
session_id: str
|
||||
):
|
||||
"""
|
||||
取消二维码登录会话
|
||||
|
||||
- **session_id**: 会话 ID
|
||||
|
||||
用于用户关闭二维码对话框时,终止后台的 Selenium 进程
|
||||
"""
|
||||
try:
|
||||
result = AuthService.cancel_qrcode_session(session_id)
|
||||
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="验证 JWT Token 有效性")
|
||||
async def verify_token(
|
||||
request: TokenVerifyRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
验证 JWT Token 有效性(网站登录认证)
|
||||
|
||||
- **authorization**: JWT Token(可带或不带 "Bearer " 前缀)
|
||||
|
||||
返回 Token 是否有效以及用户信息
|
||||
|
||||
注意:
|
||||
- 此接口验证的是 JWT token(用于网站登录,21天有效期)
|
||||
- 不验证打卡业务的 authorization token(存储在数据库中)
|
||||
- JWT token 过期需要重新登录,但打卡 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="别名+密码登录")
|
||||
@limiter.limit("5/minute") # 每分钟最多5次登录尝试
|
||||
async def alias_login(
|
||||
login_data: AliasLoginRequest,
|
||||
request: Request, # slowapi需要的request参数
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
别名+密码登录(仅限已设置密码的用户)
|
||||
|
||||
- **alias**: 用户别名
|
||||
- **password**: 密码
|
||||
|
||||
返回登录结果,成功时包含 JWT token 和 user 信息
|
||||
|
||||
认证架构说明:
|
||||
- 登录成功后返回 JWT token(用于网站登录,21天有效期)
|
||||
- 如果数据库中的打卡 authorization token 过期,会返回警告信息
|
||||
- 打卡 token 过期不影响网站登录,但无法自动打卡,建议扫码更新
|
||||
|
||||
注意:
|
||||
- 用户必须已设置密码才能使用此方式登录
|
||||
- 即使打卡 token 已过期,仍然可以使用密码登录网站
|
||||
- 如需更新打卡 token,请使用扫码登录
|
||||
"""
|
||||
try:
|
||||
result = AuthService.alias_login(login_data.alias, login_data.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,234 @@
|
||||
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,
|
||||
PaginatedResponse,
|
||||
)
|
||||
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(失败)
|
||||
"""
|
||||
from backend.utils.db_helpers import get_or_404
|
||||
|
||||
# 获取打卡记录
|
||||
record = get_or_404(CheckInRecord, record_id, db, "打卡记录不存在")
|
||||
|
||||
# 验证记录归属(通过任务归属)
|
||||
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=PaginatedResponse[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, total = CheckInService.get_task_records(
|
||||
task_id, db, skip, limit, status_filter, trigger_type
|
||||
)
|
||||
return PaginatedResponse(
|
||||
records=records,
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取打卡记录失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/my-records", response_model=PaginatedResponse[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, total = CheckInService.get_user_records(
|
||||
current_user.id, db, skip, limit, status_filter, trigger_type
|
||||
)
|
||||
return PaginatedResponse(
|
||||
records=records,
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取打卡记录失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get("/records", response_model=PaginatedResponse[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, total = 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 PaginatedResponse(
|
||||
records=enriched_records,
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
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,215 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from datetime import datetime, timedelta
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.models import get_db, User
|
||||
from backend.schemas.task import TaskUpdate, TaskResponse
|
||||
from backend.services.task_service import TaskService
|
||||
from backend.dependencies import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CronValidateRequest(BaseModel):
|
||||
"""Cron 表达式验证请求"""
|
||||
cron_expression: str = Field(..., min_length=9, description="Crontab 表达式")
|
||||
|
||||
# create_task_from_template: 已在 templates.py 中定义
|
||||
|
||||
@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)
|
||||
):
|
||||
"""
|
||||
获取指定任务的详情
|
||||
|
||||
需要验证任务属于当前用户
|
||||
"""
|
||||
from backend.models import CheckInTask
|
||||
from backend.utils.db_helpers import get_owned_or_403
|
||||
|
||||
# 获取任务并验证归属
|
||||
task = get_owned_or_403(CheckInTask, task_id, current_user.id, db) # type: ignore
|
||||
|
||||
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)
|
||||
):
|
||||
"""
|
||||
更新指定任务的信息
|
||||
|
||||
需要验证任务属于当前用户
|
||||
"""
|
||||
from backend.models import CheckInTask
|
||||
from backend.utils.db_helpers import get_owned_or_403
|
||||
|
||||
# 验证任务归属并获取任务
|
||||
get_owned_or_403(CheckInTask, task_id, current_user.id, db) # type: ignore
|
||||
|
||||
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)
|
||||
):
|
||||
"""
|
||||
删除指定任务
|
||||
|
||||
需要验证任务属于当前用户,删除后会同时删除所有关联的打卡记录
|
||||
"""
|
||||
from backend.models import CheckInTask
|
||||
from backend.utils.db_helpers import get_owned_or_403
|
||||
|
||||
# 验证任务归属
|
||||
get_owned_or_403(CheckInTask, task_id, current_user.id, db) # type: ignore
|
||||
|
||||
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)
|
||||
):
|
||||
"""
|
||||
切换任务的启用/禁用状态
|
||||
|
||||
需要验证任务属于当前用户
|
||||
"""
|
||||
from backend.models import CheckInTask
|
||||
from backend.utils.db_helpers import get_owned_or_403
|
||||
|
||||
# 验证任务归属
|
||||
get_owned_or_403(CheckInTask, task_id, current_user.id, db) # type: ignore
|
||||
|
||||
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: CronValidateRequest):
|
||||
"""
|
||||
验证 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.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,214 @@
|
||||
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**: 任务名称(可选)
|
||||
- **cron_expression**: Cron 表达式(可选,默认每天 20:00)
|
||||
"""
|
||||
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,
|
||||
cron_expression=request.cron_expression
|
||||
)
|
||||
return task
|
||||
@@ -0,0 +1,282 @@
|
||||
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
|
||||
from backend.exceptions import ValidationError, AuthorizationError, ResourceNotFoundError
|
||||
|
||||
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)
|
||||
):
|
||||
"""
|
||||
创建用户(需要管理员权限)
|
||||
|
||||
- **alias**: 用户别名(用于登录)
|
||||
- **role**: 角色(可选,默认 "user")
|
||||
- **email**: 邮箱地址(可选)
|
||||
"""
|
||||
try:
|
||||
user = UserService.create_user(user_data, db)
|
||||
return user
|
||||
except ValueError as e:
|
||||
raise ValidationError(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,
|
||||
"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 ValidationError(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 状态(authorization token,非 JWT)
|
||||
|
||||
注意:此接口检查的是打卡业务 token,不是网站登录 JWT token
|
||||
"""
|
||||
from backend.services.auth_service import AuthService
|
||||
|
||||
# 使用统一的验证方法
|
||||
result = AuthService.verify_checkin_authorization(current_user)
|
||||
|
||||
return {
|
||||
"is_valid": result["is_valid"],
|
||||
"jwt_exp": current_user.jwt_exp,
|
||||
"expires_at": result.get("expires_at"),
|
||||
"days_until_expiry": result.get("days_remaining"),
|
||||
"expiring_soon": result.get("expiring_soon", False)
|
||||
}
|
||||
|
||||
|
||||
@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)"),
|
||||
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)
|
||||
- **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 AuthorizationError("权限不足,只能查看自己的信息")
|
||||
|
||||
user = UserService.get_user_by_id(user_id, db)
|
||||
if not user:
|
||||
raise ResourceNotFoundError(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 AuthorizationError("权限不足,只能更新自己的信息")
|
||||
# 普通用户不能修改 role
|
||||
if user_data.role is not None:
|
||||
raise AuthorizationError("普通用户不能修改角色")
|
||||
|
||||
try:
|
||||
# 获取更新前的用户状态
|
||||
old_user = UserService.get_user_by_id(user_id, db)
|
||||
if not old_user:
|
||||
raise ResourceNotFoundError(f"用户 ID {user_id} 不存在")
|
||||
|
||||
# 保存更新前的审批状态 (先读取后转换为 Python bool)
|
||||
old_approved_value = old_user.is_approved
|
||||
was_approved_before = True if old_approved_value else False
|
||||
|
||||
# 更新用户信息
|
||||
user = UserService.update_user(user_id, user_data, db)
|
||||
|
||||
# 检查是否需要发送审批通过邮件
|
||||
new_approved_value = user.is_approved
|
||||
is_approved_now = True if new_approved_value else False
|
||||
|
||||
is_admin = (current_user.role == "admin")
|
||||
needs_notification = (is_admin and (not was_approved_before) and is_approved_now)
|
||||
|
||||
if needs_notification:
|
||||
try:
|
||||
from backend.services.email_service import EmailService
|
||||
EmailService.notify_user_approved(user)
|
||||
except Exception as e:
|
||||
# 邮件发送失败不影响审批操作
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"发送审批通过邮件失败: {e}")
|
||||
|
||||
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 ResourceNotFoundError(str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"删除用户失败: {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import List
|
||||
|
||||
# 项目根目录
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
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"
|
||||
|
||||
# 安全配置(登录)
|
||||
SECRET_KEY: str = "CheckInSecretKey"
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL: str = f"sqlite:///{BASE_DIR}/data/checkin.db"
|
||||
|
||||
# CORS 配置(从环境变量读取,用逗号分隔)
|
||||
CORS_ORIGINS: str = "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
|
||||
|
||||
# 前端 URL 配置(用于邮件中的链接)
|
||||
FRONTEND_URL: str = "http://localhost:3000"
|
||||
|
||||
# 定时任务配置(可通过环境变量配置)
|
||||
TOKEN_CHECK_INTERVAL_MINUTES: int = 30 # Token 检查间隔(分钟)
|
||||
SESSION_CLEANUP_INTERVAL_HOURS: int = 24 # 会话清理间隔(小时)
|
||||
|
||||
# Selenium / Chrome 配置(从 .env 读取)
|
||||
CHROME_BINARY_PATH: str = ""
|
||||
CHROMEDRIVER_PATH: str = ""
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,124 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import logging
|
||||
import jwt as pyjwt
|
||||
from fastapi import Depends, HTTPException, Header, status
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.models import get_db, User
|
||||
from backend.utils.jwt import JWTManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
authorization: Optional[str] = Header(None),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
"""
|
||||
获取当前用户(使用 JWT 认证)
|
||||
|
||||
认证说明:
|
||||
1. 网站登录使用 JWT token(存储在前端,21天过期)
|
||||
2. 打卡业务使用 authorization token(存储在数据库 User.authorization)
|
||||
3. JWT 过期后需要重新登录,但打卡 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
|
||||
|
||||
try:
|
||||
# 验证 JWT token
|
||||
payload = JWTManager.verify_token(token)
|
||||
user_id = payload.get("user_id")
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token 格式错误",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 从数据库获取用户
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
except pyjwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="登录已过期,请重新登录",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
except pyjwt.InvalidTokenError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证信息",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"认证失败: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="认证失败",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
自定义异常类
|
||||
|
||||
提供统一的异常处理机制,避免直接抛出通用Exception
|
||||
"""
|
||||
|
||||
|
||||
class BaseAPIException(Exception):
|
||||
"""API 异常基类"""
|
||||
|
||||
def __init__(self, message: str, status_code: int = 500, error_code: str = None):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.error_code = error_code or self.__class__.__name__
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class ValidationError(BaseAPIException):
|
||||
"""验证错误 - 400"""
|
||||
|
||||
def __init__(self, message: str, error_code: str = "VALIDATION_ERROR"):
|
||||
super().__init__(message, status_code=400, error_code=error_code)
|
||||
|
||||
|
||||
class AuthenticationError(BaseAPIException):
|
||||
"""认证错误 - 401"""
|
||||
|
||||
def __init__(self, message: str = "未授权", error_code: str = "AUTHENTICATION_ERROR"):
|
||||
super().__init__(message, status_code=401, error_code=error_code)
|
||||
|
||||
|
||||
class AuthorizationError(BaseAPIException):
|
||||
"""授权错误 - 403"""
|
||||
|
||||
def __init__(self, message: str = "无权限访问", error_code: str = "AUTHORIZATION_ERROR"):
|
||||
super().__init__(message, status_code=403, error_code=error_code)
|
||||
|
||||
|
||||
class ResourceNotFoundError(BaseAPIException):
|
||||
"""资源未找到 - 404"""
|
||||
|
||||
def __init__(self, message: str = "资源未找到", error_code: str = "NOT_FOUND"):
|
||||
super().__init__(message, status_code=404, error_code=error_code)
|
||||
|
||||
|
||||
class ResourceConflictError(BaseAPIException):
|
||||
"""资源冲突 - 409"""
|
||||
|
||||
def __init__(self, message: str = "资源冲突", error_code: str = "CONFLICT"):
|
||||
super().__init__(message, status_code=409, error_code=error_code)
|
||||
|
||||
|
||||
class BusinessLogicError(BaseAPIException):
|
||||
"""业务逻辑错误 - 默认422,但可自定义状态码(如429)"""
|
||||
|
||||
def __init__(self, message: str, error_code: str = "BUSINESS_ERROR", status_code: int = 422):
|
||||
super().__init__(message, status_code=status_code, error_code=error_code)
|
||||
|
||||
|
||||
class InternalServerError(BaseAPIException):
|
||||
"""服务器内部错误 - 500"""
|
||||
|
||||
def __init__(self, message: str = "服务器内部错误", error_code: str = "INTERNAL_ERROR"):
|
||||
super().__init__(message, status_code=500, error_code=error_code)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
速率限制器配置
|
||||
|
||||
支持Cloudflare Tunnel和其他代理服务
|
||||
"""
|
||||
from slowapi import Limiter
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
def get_real_ip(request: Request) -> str:
|
||||
"""
|
||||
获取用户真实IP地址(支持Cloudflare Tunnel)
|
||||
|
||||
Cloudflare会设置以下请求头:
|
||||
- CF-Connecting-IP: 用户真实IP (最可靠)
|
||||
- X-Forwarded-For: 代理链中的IP列表
|
||||
- X-Real-IP: 原始请求IP
|
||||
|
||||
优先级:
|
||||
1. CF-Connecting-IP (Cloudflare专用,最可靠)
|
||||
2. X-Real-IP (Nginx/通用代理)
|
||||
3. X-Forwarded-For (标准代理头)
|
||||
4. request.client.host (直连)
|
||||
"""
|
||||
# Cloudflare Tunnel / Cloudflare CDN
|
||||
cf_connecting_ip = request.headers.get("CF-Connecting-IP")
|
||||
if cf_connecting_ip:
|
||||
return cf_connecting_ip
|
||||
|
||||
# Nginx或其他反向代理
|
||||
x_real_ip = request.headers.get("X-Real-IP")
|
||||
if x_real_ip:
|
||||
return x_real_ip
|
||||
|
||||
# 标准代理头(取第一个IP)
|
||||
x_forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if x_forwarded_for:
|
||||
return x_forwarded_for.split(",")[0].strip()
|
||||
|
||||
# 直连(无代理)
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
# 初始化速率限制器,使用自定义IP获取函数
|
||||
limiter = Limiter(key_func=get_real_ip)
|
||||
@@ -0,0 +1,176 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from backend.config import settings
|
||||
from backend.models import init_db
|
||||
from backend.exceptions import BaseAPIException
|
||||
from backend.schemas.response import ErrorResponse, ErrorDetail
|
||||
from backend.limiter import limiter
|
||||
|
||||
# 配置日志
|
||||
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,
|
||||
)
|
||||
|
||||
# 绑定速率限制器到应用
|
||||
app.state.limiter = limiter
|
||||
|
||||
# 配置 CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list, # 使用属性方法获取列表
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# 全局异常处理器
|
||||
@app.exception_handler(BaseAPIException)
|
||||
async def api_exception_handler(request: Request, exc: BaseAPIException):
|
||||
"""处理自定义 API 异常"""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code=exc.error_code,
|
||||
message=exc.message
|
||||
)
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""处理请求验证错误"""
|
||||
errors = exc.errors()
|
||||
# 取第一个错误作为主要错误消息
|
||||
first_error = errors[0] if errors else {}
|
||||
field = ".".join(str(loc) for loc in first_error.get("loc", []))
|
||||
message = first_error.get("msg", "验证错误")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content=ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code="VALIDATION_ERROR",
|
||||
message=message,
|
||||
field=field or None
|
||||
)
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""处理未捕获的异常"""
|
||||
logger.error(f"未处理的异常: {type(exc).__name__}: {str(exc)}", exc_info=True)
|
||||
|
||||
# 不向客户端暴露内部错误详情
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code="INTERNAL_ERROR",
|
||||
message="服务器内部错误,请稍后重试"
|
||||
)
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
|
||||
# 健康检查端点
|
||||
@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(
|
||||
"backend.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=True,
|
||||
reload_dirs=[str(settings.BASE_DIR / "apps" / "backend")],
|
||||
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,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,46 @@
|
||||
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 过期时间戳")
|
||||
token_expiring_notified = Column(Boolean, default=False, nullable=False, comment="Token 即将过期提醒是否已发送(过期前30分钟)")
|
||||
token_expired_notified = Column(Boolean, default=False, nullable=False, comment="Token 已过期提醒是否已发送(过期后30分钟内)")
|
||||
role = Column(String(20), default="user", index=True, comment="角色: user/admin")
|
||||
is_approved = Column(Boolean, default=False, index=True, comment="是否已通过管理员审批")
|
||||
|
||||
# 账户锁定相关字段
|
||||
failed_login_attempts = Column(Integer, default=0, nullable=False, comment="连续登录失败次数")
|
||||
locked_until = Column(DateTime(timezone=True), nullable=True, comment="账户锁定到期时间")
|
||||
last_failed_login = Column(DateTime(timezone=True), nullable=True, comment="最后一次登录失败时间")
|
||||
|
||||
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,27 @@
|
||||
# Web Framework
|
||||
fastapi>=0.115.12
|
||||
uvicorn[standard]>=0.34.0
|
||||
|
||||
# Database
|
||||
sqlalchemy>=2.0.36
|
||||
|
||||
# Validation & Settings
|
||||
pydantic[email]>=2.10.6
|
||||
pydantic-settings>=2.7.1
|
||||
python-dotenv>=1.0.1
|
||||
|
||||
# Authentication & Security
|
||||
pyjwt>=2.10.1
|
||||
bcrypt>=4.2.2
|
||||
slowapi>=0.1.9
|
||||
|
||||
# Task Scheduling
|
||||
apscheduler>=3.10.4
|
||||
croniter>=5.0.3
|
||||
|
||||
# Automation
|
||||
selenium>=4.28.1
|
||||
filelock>=3.16.1
|
||||
|
||||
# HTTP & Utilities
|
||||
requests>=2.32.3
|
||||
@@ -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,58 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Generic, TypeVar
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
"""分页响应 Schema"""
|
||||
records: List[T] = Field(..., description="记录列表")
|
||||
total: int = Field(..., description="总记录数")
|
||||
skip: int = Field(..., description="跳过的记录数")
|
||||
limit: int = Field(..., description="每页记录数")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
统一的 API 响应 Schema
|
||||
"""
|
||||
from typing import Generic, TypeVar, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class ApiResponse(BaseModel, Generic[T]):
|
||||
"""统一成功响应"""
|
||||
success: bool = True
|
||||
data: Optional[T] = None
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
"""错误详情"""
|
||||
code: str
|
||||
message: str
|
||||
field: Optional[str] = None # 字段验证错误时使用
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""统一错误响应"""
|
||||
success: bool = False
|
||||
error: ErrorDetail
|
||||
@@ -0,0 +1,148 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
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 字段
|
||||
"""
|
||||
from backend.utils.json_helpers import safe_parse_json, extract_thread_id
|
||||
|
||||
if not v or not v.strip():
|
||||
raise ValueError("payload_config 不能为空")
|
||||
|
||||
payload = safe_parse_json(v)
|
||||
if payload is None:
|
||||
raise ValueError("payload_config 必须是有效的 JSON 格式")
|
||||
|
||||
# 检查是否为字典类型
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("payload_config 必须是 JSON 对象(字典)")
|
||||
|
||||
# 检查必需字段 ThreadId
|
||||
thread_id = extract_thread_id(v)
|
||||
if not thread_id or not str(thread_id).strip():
|
||||
raise ValueError("payload_config 必须包含有效的 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(如果提供的话)
|
||||
"""
|
||||
from backend.utils.json_helpers import safe_parse_json, extract_thread_id
|
||||
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
if not v.strip():
|
||||
raise ValueError("payload_config 不能为空字符串")
|
||||
|
||||
payload = safe_parse_json(v)
|
||||
if payload is None:
|
||||
raise ValueError("payload_config 必须是有效的 JSON 格式")
|
||||
|
||||
# 检查是否为字典类型
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("payload_config 必须是 JSON 对象(字典)")
|
||||
|
||||
# 检查必需字段 ThreadId
|
||||
thread_id = extract_thread_id(v)
|
||||
if not thread_id or not str(thread_id).strip():
|
||||
raise ValueError("payload_config 必须包含有效的 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,148 @@
|
||||
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="任务名称(可选)")
|
||||
cron_expression: Optional[str] = Field("0 20 * * *", description="Cron 表达式(可选,默认每天 20:00)")
|
||||
|
||||
|
||||
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, EmailStr
|
||||
|
||||
|
||||
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[EmailStr] = Field(None, description="邮箱地址")
|
||||
password: Optional[str] = Field(None, min_length=6, description="初始密码(可选)")
|
||||
is_approved: Optional[bool] = Field(True, 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[EmailStr] = 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[EmailStr] = 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
|
||||
role: str
|
||||
is_approved: bool
|
||||
jwt_exp: str
|
||||
email: Optional[EmailStr] = 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
|
||||
expires_at: Optional[int] = None # Unix 时间戳(秒)
|
||||
days_until_expiry: Optional[int] = None
|
||||
expiring_soon: bool = False # 是否即将过期(30分钟内)
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
创建管理员用户的脚本
|
||||
|
||||
使用方法:
|
||||
PYTHONPATH=apps python apps/backend/scripts/create_admin.py
|
||||
|
||||
或使用虚拟环境:
|
||||
PYTHONPATH=apps ./venv/bin/python apps/backend/scripts/create_admin.py
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APPS_DIR = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(APPS_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,78 @@
|
||||
"""
|
||||
数据库迁移脚本:添加账户锁定相关字段
|
||||
|
||||
添加字段:
|
||||
- failed_login_attempts: 连续登录失败次数
|
||||
- locked_until: 账户锁定到期时间
|
||||
- last_failed_login: 最后一次登录失败时间
|
||||
|
||||
运行方式:
|
||||
PYTHONPATH=apps python -m backend.scripts.migrate_add_account_lockout
|
||||
python -m backend.scripts.migrate_add_account_lockout
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APPS_DIR = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(APPS_DIR))
|
||||
|
||||
from sqlalchemy import text
|
||||
from backend.models.database import engine
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate():
|
||||
"""执行迁移"""
|
||||
logger.info("开始迁移:添加账户锁定相关字段...")
|
||||
|
||||
with engine.connect() as conn:
|
||||
# 检查字段是否已存在
|
||||
result = conn.execute(text("PRAGMA table_info(users)"))
|
||||
columns = [row[1] for row in result]
|
||||
|
||||
# 添加 failed_login_attempts 字段
|
||||
if 'failed_login_attempts' not in columns:
|
||||
logger.info("添加 failed_login_attempts 字段...")
|
||||
conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER DEFAULT 0 NOT NULL"
|
||||
))
|
||||
conn.commit()
|
||||
logger.info("✓ failed_login_attempts 字段添加成功")
|
||||
else:
|
||||
logger.info("✓ failed_login_attempts 字段已存在,跳过")
|
||||
|
||||
# 添加 locked_until 字段
|
||||
if 'locked_until' not in columns:
|
||||
logger.info("添加 locked_until 字段...")
|
||||
conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN locked_until DATETIME"
|
||||
))
|
||||
conn.commit()
|
||||
logger.info("✓ locked_until 字段添加成功")
|
||||
else:
|
||||
logger.info("✓ locked_until 字段已存在,跳过")
|
||||
|
||||
# 添加 last_failed_login 字段
|
||||
if 'last_failed_login' not in columns:
|
||||
logger.info("添加 last_failed_login 字段...")
|
||||
conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN last_failed_login DATETIME"
|
||||
))
|
||||
conn.commit()
|
||||
logger.info("✓ last_failed_login 字段添加成功")
|
||||
else:
|
||||
logger.info("✓ last_failed_login 字段已存在,跳过")
|
||||
|
||||
logger.info("✅ 迁移完成!账户锁定功能已启用")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
migrate()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 迁移失败: {e}")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
测试新的异常处理系统
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APPS_DIR = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(APPS_DIR))
|
||||
|
||||
from backend.exceptions import (
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
ResourceNotFoundError,
|
||||
BusinessLogicError,
|
||||
)
|
||||
from backend.schemas.response import ErrorResponse, ErrorDetail
|
||||
|
||||
def test_exceptions():
|
||||
"""测试自定义异常"""
|
||||
print("=" * 60)
|
||||
print("测试自定义异常类")
|
||||
print("=" * 60)
|
||||
|
||||
# 测试 ValidationError
|
||||
try:
|
||||
raise ValidationError("用户名长度必须在2-50之间")
|
||||
except ValidationError as e:
|
||||
print(f"✅ ValidationError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
|
||||
|
||||
# 测试 AuthenticationError
|
||||
try:
|
||||
raise AuthenticationError("Token已过期")
|
||||
except AuthenticationError as e:
|
||||
print(f"✅ AuthenticationError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
|
||||
|
||||
# 测试 AuthorizationError
|
||||
try:
|
||||
raise AuthorizationError("需要管理员权限")
|
||||
except AuthorizationError as e:
|
||||
print(f"✅ AuthorizationError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
|
||||
|
||||
# 测试 ResourceNotFoundError
|
||||
try:
|
||||
raise ResourceNotFoundError("用户不存在")
|
||||
except ResourceNotFoundError as e:
|
||||
print(f"✅ ResourceNotFoundError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
|
||||
|
||||
# 测试 BusinessLogicError
|
||||
try:
|
||||
raise BusinessLogicError("打卡任务已禁用")
|
||||
except BusinessLogicError as e:
|
||||
print(f"✅ BusinessLogicError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
|
||||
|
||||
|
||||
def test_response_schemas():
|
||||
"""测试响应 Schema"""
|
||||
print("\n" + "=" * 60)
|
||||
print("测试响应 Schema")
|
||||
print("=" * 60)
|
||||
|
||||
# 测试 ErrorResponse
|
||||
error_response = ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code="VALIDATION_ERROR",
|
||||
message="邮箱格式不正确",
|
||||
field="email"
|
||||
)
|
||||
)
|
||||
|
||||
response_dict = error_response.model_dump()
|
||||
print(f"✅ ErrorResponse 序列化成功:")
|
||||
print(f" {response_dict}")
|
||||
|
||||
assert response_dict["success"] == False
|
||||
assert response_dict["error"]["code"] == "VALIDATION_ERROR"
|
||||
assert response_dict["error"]["message"] == "邮箱格式不正确"
|
||||
assert response_dict["error"]["field"] == "email"
|
||||
print("✅ 所有断言通过")
|
||||
|
||||
|
||||
def check_old_exception_patterns():
|
||||
"""检查旧的异常处理模式"""
|
||||
print("\n" + "=" * 60)
|
||||
print("检查需要更新的旧异常代码")
|
||||
print("=" * 60)
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
patterns = {
|
||||
"HTTPException with detail": r'raise HTTPException.*detail=f?".*{',
|
||||
"except Exception": r'except Exception as',
|
||||
}
|
||||
|
||||
results = {}
|
||||
for pattern_name, pattern in patterns.items():
|
||||
results[pattern_name] = []
|
||||
|
||||
for root, dirs, files in os.walk(APPS_DIR / 'backend' / 'api'):
|
||||
for file in files:
|
||||
if file.endswith('.py'):
|
||||
filepath = os.path.join(root, file)
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
matches = re.findall(pattern, content, re.MULTILINE)
|
||||
if matches:
|
||||
results[pattern_name].append((filepath, len(matches)))
|
||||
|
||||
for pattern_name, files in results.items():
|
||||
print(f"\n{pattern_name}:")
|
||||
if files:
|
||||
print(f" ⚠️ 发现 {sum(count for _, count in files)} 处使用")
|
||||
for filepath, count in files:
|
||||
print(f" - {filepath}: {count} 处")
|
||||
else:
|
||||
print(f" ✅ 未发现使用")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_exceptions()
|
||||
test_response_schemas()
|
||||
check_old_exception_patterns()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ 所有测试通过!新的异常处理系统工作正常")
|
||||
print("=" * 60)
|
||||
@@ -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,628 @@
|
||||
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
|
||||
from backend.utils.jwt import JWTManager
|
||||
|
||||
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:
|
||||
logger.warning(f"用户 {alias} 是测试账号(未绑定 QQ),禁止扫码登录")
|
||||
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.token_expiring_notified = False # 重置"即将过期"提醒标志
|
||||
user.token_expired_notified = False # 重置"已过期"提醒标志
|
||||
user.updated_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info(f"更新已注册用户 {user.alias} 的 Token")
|
||||
|
||||
# 生成 JWT access token(用于网站登录)
|
||||
access_token = JWTManager.create_access_token(user.id, user.alias)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "登录成功",
|
||||
"token": access_token, # 返回 JWT 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": "注册失败:用户名已被占用,请更换用户名"
|
||||
}
|
||||
|
||||
# 创建新用户(待审批状态)
|
||||
new_user = User(
|
||||
jwt_sub=jwt_sub,
|
||||
alias=alias,
|
||||
authorization=pure_token, # 存储清理后的 token
|
||||
jwt_exp=jwt_exp,
|
||||
role="user",
|
||||
is_approved=False, # 待审批
|
||||
)
|
||||
|
||||
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}")
|
||||
|
||||
# 生成 JWT access token(用于网站登录)
|
||||
access_token = JWTManager.create_access_token(new_user.id, new_user.alias)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "注册成功,请等待管理员审批(24小时内)",
|
||||
"token": access_token, # 返回 JWT 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]:
|
||||
"""
|
||||
验证 JWT Token 有效性
|
||||
|
||||
Args:
|
||||
authorization: JWT Token(可带或不带 "Bearer " 前缀)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
包含验证结果的字典
|
||||
"""
|
||||
from backend.utils.jwt import JWTManager
|
||||
|
||||
# 移除 "Bearer " 前缀
|
||||
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
|
||||
|
||||
try:
|
||||
# 验证 JWT token
|
||||
payload = JWTManager.verify_token(token)
|
||||
user_id = payload.get("user_id")
|
||||
|
||||
if not user_id:
|
||||
return {
|
||||
"is_valid": False,
|
||||
"message": "Token 格式错误"
|
||||
}
|
||||
|
||||
# 从数据库获取用户
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
if not user:
|
||||
return {
|
||||
"is_valid": False,
|
||||
"message": "用户不存在"
|
||||
}
|
||||
|
||||
return {
|
||||
"is_valid": True,
|
||||
"message": "Token 有效",
|
||||
"user_id": user.id,
|
||||
"alias": user.alias,
|
||||
"role": user.role,
|
||||
"is_approved": user.is_approved
|
||||
}
|
||||
|
||||
except pyjwt.ExpiredSignatureError:
|
||||
return {
|
||||
"is_valid": False,
|
||||
"message": "JWT Token 已过期"
|
||||
}
|
||||
except pyjwt.InvalidTokenError:
|
||||
return {
|
||||
"is_valid": False,
|
||||
"message": "JWT Token 无效"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"验证 JWT Token 失败: {str(e)}")
|
||||
return {
|
||||
"is_valid": False,
|
||||
"message": "Token 验证失败"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def verify_checkin_authorization(user: User) -> Dict[str, Any]:
|
||||
"""
|
||||
验证打卡业务 authorization token 的有效性
|
||||
|
||||
注意:这与 JWT token 验证不同
|
||||
- JWT token 用于网站登录认证
|
||||
- authorization token 用于打卡业务操作(存储在 User.authorization)
|
||||
|
||||
Args:
|
||||
user: 用户对象
|
||||
|
||||
Returns:
|
||||
包含打卡 token 验证结果的字典
|
||||
"""
|
||||
from backend.utils.time_helpers import (
|
||||
parse_jwt_exp,
|
||||
is_timestamp_expired,
|
||||
days_until_expiry,
|
||||
minutes_until_expiry,
|
||||
seconds_until_expiry
|
||||
)
|
||||
|
||||
# 检查是否有 authorization token
|
||||
if not user.authorization or user.authorization == "":
|
||||
return {
|
||||
"is_valid": False,
|
||||
"message": "未设置打卡凭证",
|
||||
"reason": "no_token"
|
||||
}
|
||||
|
||||
# 解析 jwt_exp
|
||||
exp_timestamp = parse_jwt_exp(user.jwt_exp)
|
||||
if not exp_timestamp:
|
||||
return {
|
||||
"is_valid": False,
|
||||
"message": "打卡凭证无效",
|
||||
"reason": "invalid_expiry"
|
||||
}
|
||||
|
||||
# 检查是否过期
|
||||
if is_timestamp_expired(exp_timestamp):
|
||||
days_expired = abs(days_until_expiry(exp_timestamp))
|
||||
return {
|
||||
"is_valid": False,
|
||||
"message": f"打卡凭证已过期 {days_expired} 天",
|
||||
"reason": "expired",
|
||||
"days_expired": days_expired
|
||||
}
|
||||
|
||||
# Token 有效,计算剩余时间
|
||||
seconds_remaining = seconds_until_expiry(exp_timestamp)
|
||||
days_remaining = days_until_expiry(exp_timestamp)
|
||||
minutes_remaining = minutes_until_expiry(exp_timestamp)
|
||||
|
||||
# 判断是否即将过期(30分钟内)
|
||||
expiring_soon = minutes_remaining <= 30
|
||||
|
||||
return {
|
||||
"is_valid": True,
|
||||
"message": "打卡凭证有效",
|
||||
"days_remaining": days_remaining,
|
||||
"minutes_remaining": minutes_remaining,
|
||||
"expiring_soon": expiring_soon,
|
||||
"expires_at": exp_timestamp
|
||||
}
|
||||
|
||||
@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 user.locked_until:
|
||||
# 如果锁定时间还未到期
|
||||
if datetime.now() < user.locked_until:
|
||||
remaining_seconds = (user.locked_until - datetime.now()).total_seconds()
|
||||
remaining_minutes = int(remaining_seconds / 60) + 1
|
||||
logger.warning(f"别名登录失败:用户 {alias} 账户已锁定,剩余 {remaining_minutes} 分钟")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"账户已锁定,请 {remaining_minutes} 分钟后再试"
|
||||
}
|
||||
else:
|
||||
# 锁定时间已过,重置锁定状态
|
||||
user.locked_until = None
|
||||
user.failed_login_attempts = 0
|
||||
db.commit()
|
||||
logger.info(f"用户 {alias} 的账户锁定已自动解除")
|
||||
|
||||
# 检查用户是否设置了密码
|
||||
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):
|
||||
# 密码错误,增加失败次数
|
||||
user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
|
||||
user.last_failed_login = datetime.now()
|
||||
|
||||
# 如果失败次数达到5次,锁定账户15分钟
|
||||
if user.failed_login_attempts >= 5:
|
||||
user.locked_until = datetime.now() + timedelta(minutes=15)
|
||||
db.commit()
|
||||
logger.warning(f"别名登录失败:用户 {alias} 密码错误次数过多,账户已锁定15分钟")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "密码错误次数过多,账户已锁定15分钟"
|
||||
}
|
||||
|
||||
db.commit()
|
||||
remaining_attempts = 5 - user.failed_login_attempts
|
||||
logger.warning(f"别名登录失败:用户 {alias} 密码错误,剩余尝试次数: {remaining_attempts}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"用户名或密码错误,剩余尝试次数: {remaining_attempts}"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"密码验证异常:{e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "登录失败,请稍后重试"
|
||||
}
|
||||
|
||||
# 密码正确,重置失败次数
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
user.last_failed_login = None
|
||||
db.commit()
|
||||
|
||||
# 检查 Token 状态(仅作提示,不阻止登录)
|
||||
token_warning = None
|
||||
|
||||
if not user.authorization or user.jwt_exp == "0":
|
||||
logger.info(f"用户 {alias} Token 无效,允许密码登录但需提示用户更新")
|
||||
token_warning = "token_invalid"
|
||||
else:
|
||||
# 检查 Token 是否过期
|
||||
from backend.utils.time_helpers import parse_jwt_exp, is_timestamp_expired
|
||||
|
||||
exp_timestamp = parse_jwt_exp(user.jwt_exp)
|
||||
if exp_timestamp and is_timestamp_expired(exp_timestamp):
|
||||
logger.info(f"用户 {alias} Token 已过期,允许密码登录但需提示用户更新")
|
||||
token_warning = "token_expired"
|
||||
|
||||
# 登录成功
|
||||
logger.info(f"✅ 用户 {alias} (ID: {user.id}) 别名登录成功")
|
||||
|
||||
# 生成 JWT access token(用于网站登录)
|
||||
access_token = JWTManager.create_access_token(user.id, user.alias)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"message": "登录成功",
|
||||
"token": access_token, # 返回 JWT token(用于网站登录)
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"alias": user.alias,
|
||||
"role": user.role,
|
||||
"is_approved": user.is_approved
|
||||
}
|
||||
}
|
||||
|
||||
# 如果打卡 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
|
||||
|
||||
@staticmethod
|
||||
def cancel_qrcode_session(session_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
取消二维码登录会话
|
||||
|
||||
Args:
|
||||
session_id: 会话 ID
|
||||
|
||||
Returns:
|
||||
包含取消结果的字典
|
||||
"""
|
||||
from backend.workers.token_refresher import cancel_session
|
||||
|
||||
success = cancel_session(session_id)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "会话已取消"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "取消失败或会话不存在"
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
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 handle_token_expired(user: User, task: CheckInTask, db: Session) -> None:
|
||||
"""
|
||||
处理 Token 过期情况:发送邮件通知并标记标志位
|
||||
|
||||
Args:
|
||||
user: 用户对象
|
||||
task: 打卡任务对象
|
||||
db: 数据库会话
|
||||
"""
|
||||
if not user or not user.email:
|
||||
return
|
||||
|
||||
# 检查是否已经发送过通知
|
||||
if user.token_expired_notified:
|
||||
logger.debug(f"用户 {user.alias} 已发送过 Token 过期通知,跳过")
|
||||
return
|
||||
|
||||
try:
|
||||
from backend.services.email_service import EmailService
|
||||
from backend.utils.json_helpers import build_task_info
|
||||
|
||||
# 使用辅助函数构建 task_info
|
||||
task_info = build_task_info(task)
|
||||
|
||||
# 发送打卡失败通知(内容包含 Token 失效说明和刷新指引)
|
||||
EmailService.notify_check_in_result(user, task_info, False, "Token 已失效,需要重新授权")
|
||||
logger.info(f"已发送 Token 过期邮件到 {user.email}")
|
||||
|
||||
# 标记已发送 Token 过期通知
|
||||
user.token_expired_notified = True
|
||||
db.commit()
|
||||
logger.info(f"标记用户 {user.alias} 的 token_expired_notified 为 True")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理 Token 过期失败: {e}")
|
||||
|
||||
@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)
|
||||
|
||||
# 如果是 Token 过期导致的失败,处理 Token 过期情况
|
||||
if result["status"] == "token_expired" and task.user:
|
||||
CheckInService.handle_token_expired(task.user, task, db)
|
||||
|
||||
# 更新记录
|
||||
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,交给统一的打卡逻辑处理
|
||||
# 这样可以确保所有错误(包括 Token 过期)都通过统一的流程处理
|
||||
|
||||
# 创建待处理记录
|
||||
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 验证方法
|
||||
from backend.services.auth_service import AuthService
|
||||
token_result = AuthService.verify_checkin_authorization(user)
|
||||
|
||||
if not token_result["is_valid"]:
|
||||
error_msg = token_result["message"]
|
||||
logger.warning(f"⏰ {error_msg} - 用户: {user.alias}, Task ID: {task.id}")
|
||||
|
||||
# 处理 Token 过期:发送邮件并标记
|
||||
CheckInService.handle_token_expired(user, task, db)
|
||||
|
||||
# 记录失败
|
||||
record = CheckInRecord(
|
||||
task_id=task.id,
|
||||
status="token_expired", # 使用统一的状态标识
|
||||
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
|
||||
}
|
||||
|
||||
# 执行打卡(传递 task 对象和用户 token)
|
||||
logger.info(f"🤖 调用 Selenium Worker 执行打卡...")
|
||||
result = perform_check_in(task, user.authorization)
|
||||
|
||||
# 如果是 Token 过期导致的失败,处理 Token 过期情况
|
||||
if result["status"] == "token_expired" and user:
|
||||
CheckInService.handle_token_expired(user, task, db)
|
||||
|
||||
# 保存打卡记录
|
||||
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 get_task_records(
|
||||
task_id: int,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[str] = None,
|
||||
trigger_type: Optional[str] = None
|
||||
) -> tuple[List[CheckInRecord], int]:
|
||||
"""
|
||||
获取任务的打卡记录
|
||||
|
||||
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)
|
||||
|
||||
# 获取总数
|
||||
total = query.count()
|
||||
|
||||
# 获取分页数据
|
||||
records = query.order_by(
|
||||
CheckInRecord.check_in_time.desc()
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
return records, total
|
||||
|
||||
@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
|
||||
) -> tuple[List[CheckInRecord], int]:
|
||||
"""
|
||||
获取用户的所有打卡记录
|
||||
|
||||
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)
|
||||
|
||||
# 获取总数
|
||||
total = query.count()
|
||||
|
||||
# 获取分页数据
|
||||
records = query.order_by(
|
||||
CheckInRecord.check_in_time.desc()
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
return records, total
|
||||
|
||||
@staticmethod
|
||||
def get_all_records(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
task_id: Optional[int] = None,
|
||||
status: Optional[str] = None
|
||||
) -> tuple[List[CheckInRecord], int]:
|
||||
"""
|
||||
获取所有打卡记录(管理员)- 使用联表查询优化性能
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
skip: 跳过记录数
|
||||
limit: 限制记录数
|
||||
task_id: 过滤任务 ID
|
||||
status: 过滤状态
|
||||
|
||||
Returns:
|
||||
(打卡记录列表, 总记录数)
|
||||
"""
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
# 使用 joinedload 预加载关联的 task 和 user,避免 N+1 查询
|
||||
query = db.query(CheckInRecord).options(
|
||||
joinedload(CheckInRecord.task).joinedload(CheckInTask.user)
|
||||
)
|
||||
|
||||
if task_id:
|
||||
query = query.filter(CheckInRecord.task_id == task_id)
|
||||
|
||||
if status:
|
||||
query = query.filter(CheckInRecord.status == status)
|
||||
|
||||
# 获取总数
|
||||
total = query.count()
|
||||
|
||||
# 获取分页数据
|
||||
records = query.order_by(
|
||||
CheckInRecord.check_in_time.desc()
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
return records, total
|
||||
|
||||
@staticmethod
|
||||
def enrich_record_with_user_task_info(record: CheckInRecord, db: Session) -> dict:
|
||||
"""
|
||||
为打卡记录添加用户和任务信息
|
||||
|
||||
注意:如果使用了 joinedload,task 和 user 已经预加载,不会产生额外查询
|
||||
|
||||
Args:
|
||||
record: 打卡记录对象
|
||||
db: 数据库会话(可选,仅在未使用 joinedload 时使用)
|
||||
|
||||
Returns:
|
||||
包含额外信息的记录字典
|
||||
"""
|
||||
# 尝试使用已加载的关联对象,如果没有则查询
|
||||
task = record.task if hasattr(record, 'task') and record.task else \
|
||||
db.query(CheckInTask).filter(CheckInTask.id == record.task_id).first()
|
||||
|
||||
# 获取用户信息
|
||||
user = None
|
||||
task_name = None
|
||||
thread_id = None
|
||||
|
||||
if task:
|
||||
# 尝试使用已加载的 user,否则查询
|
||||
user = task.user if hasattr(task, 'user') and task.user else \
|
||||
db.query(User).filter(User.id == task.user_id).first()
|
||||
task_name = task.name
|
||||
|
||||
# 从 payload_config 提取 ThreadId
|
||||
from backend.utils.json_helpers import extract_thread_id
|
||||
thread_id = extract_thread_id(task.payload_config) # type: ignore
|
||||
|
||||
# 转换为字典并添加额外字段
|
||||
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,795 @@
|
||||
"""
|
||||
邮件业务服务 (高级)
|
||||
|
||||
职能:提供业务相关的邮件操作
|
||||
- 新用户注册通知
|
||||
- 用户审批通知
|
||||
- 打卡结果通知
|
||||
- Token 到期提醒
|
||||
- 调用底层 EmailNotifier 发送邮件
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import User
|
||||
from backend.workers.email_notifier import EmailNotifier
|
||||
from backend.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""邮件业务服务(高级服务)"""
|
||||
|
||||
@staticmethod
|
||||
def send_email(to_emails: List[str], subject: str, body_html: str) -> bool:
|
||||
"""
|
||||
发送邮件(业务层方法,调用底层 EmailNotifier)
|
||||
|
||||
Args:
|
||||
to_emails: 收件人邮箱列表
|
||||
subject: 邮件主题
|
||||
body_html: 邮件正文(HTML 格式)
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
return EmailNotifier.send_email(to_emails, subject, body_html)
|
||||
|
||||
@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()
|
||||
# 使用 str() 转换避免类型检查问题,并过滤空值
|
||||
admin_emails: List[str] = []
|
||||
for admin in admins:
|
||||
email_value = admin.email
|
||||
if email_value is not None: # 使用 is not None 避免布尔转换
|
||||
admin_emails.append(str(email_value))
|
||||
|
||||
if not admin_emails:
|
||||
logger.warning("没有找到管理员邮箱,无法发送通知")
|
||||
return False
|
||||
|
||||
# 构建邮件内容
|
||||
subject = f"【接龙自动打卡系统】新用户注册通知 - {user.alias}"
|
||||
|
||||
# 安全获取创建时间
|
||||
created_at_value = user.created_at
|
||||
created_time = created_at_value.strftime('%Y-%m-%d %H:%M:%S') if created_at_value is not None else '未知'
|
||||
|
||||
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>{created_time}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="warning">
|
||||
<strong>⚠️ 重要提示:</strong>
|
||||
<p>该用户需要在 24 小时内通过审批,否则账户将被自动删除。</p>
|
||||
<p>请登录管理后台进行审批操作。</p>
|
||||
</div>
|
||||
|
||||
<p>登录地址:<a href="{settings.FRONTEND_URL}/admin/users">{settings.FRONTEND_URL}/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_user_approved(user: User) -> bool:
|
||||
"""
|
||||
通知用户审批已通过
|
||||
|
||||
Args:
|
||||
user: 已通过审批的用户
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
user_email = user.email
|
||||
if user_email is None:
|
||||
logger.info(f"用户 {user.alias} 未设置邮箱,跳过审批通知")
|
||||
return False
|
||||
|
||||
# 构建邮件内容
|
||||
subject = f"【接龙自动打卡系统】账户审批通过 - {user.alias}"
|
||||
|
||||
# 安全获取创建时间
|
||||
user_created_at = user.created_at
|
||||
created_time = user_created_at.strftime('%Y-%m-%d %H:%M:%S') if user_created_at is not None else '未知'
|
||||
|
||||
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: #28a745;
|
||||
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;
|
||||
}}
|
||||
.success-box {{
|
||||
background-color: #d4edda;
|
||||
border-left: 4px solid #28a745;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
}}
|
||||
.btn {{
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h2>🎉 恭喜!账户审批通过</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>您好,{user.alias}!</p>
|
||||
<p>恭喜您的账户已通过管理员审批,现在可以使用所有功能了。</p>
|
||||
|
||||
<div class="success-box">
|
||||
<strong>✅ 审批结果:</strong> 已通过
|
||||
<br>
|
||||
<strong>审批时间:</strong> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
</div>
|
||||
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<td>用户名</td>
|
||||
<td>{user.alias}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>账户角色</td>
|
||||
<td>{user.role}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>注册时间</td>
|
||||
<td>{created_time}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p><strong>接下来您可以:</strong></p>
|
||||
<ul>
|
||||
<li>登录系统创建自动打卡任务</li>
|
||||
<li>配置打卡时间和内容</li>
|
||||
<li>查看打卡记录和统计</li>
|
||||
</ul>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{settings.FRONTEND_URL}/login" class="btn">立即登录</a>
|
||||
</p>
|
||||
|
||||
<p style="color: #666; font-size: 14px;">
|
||||
💡 <strong>温馨提示:</strong>如果您还没有设置密码,建议在个人设置中设置密码,方便后续登录。
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>此邮件由系统自动发送,请勿直接回复。</p>
|
||||
<p>接龙自动打卡系统 © {datetime.now().year}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return EmailService.send_email([str(user_email)], subject, body_html)
|
||||
|
||||
@staticmethod
|
||||
def notify_user_rejected(user: User, reason: str = "") -> bool:
|
||||
"""
|
||||
通知用户审批被拒绝
|
||||
|
||||
Args:
|
||||
user: 被拒绝的用户
|
||||
reason: 拒绝原因(可选)
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
user_email = user.email
|
||||
if user_email is None:
|
||||
logger.info(f"用户 {user.alias} 未设置邮箱,跳过拒绝通知")
|
||||
return False
|
||||
|
||||
# 构建邮件内容
|
||||
subject = f"【接龙自动打卡系统】账户审批结果 - {user.alias}"
|
||||
|
||||
reason_html = f"<p><strong>拒绝原因:</strong>{reason}</p>" if reason else ""
|
||||
|
||||
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: #dc3545;
|
||||
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;
|
||||
}}
|
||||
.footer {{
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}}
|
||||
.error-box {{
|
||||
background-color: #f8d7da;
|
||||
border-left: 4px solid #dc3545;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h2>账户审批结果通知</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>您好,{user.alias}!</p>
|
||||
<p>很遗憾,您的账户注册申请未能通过审批。</p>
|
||||
|
||||
<div class="error-box">
|
||||
<strong>❌ 审批结果:</strong> 未通过
|
||||
<br>
|
||||
<strong>处理时间:</strong> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
</div>
|
||||
|
||||
{reason_html}
|
||||
|
||||
<p>如有疑问,请联系系统管理员。</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>此邮件由系统自动发送,请勿直接回复。</p>
|
||||
<p>接龙自动打卡系统 © {datetime.now().year}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return EmailService.send_email([str(user_email)], subject, body_html)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def notify_token_expiring(user: User, jwt_exp: str) -> bool:
|
||||
"""
|
||||
通知用户 Token 即将过期
|
||||
|
||||
Args:
|
||||
user: 用户对象
|
||||
jwt_exp: Token 过期时间戳
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
user_email = user.email
|
||||
if user_email is None:
|
||||
logger.info(f"用户 {user.alias} 未设置邮箱,跳过 Token 过期通知")
|
||||
return False
|
||||
|
||||
# 计算剩余时间
|
||||
from backend.utils.time_helpers import parse_jwt_exp, minutes_until_expiry
|
||||
|
||||
exp_timestamp = parse_jwt_exp(jwt_exp)
|
||||
minutes_left = minutes_until_expiry(exp_timestamp) if exp_timestamp else 0
|
||||
|
||||
# 构建邮件内容
|
||||
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: #ff9800;
|
||||
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;
|
||||
}}
|
||||
.warning-box {{
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ff9800;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
}}
|
||||
.footer {{
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}}
|
||||
.btn {{
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h2>⚠️ 登录凭证即将过期</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>您好,{user.alias}!</p>
|
||||
<p>您的 QQ 登录凭证即将在 <strong>{minutes_left} 分钟</strong>后过期。</p>
|
||||
|
||||
<div class="warning-box">
|
||||
<strong>⚠️ 重要提示:</strong>
|
||||
<ul style="margin: 10px 0; padding-left: 20px;">
|
||||
<li>登录凭证过期后,系统将无法自动执行您的打卡任务</li>
|
||||
<li>建议尽快登录系统刷新凭证</li>
|
||||
<li>如果您已设置密码,可以使用密码登录后扫码刷新凭证</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p><strong>如何刷新凭证:</strong></p>
|
||||
<ol style="margin: 10px 0; padding-left: 20px;">
|
||||
<li>登录系统(扫码或密码登录)</li>
|
||||
<li>在个人设置旁的按钮中进行刷新 Token</li>
|
||||
<li>使用手机 QQ 扫描二维码完成刷新</li>
|
||||
</ol>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{settings.FRONTEND_URL}/login" class="btn">立即登录刷新</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>此邮件由系统自动发送,请勿直接回复。</p>
|
||||
<p>接龙自动打卡系统 © {datetime.now().year}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return EmailService.send_email([str(user_email)], subject, body_html)
|
||||
|
||||
@staticmethod
|
||||
def notify_token_expired(user: User) -> bool:
|
||||
"""
|
||||
通知用户 Token 已过期
|
||||
|
||||
Args:
|
||||
user: 用户对象
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
user_email = user.email
|
||||
if user_email is None:
|
||||
logger.info(f"用户 {user.alias} 未设置邮箱,跳过 Token 已过期通知")
|
||||
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: #dc3545;
|
||||
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;
|
||||
}}
|
||||
.error-box {{
|
||||
background-color: #f8d7da;
|
||||
border-left: 4px solid #dc3545;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
}}
|
||||
.footer {{
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}}
|
||||
.btn {{
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h2>❌ 登录凭证已过期</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>您好,{user.alias}!</p>
|
||||
<p>您的 QQ 登录凭证已过期,系统已无法自动执行打卡任务。</p>
|
||||
|
||||
<div class="error-box">
|
||||
<strong>⚠️ 重要提示:</strong>
|
||||
<ul style="margin: 10px 0; padding-left: 20px;">
|
||||
<li>登录凭证已过期,所有自动打卡任务已暂停</li>
|
||||
<li>请尽快登录系统刷新凭证以恢复服务</li>
|
||||
<li>如果您已设置密码,可以使用密码登录后扫码刷新凭证</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p><strong>如何刷新 Token:</strong></p>
|
||||
<ol style="margin: 10px 0; padding-left: 20px;">
|
||||
<li>登录系统(扫码或密码登录)</li>
|
||||
<li>在个人设置旁的按钮中进行刷新 Token</li>
|
||||
<li>使用手机 QQ 扫描二维码完成刷新</li>
|
||||
</ol>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{settings.FRONTEND_URL}/login" class="btn">立即登录刷新</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>此邮件由系统自动发送,请勿直接回复。</p>
|
||||
<p>接龙自动打卡系统 © {datetime.now().year}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return EmailService.send_email([str(user_email)], 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:
|
||||
是否发送成功
|
||||
"""
|
||||
user_email = user.email
|
||||
if user_email is None:
|
||||
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}"
|
||||
|
||||
# 判断是否是 Token 失效导致的失败
|
||||
is_token_error = not success and message and (
|
||||
"Token" in message or "token" in message or
|
||||
"失效" in message or "授权" in message or "登录" in message
|
||||
)
|
||||
|
||||
# Token 失效时的额外提示内容
|
||||
token_error_section = ""
|
||||
if is_token_error:
|
||||
token_error_section = f"""
|
||||
<div class="error-box">
|
||||
<strong>⚠️ 打卡凭证已过期</strong>
|
||||
<p style="margin: 10px 0;">打卡凭证已过期,无法自动打卡。所有自动打卡任务已暂停,请尽快刷新 Token 以恢复服务。</p>
|
||||
</div>
|
||||
|
||||
<p><strong>如何刷新 Token:</strong></p>
|
||||
<ol style="margin: 10px 0; padding-left: 20px;">
|
||||
<li>登录系统(扫码或密码登录)</li>
|
||||
<li>进入"仪表盘"或点击右上角的"刷新 Token"按钮</li>
|
||||
<li>使用手机 QQ 扫描二维码完成刷新</li>
|
||||
</ol>
|
||||
|
||||
<p style="text-align: center; margin-top: 20px;">
|
||||
<a href="{settings.FRONTEND_URL}/dashboard" class="btn">立即登录刷新</a>
|
||||
</p>
|
||||
"""
|
||||
|
||||
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;
|
||||
}}
|
||||
.error-box {{
|
||||
background-color: #f8d7da;
|
||||
border-left: 4px solid #dc3545;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
}}
|
||||
.btn {{
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
}}
|
||||
</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>
|
||||
|
||||
{token_error_section if is_token_error else '<p>如有问题,请及时检查您的打卡配置。</p>'}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>此邮件由系统自动发送,请勿直接回复。</p>
|
||||
<p>接龙自动打卡系统 © {datetime.now().year}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return EmailService.send_email([str(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,386 @@
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
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
|
||||
|
||||
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 是否即将过期,并发送邮件提醒
|
||||
|
||||
检查所有用户的打卡 authorization token,如果在 30 分钟内过期,发送提醒邮件
|
||||
注意:检查的是打卡业务 token,不是网站登录 JWT token
|
||||
"""
|
||||
from backend.utils.time_helpers import seconds_until_expiry, parse_jwt_exp
|
||||
|
||||
logger.info("Scheduler: 正在执行打卡 Token 过期检查...")
|
||||
|
||||
try:
|
||||
# 创建数据库会话
|
||||
db = next(get_db())
|
||||
|
||||
try:
|
||||
# 获取所有用户
|
||||
users = db.query(User).all()
|
||||
notified_count = 0
|
||||
|
||||
for user in users:
|
||||
# 跳过没有邮箱的用户
|
||||
user_email = user.email
|
||||
if not user_email:
|
||||
logger.debug(f"用户 {user.alias} 未设置邮箱,跳过检查")
|
||||
continue
|
||||
|
||||
# 解析 jwt_exp
|
||||
jwt_exp_value = user.jwt_exp
|
||||
jwt_exp_str = str(jwt_exp_value) if jwt_exp_value is not None else "0"
|
||||
exp_timestamp = parse_jwt_exp(jwt_exp_str)
|
||||
if not exp_timestamp:
|
||||
logger.debug(f"用户 {user.alias} 的 jwt_exp 无效,跳过检查")
|
||||
continue
|
||||
|
||||
# 计算剩余时间
|
||||
time_until_expiry = seconds_until_expiry(exp_timestamp)
|
||||
|
||||
logger.debug(f"用户 {user.alias}: 剩余 {time_until_expiry} 秒 (即将过期标志={user.token_expiring_notified}, 已过期标志={user.token_expired_notified})")
|
||||
|
||||
# 情况1:Token 即将过期(过期前 30 分钟内,且还未过期)
|
||||
if 0 < time_until_expiry < 1800: # 30分钟 = 1800秒
|
||||
# 检查是否已发送过提醒
|
||||
expiring_notified = bool(user.token_expiring_notified)
|
||||
if not expiring_notified:
|
||||
logger.info(f"用户 {user.alias} 的打卡 Token 即将过期,发送邮件提醒到 {user_email}...")
|
||||
from backend.services.email_service import EmailService
|
||||
|
||||
# 发送"即将过期"邮件
|
||||
success = EmailService.notify_token_expiring(user, jwt_exp_str)
|
||||
|
||||
if success:
|
||||
user.token_expiring_notified = True
|
||||
db.commit()
|
||||
notified_count += 1
|
||||
logger.info(f"用户 {user.alias} 的打卡 Token 即将过期邮件已发送并标记")
|
||||
else:
|
||||
logger.warning(f"用户 {user.alias} 的打卡 Token 即将过期邮件发送失败")
|
||||
|
||||
# 情况2:Token 已过期
|
||||
# 修改逻辑:只要过期就发送提醒(不限制在30分钟内)
|
||||
# 但为了避免频繁发送,使用 token_expired_notified 标志
|
||||
elif time_until_expiry <= 0: # Token 已过期
|
||||
# 检查是否已发送过提醒
|
||||
expired_notified = bool(user.token_expired_notified)
|
||||
if not expired_notified:
|
||||
logger.info(f"用户 {user.alias} 的打卡 Token 已过期,发送邮件提醒到 {user_email}...")
|
||||
from backend.services.email_service import EmailService
|
||||
|
||||
# 发送"已过期"邮件
|
||||
success = EmailService.notify_token_expired(user)
|
||||
|
||||
if success:
|
||||
user.token_expired_notified = True
|
||||
db.commit()
|
||||
notified_count += 1
|
||||
logger.info(f"用户 {user.alias} 的打卡 Token 已过期邮件已发送并标记")
|
||||
else:
|
||||
logger.warning(f"用户 {user.alias} 的打卡 Token 已过期邮件发送失败")
|
||||
|
||||
# 情况3:Token 正常(剩余时间 > 30 分钟),重置提醒标志
|
||||
elif time_until_expiry >= 1800:
|
||||
expiring_notified = bool(user.token_expiring_notified)
|
||||
expired_notified = bool(user.token_expired_notified)
|
||||
if expiring_notified or expired_notified:
|
||||
user.token_expiring_notified = False
|
||||
user.token_expired_notified = False
|
||||
db.commit()
|
||||
logger.info(f"用户 {user.alias} 的打卡 Token 已刷新,重置所有提醒标志")
|
||||
|
||||
logger.info(f"Scheduler: 打卡 Token 过期检查完成,共发送 {notified_count} 封提醒邮件")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler: Token 过期检查任务发生错误: {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")
|
||||
|
||||
# 添加 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,376 @@
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
|
||||
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 用于唯一性校验
|
||||
from backend.utils.json_helpers import safe_parse_payload, extract_thread_id
|
||||
|
||||
payload = safe_parse_payload(task_data.payload_config)
|
||||
thread_id = payload.get('ThreadId')
|
||||
if not thread_id:
|
||||
raise ValueError("payload_config 中缺少 ThreadId")
|
||||
|
||||
# 3. 验证唯一性:同一用户在同一个接龙中不能有重复的任务
|
||||
existing_tasks = db.query(
|
||||
CheckInTask.payload_config
|
||||
).filter(
|
||||
CheckInTask.user_id == user_id
|
||||
).all()
|
||||
|
||||
for (payload_config,) in existing_tasks:
|
||||
existing_thread_id = extract_thread_id(payload_config)
|
||||
# extract_thread_id 已处理异常,失败时返回 None
|
||||
if existing_thread_id and existing_thread_id == thread_id:
|
||||
logger.warning(f"⚠️ 任务创建冲突 - User: {user.alias}({user_id}), ThreadId: {thread_id}")
|
||||
raise ValueError(f"该接龙中已存在任务。ThreadId: {thread_id}")
|
||||
|
||||
# 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:
|
||||
包含额外信息的任务字典
|
||||
"""
|
||||
from backend.utils.json_helpers import extract_thread_id
|
||||
|
||||
# 获取最后一次打卡记录
|
||||
last_record = db.query(CheckInRecord).filter(
|
||||
CheckInRecord.task_id == task.id
|
||||
).order_by(desc(CheckInRecord.check_in_time)).first()
|
||||
|
||||
# 从 payload_config 提取 ThreadId
|
||||
thread_id = extract_thread_id(task.payload_config) # type: ignore
|
||||
|
||||
# 转换为字典并添加额外字段
|
||||
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}"
|
||||
|
||||
# 先移除旧的任务(如果存在)
|
||||
existing_job = scheduler.get_job(job_id)
|
||||
if existing_job:
|
||||
scheduler.remove_job(job_id)
|
||||
logger.info(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,577 @@
|
||||
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,
|
||||
cron_expression: Optional[str] = "0 20 * * *"
|
||||
) -> CheckInTask:
|
||||
"""
|
||||
从模板创建打卡任务
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
thread_id: 接龙项目 ID
|
||||
field_values: 用户填写的字段值
|
||||
user_id: 用户 ID
|
||||
task_name: 任务名称(可选)
|
||||
db: 数据库会话
|
||||
cron_expression: Cron 表达式(可选,默认每天 20:00)
|
||||
|
||||
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}"
|
||||
|
||||
# 创建任务(包含 cron_expression)
|
||||
try:
|
||||
task = CheckInTask(
|
||||
user_id=user_id,
|
||||
payload_config=json.dumps(payload, ensure_ascii=False),
|
||||
name=task_name,
|
||||
is_active=True,
|
||||
cron_expression=cron_expression or "0 20 * * *"
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
logger.info(f"从模板创建任务成功: {task.name} (ID: {task.id}, 模板: {template.name}, ThreadId: {thread_id})")
|
||||
|
||||
# 如果任务启用且包含 cron_expression,立即添加到调度器
|
||||
if task.is_scheduled_enabled:
|
||||
from backend.services.task_service import TaskService
|
||||
TaskService._reload_scheduler_for_task(task, db)
|
||||
|
||||
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,310 @@
|
||||
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__)
|
||||
|
||||
|
||||
def escape_like_pattern(text: str) -> str:
|
||||
"""
|
||||
转义 LIKE 查询中的特殊字符
|
||||
|
||||
Args:
|
||||
text: 原始搜索文本
|
||||
|
||||
Returns:
|
||||
转义后的文本
|
||||
"""
|
||||
return text.replace('%', r'\%').replace('_', r'\_')
|
||||
|
||||
|
||||
class UserService:
|
||||
"""用户服务"""
|
||||
|
||||
@staticmethod
|
||||
def create_user(user_data: UserCreate, db: Session) -> User:
|
||||
"""
|
||||
创建用户(管理员手动创建)
|
||||
|
||||
Args:
|
||||
user_data: 用户创建数据(包括 alias, role, email, password 等)
|
||||
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=None, # NULL 表示未绑定 QQ
|
||||
alias=user_data.alias,
|
||||
email=user_data.email,
|
||||
role=user_data.role or "user",
|
||||
is_approved=user_data.is_approved if user_data.is_approved is not None else True, # 使用请求中的值,默认已审批
|
||||
jwt_exp="0",
|
||||
authorization=None,
|
||||
)
|
||||
|
||||
# 如果提供了密码,则设置密码
|
||||
if user_data.password:
|
||||
import bcrypt
|
||||
password_hash = bcrypt.hashpw(user_data.password.encode('utf-8'), bcrypt.gensalt())
|
||||
setattr(user, 'password_hash', password_hash.decode('utf-8'))
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info(f"管理员创建用户成功: {user.alias} (ID: {user.id}, 角色: {user.role}, 密码: {'已设置' if user_data.password else '未设置'})")
|
||||
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:
|
||||
# 转义 LIKE 特殊字符,防止通配符滥用
|
||||
escaped_search = escape_like_pattern(search)
|
||||
# 注意:jwt_sub 可能为 NULL,需要处理
|
||||
search_conditions = [User.alias.ilike(f"%{escaped_search}%")]
|
||||
# 只有当 jwt_sub 不为空时才搜索
|
||||
search_conditions.append(User.jwt_sub.ilike(f"%{escaped_search}%"))
|
||||
query = query.filter(or_(*search_conditions))
|
||||
|
||||
# 角色过滤
|
||||
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,123 @@
|
||||
"""
|
||||
数据库操作辅助函数
|
||||
|
||||
提供统一的资源查询、权限验证等通用功能
|
||||
"""
|
||||
from typing import TypeVar, Type, Optional, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def get_or_404(
|
||||
model: Type[T],
|
||||
model_id: int,
|
||||
db: Session,
|
||||
error_message: Optional[str] = None
|
||||
) -> T:
|
||||
"""
|
||||
查询资源,不存在则抛出 404
|
||||
|
||||
Args:
|
||||
model: SQLAlchemy 模型类
|
||||
model_id: 资源 ID
|
||||
db: 数据库会话
|
||||
error_message: 自定义错误消息
|
||||
|
||||
Returns:
|
||||
查询到的资源对象
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 资源不存在
|
||||
"""
|
||||
obj = db.query(model).filter(model.id == model_id).first()
|
||||
if not obj:
|
||||
default_message = f"{model.__name__}不存在"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=error_message or default_message
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
def get_owned_or_403(
|
||||
model: Type[T],
|
||||
model_id: int,
|
||||
user_id: int,
|
||||
db: Session,
|
||||
error_message: Optional[str] = None
|
||||
) -> T:
|
||||
"""
|
||||
查询资源并验证归属,否则抛出 403
|
||||
|
||||
Args:
|
||||
model: SQLAlchemy 模型类(必须有 user_id 字段)
|
||||
model_id: 资源 ID
|
||||
user_id: 当前用户 ID
|
||||
db: 数据库会话
|
||||
error_message: 自定义错误消息
|
||||
|
||||
Returns:
|
||||
查询到的资源对象
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 无权访问此资源
|
||||
"""
|
||||
obj = db.query(model).filter(
|
||||
model.id == model_id,
|
||||
model.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not obj:
|
||||
# 先检查资源是否存在
|
||||
exists = db.query(model).filter(model.id == model_id).first()
|
||||
if not exists:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"{model.__name__}不存在"
|
||||
)
|
||||
# 资源存在但不属于当前用户
|
||||
default_message = f"无权访问此{model.__name__}"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=error_message or default_message
|
||||
)
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
def get_by_field_or_404(
|
||||
model: Type[T],
|
||||
field_name: str,
|
||||
field_value: Any,
|
||||
db: Session,
|
||||
error_message: Optional[str] = None
|
||||
) -> T:
|
||||
"""
|
||||
根据字段查询资源,不存在则抛出 404
|
||||
|
||||
Args:
|
||||
model: SQLAlchemy 模型类
|
||||
field_name: 字段名
|
||||
field_value: 字段值
|
||||
db: 数据库会话
|
||||
error_message: 自定义错误消息
|
||||
|
||||
Returns:
|
||||
查询到的资源对象
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 资源不存在
|
||||
"""
|
||||
obj = db.query(model).filter(
|
||||
getattr(model, field_name) == field_value
|
||||
).first()
|
||||
|
||||
if not obj:
|
||||
default_message = f"{model.__name__}不存在"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=error_message or default_message
|
||||
)
|
||||
return obj
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
JSON 处理辅助函数
|
||||
|
||||
提供安全的 JSON 解析和数据提取功能
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Any, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def safe_parse_json(
|
||||
json_str: Optional[str],
|
||||
default: Any = None,
|
||||
log_error: bool = True
|
||||
) -> Any:
|
||||
"""
|
||||
安全解析 JSON 字符串,失败时返回默认值
|
||||
|
||||
Args:
|
||||
json_str: JSON 字符串
|
||||
default: 解析失败时的默认值
|
||||
log_error: 是否记录解析错误日志
|
||||
|
||||
Returns:
|
||||
解析后的对象,失败时返回 default
|
||||
"""
|
||||
if not json_str:
|
||||
return default
|
||||
|
||||
try:
|
||||
return json.loads(str(json_str))
|
||||
except (json.JSONDecodeError, AttributeError, TypeError) as e:
|
||||
if log_error:
|
||||
logger.debug(f"JSON 解析失败: {str(e)}, 原始数据: {json_str[:100]}...")
|
||||
return default
|
||||
|
||||
|
||||
def safe_parse_payload(
|
||||
payload_config: Optional[str],
|
||||
default: Optional[Dict] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
安全解析 payload_config,失败时返回默认字典
|
||||
|
||||
Args:
|
||||
payload_config: payload 配置字符串
|
||||
default: 解析失败时的默认值
|
||||
|
||||
Returns:
|
||||
解析后的字典
|
||||
"""
|
||||
result = safe_parse_json(payload_config, default or {})
|
||||
# 确保返回值是字典类型
|
||||
if not isinstance(result, dict):
|
||||
logger.warning(f"payload_config 不是字典类型: {type(result)}")
|
||||
return default or {}
|
||||
return result
|
||||
|
||||
|
||||
def extract_thread_id(payload_config: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
从 payload_config 中提取 ThreadId
|
||||
|
||||
Args:
|
||||
payload_config: payload 配置字符串
|
||||
|
||||
Returns:
|
||||
ThreadId 或 None
|
||||
"""
|
||||
payload = safe_parse_payload(payload_config)
|
||||
return payload.get('ThreadId')
|
||||
|
||||
|
||||
def extract_signature(payload_config: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
从 payload_config 中提取 Signature
|
||||
|
||||
Args:
|
||||
payload_config: payload 配置字符串
|
||||
|
||||
Returns:
|
||||
Signature 或 None
|
||||
"""
|
||||
payload = safe_parse_payload(payload_config)
|
||||
return payload.get('Signature')
|
||||
|
||||
|
||||
def build_task_info(task) -> Dict[str, str]:
|
||||
"""
|
||||
从 task 对象构建 task_info 字典(用于邮件通知等场景)
|
||||
|
||||
Args:
|
||||
task: CheckInTask 对象
|
||||
|
||||
Returns:
|
||||
包含 thread_id 和 name 的字典
|
||||
"""
|
||||
return {
|
||||
'thread_id': extract_thread_id(getattr(task, 'payload_config', None)) or '未知',
|
||||
'name': getattr(task, 'name', None) or f'Task-{getattr(task, "id", "Unknown")}'
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
JWT 认证工具模块
|
||||
|
||||
用于生成和验证网站登录的 JWT Token
|
||||
注意:这与打卡业务的 authorization token 是分开的
|
||||
"""
|
||||
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from backend.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET_KEY = settings.SECRET_KEY # 使用现有的 SECRET_KEY
|
||||
JWT_ALGORITHM = "HS256"
|
||||
JWT_EXPIRATION_DAYS = 21 # JWT 有效期:21天
|
||||
|
||||
|
||||
class JWTManager:
|
||||
"""JWT 管理器"""
|
||||
|
||||
@staticmethod
|
||||
def create_access_token(user_id: int, user_alias: str) -> str:
|
||||
"""
|
||||
创建访问令牌
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
user_alias: 用户别名
|
||||
|
||||
Returns:
|
||||
JWT token 字符串
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
exp = now + timedelta(days=JWT_EXPIRATION_DAYS)
|
||||
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"alias": user_alias,
|
||||
"iat": now, # Issued At - 签发时间
|
||||
"exp": exp, # Expiration Time - 过期时间
|
||||
"type": "access" # Token 类型
|
||||
}
|
||||
|
||||
token = jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
||||
logger.info(f"为用户 {user_alias}(ID: {user_id}) 创建 JWT,过期时间: {exp}")
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
def verify_token(token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
验证并解码 JWT token
|
||||
|
||||
Args:
|
||||
token: JWT token 字符串
|
||||
|
||||
Returns:
|
||||
解码后的 payload 字典
|
||||
|
||||
Raises:
|
||||
jwt.ExpiredSignatureError: Token 已过期
|
||||
jwt.InvalidTokenError: Token 无效
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
|
||||
|
||||
# 验证 token 类型
|
||||
if payload.get("type") != "access":
|
||||
raise jwt.InvalidTokenError("Token 类型不正确")
|
||||
|
||||
return payload
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("JWT Token 已过期")
|
||||
raise
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"JWT Token 无效: {str(e)}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"验证 JWT Token 时发生错误: {str(e)}")
|
||||
raise jwt.InvalidTokenError(f"Token 验证失败: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def get_user_id_from_token(token: str) -> Optional[int]:
|
||||
"""
|
||||
从 JWT token 中提取用户 ID(不验证过期)
|
||||
|
||||
Args:
|
||||
token: JWT token 字符串
|
||||
|
||||
Returns:
|
||||
用户 ID 或 None
|
||||
"""
|
||||
try:
|
||||
# decode 时设置 verify=False 跳过过期验证
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
JWT_SECRET_KEY,
|
||||
algorithms=[JWT_ALGORITHM],
|
||||
options={"verify_exp": False}
|
||||
)
|
||||
return payload.get("user_id")
|
||||
except Exception as e:
|
||||
logger.error(f"从 Token 提取用户 ID 失败: {str(e)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def is_token_expired(token: str) -> bool:
|
||||
"""
|
||||
检查 token 是否过期(不抛出异常)
|
||||
|
||||
Args:
|
||||
token: JWT token 字符串
|
||||
|
||||
Returns:
|
||||
True 表示已过期,False 表示未过期
|
||||
"""
|
||||
try:
|
||||
JWTManager.verify_token(token)
|
||||
return False
|
||||
except jwt.ExpiredSignatureError:
|
||||
return True
|
||||
except jwt.InvalidTokenError:
|
||||
return True
|
||||
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
时间处理辅助函数
|
||||
|
||||
提供统一的时间戳处理和格式化功能
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def now_timestamp() -> int:
|
||||
"""
|
||||
获取当前时间戳(秒)
|
||||
|
||||
Returns:
|
||||
当前时间戳
|
||||
"""
|
||||
return int(datetime.now().timestamp())
|
||||
|
||||
|
||||
def is_timestamp_expired(timestamp: int) -> bool:
|
||||
"""
|
||||
检查时间戳是否已过期
|
||||
|
||||
Args:
|
||||
timestamp: 时间戳(秒)
|
||||
|
||||
Returns:
|
||||
是否已过期
|
||||
"""
|
||||
return now_timestamp() > timestamp
|
||||
|
||||
|
||||
def seconds_until_expiry(timestamp: int) -> int:
|
||||
"""
|
||||
计算距离过期的秒数(负数表示已过期)
|
||||
|
||||
Args:
|
||||
timestamp: 时间戳(秒)
|
||||
|
||||
Returns:
|
||||
距离过期的秒数
|
||||
"""
|
||||
return timestamp - now_timestamp()
|
||||
|
||||
|
||||
def days_until_expiry(timestamp: int) -> int:
|
||||
"""
|
||||
计算距离过期的天数(负数表示已过期)
|
||||
|
||||
Args:
|
||||
timestamp: 时间戳(秒)
|
||||
|
||||
Returns:
|
||||
距离过期的天数
|
||||
"""
|
||||
seconds = seconds_until_expiry(timestamp)
|
||||
return seconds // 86400
|
||||
|
||||
|
||||
def hours_until_expiry(timestamp: int) -> int:
|
||||
"""
|
||||
计算距离过期的小时数(负数表示已过期)
|
||||
|
||||
Args:
|
||||
timestamp: 时间戳(秒)
|
||||
|
||||
Returns:
|
||||
距离过期的小时数
|
||||
"""
|
||||
seconds = seconds_until_expiry(timestamp)
|
||||
return seconds // 3600
|
||||
|
||||
|
||||
def minutes_until_expiry(timestamp: int) -> int:
|
||||
"""
|
||||
计算距离过期的分钟数(负数表示已过期)
|
||||
|
||||
Args:
|
||||
timestamp: 时间戳(秒)
|
||||
|
||||
Returns:
|
||||
距离过期的分钟数
|
||||
"""
|
||||
seconds = seconds_until_expiry(timestamp)
|
||||
return seconds // 60
|
||||
|
||||
|
||||
def format_timestamp(timestamp: int, format_str: str = '%Y-%m-%d %H:%M:%S') -> str:
|
||||
"""
|
||||
格式化时间戳为人类可读格式
|
||||
|
||||
Args:
|
||||
timestamp: 时间戳(秒)
|
||||
format_str: 时间格式字符串
|
||||
|
||||
Returns:
|
||||
格式化后的时间字符串
|
||||
"""
|
||||
dt = datetime.fromtimestamp(timestamp)
|
||||
return dt.strftime(format_str)
|
||||
|
||||
|
||||
def format_expiry_time(timestamp: int) -> str:
|
||||
"""
|
||||
格式化过期时间为人类可读格式(带中文说明)
|
||||
|
||||
Args:
|
||||
timestamp: 时间戳(秒)
|
||||
|
||||
Returns:
|
||||
格式化后的时间字符串,如 "2024-01-01 12:00:00 (已过期 2 天)"
|
||||
"""
|
||||
formatted_time = format_timestamp(timestamp)
|
||||
days = days_until_expiry(timestamp)
|
||||
|
||||
if days > 0:
|
||||
return f"{formatted_time} (还剩 {days} 天)"
|
||||
elif days == 0:
|
||||
hours = hours_until_expiry(timestamp)
|
||||
if hours > 0:
|
||||
return f"{formatted_time} (还剩 {hours} 小时)"
|
||||
else:
|
||||
minutes = minutes_until_expiry(timestamp)
|
||||
if minutes > 0:
|
||||
return f"{formatted_time} (还剩 {minutes} 分钟)"
|
||||
else:
|
||||
return f"{formatted_time} (即将过期)"
|
||||
else:
|
||||
return f"{formatted_time} (已过期 {abs(days)} 天)"
|
||||
|
||||
|
||||
def parse_jwt_exp(jwt_exp: Optional[str]) -> Optional[int]:
|
||||
"""
|
||||
解析 jwt_exp 字段为时间戳
|
||||
|
||||
Args:
|
||||
jwt_exp: jwt_exp 字符串(可能是 "0" 或数字字符串)
|
||||
|
||||
Returns:
|
||||
时间戳,无效时返回 None
|
||||
"""
|
||||
if not jwt_exp or jwt_exp == "0":
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(jwt_exp)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
@@ -0,0 +1,317 @@
|
||||
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
|
||||
|
||||
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}")
|
||||
try:
|
||||
debug_screenshot = os.path.join(settings.BASE_DIR, 'payload_debug.png')
|
||||
driver.save_screenshot(debug_screenshot)
|
||||
except Exception as screenshot_error:
|
||||
logger.warning(f"保存调试截图失败: {screenshot_error}")
|
||||
|
||||
finally:
|
||||
# 优雅关闭 WebDriver,避免 Windows asyncio ConnectionResetError
|
||||
try:
|
||||
driver.quit()
|
||||
except Exception as e:
|
||||
# 忽略 WebDriver 关闭时的连接错误(Windows 平台常见问题)
|
||||
if "WinError 10054" not in str(e) and "ConnectionResetError" not in str(e):
|
||||
logger.warning(f"关闭 WebDriver 时出现警告: {e}")
|
||||
|
||||
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 用于日志
|
||||
from backend.utils.json_helpers import safe_parse_payload, extract_signature
|
||||
|
||||
payload_dict = safe_parse_payload(task.payload_config)
|
||||
signature = extract_signature(task.payload_config) or '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)
|
||||
from backend.utils.json_helpers import safe_parse_payload, extract_thread_id
|
||||
|
||||
payload = safe_parse_payload(task.payload_config)
|
||||
thread_id = extract_thread_id(task.payload_config)
|
||||
|
||||
if not thread_id:
|
||||
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 实现逻辑)
|
||||
# 情况1: 明确包含"打卡成功" → 成功
|
||||
if "打卡成功" in response_text:
|
||||
logger.info(f"✅ 检测到成功关键字 '打卡成功',打卡成功")
|
||||
# 发送成功邮件通知
|
||||
if task.user and task.user.email:
|
||||
try:
|
||||
from backend.services.email_service import EmailService
|
||||
task_info = {
|
||||
'thread_id': payload.get('ThreadId', '未知'),
|
||||
'name': getattr(task, 'name', '打卡任务')
|
||||
}
|
||||
EmailService.notify_check_in_result(task.user, task_info, True, "打卡成功")
|
||||
except Exception as e:
|
||||
logger.error(f"发送打卡成功邮件失败: {e}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"status": "success",
|
||||
"response_text": response_text,
|
||||
"error_message": ""
|
||||
}
|
||||
|
||||
# 情况2: 已经提交过了(重复提交)→ 视为成功,但不发送邮件
|
||||
# 匹配 "已被提交" 或 "已经打卡"
|
||||
elif ("已被提交" in response_text or "已经打卡" in response_text or
|
||||
"重复提交" in response_text):
|
||||
logger.info(f"✅ 检测到'已被提交',本次打卡已完成(重复提交,不发送邮件)")
|
||||
return {
|
||||
"success": True,
|
||||
"status": "success",
|
||||
"response_text": response_text,
|
||||
"error_message": ""
|
||||
}
|
||||
|
||||
# 情况3: 不在打卡时间范围 → 标记为时间范围外
|
||||
# 匹配 Data 或 Description 中的内容
|
||||
elif ("不在打卡时间范围" in response_text or
|
||||
"不在打卡时间" in response_text):
|
||||
logger.warning(f"⏰ 检测到'不在打卡时间范围',打卡时间不符")
|
||||
return {
|
||||
"success": False,
|
||||
"status": "out_of_time",
|
||||
"response_text": response_text,
|
||||
"error_message": "不在打卡时间范围内"
|
||||
}
|
||||
|
||||
# 情况4: Token 失效的特征标识 → 失败
|
||||
# 扩展检测条件:检测多种 Token 失效的响应特征
|
||||
elif ("登录" in response_text or "授权" in response_text or
|
||||
"未登录" in response_text or "token" in response_text.lower() or
|
||||
"Unauthorized" in response_text or response.status_code == 401):
|
||||
logger.warning(f"⚠️ 检测到Token失效特征,Token 可能已失效")
|
||||
# 发送打卡失败邮件通知(邮件内容已包含Token失效提醒和刷新指引)
|
||||
if task.user and task.user.email:
|
||||
try:
|
||||
from backend.services.email_service import EmailService
|
||||
from backend.utils.json_helpers import build_task_info
|
||||
|
||||
# 使用辅助函数构建 task_info(从 task 对象提取信息)
|
||||
task_info = build_task_info(task)
|
||||
|
||||
# 只发送打卡失败通知(内容已说明Token失效)
|
||||
EmailService.notify_check_in_result(task.user, task_info, False, "Token 已失效,需要重新授权")
|
||||
except Exception as e:
|
||||
logger.error(f"发送打卡失败邮件失败: {e}")
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"status": "token_expired", # 特殊状态,用于标识 Token 过期
|
||||
"response_text": response_text,
|
||||
"error_message": "Token 已失效,需要重新授权"
|
||||
}
|
||||
|
||||
# 情况5: 其他响应 → 需要人工确认(标记为异常)
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
邮件发送引擎 (底层)
|
||||
|
||||
职能:提供基础的 SMTP 邮件发送功能
|
||||
- SMTP 服务器连接
|
||||
- 邮件发送
|
||||
- 配置管理
|
||||
- 不包含业务逻辑
|
||||
"""
|
||||
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailNotifier:
|
||||
"""邮件发送引擎(底层服务)"""
|
||||
|
||||
@staticmethod
|
||||
def get_email_config() -> Optional[dict]:
|
||||
"""
|
||||
从环境变量读取邮件配置
|
||||
|
||||
Returns:
|
||||
dict: 邮件配置,如果配置不完整则返回 None
|
||||
"""
|
||||
# 检查必要的邮件配置是否存在
|
||||
if not settings.SMTP_SERVER or not settings.SMTP_SENDER_EMAIL:
|
||||
logger.debug("邮件配置未完成(SMTP_SERVER 或 SMTP_SENDER_EMAIL 为空),邮件发送功能已禁用")
|
||||
return None
|
||||
|
||||
if not settings.SMTP_PORT:
|
||||
logger.debug("邮件配置未完成(SMTP_PORT 为空),邮件发送功能已禁用")
|
||||
return None
|
||||
|
||||
# 返回配置字典
|
||||
return {
|
||||
'smtp_server': settings.SMTP_SERVER,
|
||||
'smtp_port': settings.SMTP_PORT,
|
||||
'sender_email': settings.SMTP_SENDER_EMAIL,
|
||||
'sender_password': settings.SMTP_SENDER_PASSWORD,
|
||||
'use_ssl': settings.SMTP_USE_SSL
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def send_email(
|
||||
to_emails: List[str],
|
||||
subject: str,
|
||||
html_content: str,
|
||||
from_email: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
发送邮件(底层方法)
|
||||
|
||||
Args:
|
||||
to_emails: 收件人邮箱列表
|
||||
subject: 邮件主题
|
||||
html_content: HTML 邮件内容
|
||||
from_email: 发件人邮箱(可选,默认使用配置中的发件人)
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
email_config = EmailNotifier.get_email_config()
|
||||
if not email_config:
|
||||
logger.warning("邮件配置不完整,跳过发送邮件")
|
||||
return False
|
||||
|
||||
try:
|
||||
# 创建邮件
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['From'] = from_email or email_config['sender_email']
|
||||
msg['To'] = ', '.join(to_emails)
|
||||
msg['Subject'] = subject
|
||||
|
||||
# 添加 HTML 正文
|
||||
html_part = MIMEText(html_content, 'html', 'utf-8')
|
||||
msg.attach(html_part)
|
||||
|
||||
# 连接 SMTP 服务器并发送
|
||||
if email_config.get('use_ssl', True):
|
||||
server = smtplib.SMTP_SSL(
|
||||
email_config['smtp_server'],
|
||||
int(email_config['smtp_port'])
|
||||
)
|
||||
else:
|
||||
server = smtplib.SMTP(
|
||||
email_config['smtp_server'],
|
||||
int(email_config['smtp_port'])
|
||||
)
|
||||
server.starttls()
|
||||
|
||||
server.login(email_config['sender_email'], email_config['sender_password'])
|
||||
server.sendmail(msg['From'], 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 is_email_enabled() -> bool:
|
||||
"""
|
||||
检查邮件功能是否启用
|
||||
|
||||
Returns:
|
||||
邮件功能是否可用
|
||||
"""
|
||||
return EmailNotifier.get_email_config() is not None
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
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
|
||||
from backend.utils.json_helpers import safe_parse_json
|
||||
data = safe_parse_json(content, {})
|
||||
return data.get('status')
|
||||
except IOError 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
|
||||
from backend.utils.json_helpers import safe_parse_json
|
||||
return safe_parse_json(content, {})
|
||||
except IOError as e:
|
||||
logger.error(f"读取会话文件 {filepath} 失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def cancel_session(session_id: str) -> bool:
|
||||
"""
|
||||
取消登录会话
|
||||
|
||||
Args:
|
||||
session_id: 会话 ID
|
||||
|
||||
Returns:
|
||||
是否成功取消
|
||||
"""
|
||||
filepath = settings.SESSION_DIR / f"{session_id}.json"
|
||||
lock_path = settings.SESSION_DIR / f"{session_id}.json.lock"
|
||||
|
||||
if not filepath.exists():
|
||||
logger.warning(f"尝试取消不存在的会话: {session_id}")
|
||||
return False
|
||||
|
||||
try:
|
||||
with FileLock(lock_path, timeout=5):
|
||||
# 读取当前会话数据
|
||||
from backend.utils.json_helpers import safe_parse_json
|
||||
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if not content:
|
||||
return False
|
||||
data = safe_parse_json(content, {})
|
||||
|
||||
# 如果已经成功,不允许取消
|
||||
if data.get('status') == 'success':
|
||||
logger.info(f"会话 {session_id} 已成功,无法取消")
|
||||
return False
|
||||
|
||||
# 标记为已取消
|
||||
data['status'] = 'cancelled'
|
||||
data['message'] = '用户取消登录'
|
||||
|
||||
# 写回文件
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"✅ 会话 {session_id} 已取消")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"取消会话 {session_id} 失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
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}...")
|
||||
|
||||
# 自定义等待逻辑:每秒检查cookie和session状态
|
||||
max_wait_seconds = 120
|
||||
import time
|
||||
for i in range(max_wait_seconds):
|
||||
# 检查session是否被取消
|
||||
status = get_session_status(session_id)
|
||||
if status == 'cancelled':
|
||||
logger.info(f"Selenium ({session_id}): 用户取消了登录,终止会话")
|
||||
raise Exception("用户取消登录")
|
||||
|
||||
# 检查cookie是否出现
|
||||
cookie = driver.get_cookie(cookie_name_to_find)
|
||||
if cookie:
|
||||
break
|
||||
|
||||
time.sleep(1)
|
||||
else:
|
||||
# 超时未获取到cookie
|
||||
raise TimeoutException("等待扫码超时")
|
||||
|
||||
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}")
|
||||
@@ -0,0 +1,2 @@
|
||||
# API Base URL (Development)
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
@@ -0,0 +1,3 @@
|
||||
# API Base URL (Production)
|
||||
# 留空,让 API 请求使用相对路径(由 Nginx 转发)
|
||||
VITE_API_BASE_URL=
|
||||
@@ -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,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.local
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import js from '@eslint/js';
|
||||
import pluginVue from 'eslint-plugin-vue';
|
||||
import prettierConfig from '@vue/eslint-config-prettier';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['node_modules', 'dist', '*.local'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
prettierConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
// 浏览器环境
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
localStorage: 'readonly',
|
||||
console: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
navigator: 'readonly',
|
||||
// Node.js 环境(用于配置文件)
|
||||
process: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-v-html': 'warn',
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-unused-vars': 'warn',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>接龙自动打卡</title>
|
||||
<meta name="description" content="接龙自动打卡系统 - 轻松管理您的打卡任务" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4314
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.13.4",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.27",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-vue": "^10.7.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<!-- 圆形绿色渐变背景 -->
|
||||
<defs>
|
||||
<linearGradient id="greenGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#66bb6a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#4caf50;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- 圆形背景 -->
|
||||
<circle cx="50" cy="50" r="48" fill="url(#greenGradient)"/>
|
||||
|
||||
<!-- 白色打钩图标 -->
|
||||
<path d="M 30 50 L 42 62 L 70 34"
|
||||
stroke="white"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 650 B |
@@ -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,85 @@
|
||||
<template>
|
||||
<a-config-provider :theme="antdTheme" :locale="zhCN">
|
||||
<router-view />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue';
|
||||
import { ConfigProvider as AConfigProvider } from 'ant-design-vue';
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import getAntdTheme from './antd-theme';
|
||||
import { useTheme, initTheme, watchSystemTheme } from '@/composables/useTheme';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 初始化主题(全局)
|
||||
initTheme();
|
||||
watchSystemTheme();
|
||||
|
||||
// 使用主题
|
||||
const { isDark } = useTheme();
|
||||
|
||||
// 动态生成 Ant Design 主题
|
||||
const antdTheme = computed(() => getAntdTheme(isDark.value));
|
||||
|
||||
// 应用启动时验证 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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 修复按钮图标与文本的垂直对齐 */
|
||||
.ant-btn {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.ant-btn .anticon {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.ant-btn > span {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,248 @@
|
||||
import { theme } from 'ant-design-vue';
|
||||
|
||||
/**
|
||||
* Ant Design Vue 主题配置
|
||||
* 严格遵循 Material Design 3 规范
|
||||
* @param {boolean} isDark - 是否为暗色模式
|
||||
*/
|
||||
export default function getAntdTheme(isDark = false) {
|
||||
return {
|
||||
token: {
|
||||
// === Material Design 3 Color System ===
|
||||
|
||||
// Primary - 主色调(绿色)
|
||||
colorPrimary: isDark ? '#81c784' : '#4caf50',
|
||||
|
||||
// Secondary colors
|
||||
colorSuccess: isDark ? '#81c784' : '#4caf50',
|
||||
colorWarning: '#ff9800',
|
||||
colorError: '#f44336', // MD3 标准错误色
|
||||
colorInfo: isDark ? '#64b5f6' : '#2196f3',
|
||||
|
||||
// === Surface & Background (MD3 规范) ===
|
||||
colorBgBase: isDark ? '#1c1b1f' : '#ffffff',
|
||||
colorBgContainer: isDark ? '#1c1b1f' : '#ffffff',
|
||||
colorBgElevated: isDark ? '#26252a' : '#ffffff',
|
||||
colorBgLayout: isDark ? '#1c1b1f' : '#fefbff', // MD3 标准背景色
|
||||
colorBgSpotlight: isDark ? '#26252a' : '#ffffff',
|
||||
|
||||
// === Typography (MD3 规范) ===
|
||||
colorText: isDark ? '#e6e1e5' : '#1c1b1f', // On-surface
|
||||
colorTextSecondary: isDark ? '#cac4d0' : '#49454f', // On-surface-variant
|
||||
colorTextTertiary: isDark ? '#938f99' : '#79747e',
|
||||
colorTextQuaternary: isDark ? '#79747e' : '#938f99',
|
||||
|
||||
// === Borders ===
|
||||
colorBorder: isDark ? '#49454f' : '#d1cdd6',
|
||||
colorBorderSecondary: isDark ? '#3a3740' : '#e3e1e6',
|
||||
colorSplit: isDark ? '#49454f' : '#e3e1e6',
|
||||
|
||||
// === Shape System ===
|
||||
borderRadius: 12, // Medium shape
|
||||
borderRadiusLG: 16, // Large shape
|
||||
borderRadiusSM: 8, // Small shape
|
||||
borderRadiusXS: 4, // Extra small shape
|
||||
|
||||
// === Typography ===
|
||||
fontFamily: "'Roboto', 'Inter', system-ui, -apple-system, sans-serif",
|
||||
fontSize: 14, // Body Medium
|
||||
fontSizeLG: 16, // Body Large
|
||||
fontSizeSM: 12, // Body Small
|
||||
lineHeight: 1.428, // 20/14 = 1.428
|
||||
lineHeightLG: 1.5, // 24/16 = 1.5
|
||||
|
||||
// === Links ===
|
||||
colorLink: isDark ? '#64b5f6' : '#2196f3',
|
||||
colorLinkHover: isDark ? '#90caf9' : '#1976d2',
|
||||
colorLinkActive: isDark ? '#42a5f5' : '#1565c0',
|
||||
|
||||
// === Components ===
|
||||
controlHeight: 40,
|
||||
controlHeightLG: 48,
|
||||
controlHeightSM: 32,
|
||||
|
||||
// === Motion (MD3 规范) ===
|
||||
motionDurationSlow: '0.3s',
|
||||
motionDurationMid: '0.2s',
|
||||
motionDurationFast: '0.1s',
|
||||
},
|
||||
|
||||
components: {
|
||||
// === Card 组件 (MD3 Elevated Card) ===
|
||||
Card: {
|
||||
borderRadiusLG: 16,
|
||||
paddingLG: 24,
|
||||
colorBgContainer: isDark ? '#1c1b1f' : '#ffffff',
|
||||
colorBorderSecondary: isDark ? '#49454f' : '#e3e1e6',
|
||||
colorTextHeading: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
},
|
||||
|
||||
// === Button 组件 (MD3 规范) ===
|
||||
Button: {
|
||||
borderRadius: 20, // MD3 Filled Button 圆角
|
||||
borderRadiusLG: 24,
|
||||
borderRadiusSM: 16,
|
||||
controlHeight: 40,
|
||||
controlHeightLG: 48,
|
||||
controlHeightSM: 32,
|
||||
fontSize: 14,
|
||||
fontSizeLG: 16,
|
||||
fontSizeSM: 12,
|
||||
paddingContentHorizontal: 24,
|
||||
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
colorBgContainer: isDark ? '#26252a' : '#ffffff',
|
||||
},
|
||||
|
||||
// === Input 组件 (MD3 Text Field) ===
|
||||
Input: {
|
||||
borderRadius: 12,
|
||||
controlHeight: 40,
|
||||
colorBgContainer: isDark ? '#26252a' : '#ffffff',
|
||||
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
colorTextPlaceholder: isDark ? '#938f99' : '#79747e',
|
||||
colorBorder: isDark ? '#49454f' : '#d1cdd6',
|
||||
},
|
||||
|
||||
// === Select 组件 ===
|
||||
Select: {
|
||||
borderRadius: 12,
|
||||
controlHeight: 40,
|
||||
colorBgContainer: isDark ? '#26252a' : '#ffffff',
|
||||
colorBgElevated: isDark ? '#26252a' : '#ffffff',
|
||||
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
colorTextPlaceholder: isDark ? '#938f99' : '#79747e',
|
||||
colorBorder: isDark ? '#49454f' : '#d1cdd6',
|
||||
},
|
||||
|
||||
// === Modal 组件 (MD3 Dialog) ===
|
||||
Modal: {
|
||||
borderRadiusLG: 28, // MD3 Dialog 使用 Extra Large 圆角
|
||||
colorBgElevated: isDark ? '#1c1b1f' : '#ffffff',
|
||||
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
colorTextHeading: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
},
|
||||
|
||||
// === Table 组件 ===
|
||||
Table: {
|
||||
borderRadius: 12,
|
||||
colorBgContainer: isDark ? '#1c1b1f' : '#ffffff',
|
||||
colorFillAlter: isDark ? '#26252a' : '#f5f5f5',
|
||||
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
colorTextHeading: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
colorBorderSecondary: isDark ? '#49454f' : '#e3e1e6',
|
||||
},
|
||||
|
||||
// === Tabs 组件 ===
|
||||
Tabs: {
|
||||
borderRadius: 12,
|
||||
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
colorBgContainer: isDark ? '#1c1b1f' : '#ffffff',
|
||||
},
|
||||
|
||||
// === Menu 组件 ===
|
||||
Menu: {
|
||||
colorItemBg: isDark ? '#1c1b1f' : '#ffffff',
|
||||
colorItemBgHover: isDark ? '#26252a' : '#f5f5f5',
|
||||
colorItemBgSelected: isDark ? '#3a4a3f' : '#e8f5e9',
|
||||
colorItemText: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
colorItemTextSelected: isDark ? '#81c784' : '#4caf50',
|
||||
borderRadius: 12,
|
||||
},
|
||||
|
||||
// === Dropdown 组件 ===
|
||||
Dropdown: {
|
||||
colorBgElevated: isDark ? '#26252a' : '#ffffff',
|
||||
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
borderRadiusLG: 12,
|
||||
},
|
||||
|
||||
// === Descriptions 组件 ===
|
||||
Descriptions: {
|
||||
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
colorTextSecondary: isDark ? '#cac4d0' : '#49454f',
|
||||
colorBgContainer: isDark ? '#1c1b1f' : '#ffffff',
|
||||
colorFillAlter: isDark ? '#201f24' : '#f3f4f6', // Label 背景色 = surface-container
|
||||
colorSplit: isDark ? '#49454f' : '#e3e1e6',
|
||||
borderRadiusLG: 8, // 设置 Descriptions 容器圆角
|
||||
},
|
||||
|
||||
// === Alert 组件 ===
|
||||
Alert: {
|
||||
borderRadiusLG: 12,
|
||||
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
},
|
||||
|
||||
// === Drawer 组件 ===
|
||||
Drawer: {
|
||||
colorBgElevated: isDark ? '#1c1b1f' : '#ffffff',
|
||||
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
borderRadiusLG: 16,
|
||||
},
|
||||
|
||||
// === Form 组件 ===
|
||||
Form: {
|
||||
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
colorTextHeading: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
},
|
||||
|
||||
// === Empty 组件 ===
|
||||
Empty: {
|
||||
colorTextDescription: isDark ? '#938f99' : '#79747e',
|
||||
},
|
||||
|
||||
// === Tag 组件 ===
|
||||
Tag: {
|
||||
borderRadiusSM: 16, // 药丸形
|
||||
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
},
|
||||
|
||||
// === Switch 组件 ===
|
||||
Switch: {
|
||||
colorPrimary: isDark ? '#81c784' : '#4caf50',
|
||||
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
|
||||
},
|
||||
|
||||
// === Segmented 组件 ===
|
||||
Segmented: {
|
||||
borderRadius: 12,
|
||||
borderRadiusSM: 8,
|
||||
// 根据源码,Segmented 使用这些 token 映射:
|
||||
// labelColor <- colorTextLabel
|
||||
// labelColorHover <- colorText
|
||||
// bgColor <- colorBgLayout
|
||||
// bgColorHover <- colorFillSecondary
|
||||
// bgColorSelected <- colorBgElevated
|
||||
|
||||
// 未选中项文字颜色
|
||||
colorTextLabel: isDark ? '#938f99' : '#79747e',
|
||||
labelColor: isDark ? '#938f99' : '#79747e',
|
||||
|
||||
// 选中项和 hover 时的文字颜色
|
||||
colorText: isDark ? '#ffffff' : '#1c1b1f',
|
||||
labelColorHover: isDark ? '#ffffff' : '#1c1b1f',
|
||||
|
||||
// 整体背景色
|
||||
colorBgLayout: isDark ? '#26252a' : '#f5f5f5',
|
||||
bgColor: isDark ? '#26252a' : '#f5f5f5',
|
||||
|
||||
// hover 背景色(降低透明度,保持文字可见)
|
||||
colorFillSecondary: isDark ? 'rgba(129, 199, 132, 0.12)' : 'rgba(76, 175, 80, 0.08)',
|
||||
bgColorHover: isDark ? 'rgba(129, 199, 132, 0.12)' : 'rgba(76, 175, 80, 0.08)',
|
||||
|
||||
// 选中项背景色(主题色)
|
||||
colorBgElevated: isDark ? '#81c784' : '#4caf50',
|
||||
bgColorSelected: isDark ? '#81c784' : '#4caf50',
|
||||
},
|
||||
|
||||
// === Tooltip 组件 ===
|
||||
Tooltip: {
|
||||
colorBgSpotlight: isDark ? '#313033' : '#f5f5f5', // Tooltip 背景色(跟随主题)
|
||||
colorTextLightSolid: isDark ? '#ffffff' : '#1c1b1f', // Tooltip 文本颜色(跟随主题)
|
||||
borderRadius: 8,
|
||||
},
|
||||
},
|
||||
|
||||
// 算法配置 - 使用 Ant Design 内置的暗黑算法
|
||||
algorithm: isDark ? [theme.darkAlgorithm] : [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// 创建 axios 实例
|
||||
const client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '',
|
||||
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) {
|
||||
// JWT token 过期或无效:需要重新登录
|
||||
// 注意:打卡业务的 authorization 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,258 @@
|
||||
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}`);
|
||||
},
|
||||
|
||||
// 取消 QR 码登录会话
|
||||
cancelQRCodeSession: sessionId => {
|
||||
return client.delete(`/api/auth/qrcode_session/${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 });
|
||||
},
|
||||
|
||||
// 获取任务详情
|
||||
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,509 @@
|
||||
<template>
|
||||
<div class="crontab-editor">
|
||||
<!-- 模式选择 Tab -->
|
||||
<div class="mode-tabs">
|
||||
<button
|
||||
v-for="m in modes"
|
||||
:key="m"
|
||||
:class="{ active: mode === m }"
|
||||
class="mode-tab"
|
||||
type="button"
|
||||
@click.prevent="switchMode(m)"
|
||||
>
|
||||
{{ modeLabels[m] }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 快速模式:仅日期 20:00 -->
|
||||
<div v-if="mode === 'quick'" class="mode-content">
|
||||
<div class="quick-option">
|
||||
<a-radio-group v-model:value="selectedQuick">
|
||||
<a-radio value="20:00">
|
||||
<span class="option-label">每天 20:00(默认)</span>
|
||||
<span class="option-desc">推荐的默认时间</span>
|
||||
</a-radio>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义模式:可视化构建器 -->
|
||||
<div v-if="mode === 'custom'" class="mode-content">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="时间" name="customTime">
|
||||
<a-time-picker
|
||||
id="cron-custom-time"
|
||||
v-model:value="customTimeValue"
|
||||
format="HH:mm"
|
||||
placeholder="选择时间"
|
||||
:minute-step="30"
|
||||
style="width: 100%"
|
||||
@change="onCustomTimeChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="频率" name="customFrequency">
|
||||
<a-select id="cron-custom-frequency" v-model:value="customFrequency" style="width: 100%">
|
||||
<a-select-option value="daily">每天</a-select-option>
|
||||
<a-select-option value="weekday">工作日(周一-周五)</a-select-option>
|
||||
<a-select-option value="weekend">周末(周六-周日)</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- 高级模式:原始 Crontab 表达式 -->
|
||||
<div v-if="mode === 'advanced'" class="mode-content">
|
||||
<div class="expression-input">
|
||||
<a-textarea
|
||||
v-model:value="advancedExpression"
|
||||
placeholder="输入 crontab 表达式(例如:0 20 * * *)"
|
||||
:rows="2"
|
||||
@input="handleAdvancedInput"
|
||||
/>
|
||||
<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, watch, onBeforeUnmount } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import client from '@/api/client';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '0 0 * * *',
|
||||
},
|
||||
});
|
||||
|
||||
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 customTimeValue = ref(dayjs('20:00', 'HH:mm'));
|
||||
const customFrequency = ref('daily');
|
||||
|
||||
// 高级模式
|
||||
const advancedExpression = ref(props.modelValue || '0 20 * * *');
|
||||
const validationMessage = ref('');
|
||||
const validationStatus = ref('');
|
||||
|
||||
// 通用
|
||||
const nextExecutions = ref([]);
|
||||
|
||||
// 标志:是否正在手动编辑高级模式(防止自动解析导致模式切换)
|
||||
let isManualEditing = false;
|
||||
|
||||
// 切换模式 - 防止页面刷新
|
||||
function switchMode(newMode) {
|
||||
mode.value = newMode;
|
||||
|
||||
// 切换到快速模式时,自动选择默认值并触发保存
|
||||
if (newMode === 'quick') {
|
||||
selectedQuick.value = '20:00';
|
||||
const cron = buildCrontabFromQuick();
|
||||
advancedExpression.value = cron;
|
||||
emit('update:modelValue', cron);
|
||||
if (cron) validateAndPreview(cron);
|
||||
}
|
||||
// 切换到自定义模式时,基于当前值构建 cron
|
||||
else if (newMode === 'custom') {
|
||||
const cron = buildCrontabFromCustom();
|
||||
advancedExpression.value = cron;
|
||||
emit('update:modelValue', cron);
|
||||
if (cron) validateAndPreview(cron);
|
||||
}
|
||||
// 切换到高级模式时,使用当前的 advancedExpression
|
||||
else if (newMode === 'advanced') {
|
||||
if (advancedExpression.value) {
|
||||
emit('update:modelValue', advancedExpression.value);
|
||||
validateAndPreview(advancedExpression.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理时间选择器变化
|
||||
function onCustomTimeChange(time) {
|
||||
if (time) {
|
||||
customTime.value = time.format('HH:mm');
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 - 只在有效值时更新
|
||||
watch(selectedQuick, () => {
|
||||
const cron = buildCrontabFromQuick();
|
||||
advancedExpression.value = cron;
|
||||
emit('update:modelValue', cron);
|
||||
if (cron) validateAndPreview(cron);
|
||||
});
|
||||
|
||||
watch(customFrequency, () => {
|
||||
const cron = buildCrontabFromCustom();
|
||||
advancedExpression.value = cron;
|
||||
emit('update:modelValue', cron);
|
||||
if (cron) validateAndPreview(cron);
|
||||
});
|
||||
|
||||
watch(customTime, () => {
|
||||
const cron = buildCrontabFromCustom();
|
||||
advancedExpression.value = cron;
|
||||
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}`;
|
||||
}
|
||||
|
||||
// 处理高级模式输入 - 使用防抖以避免频繁调用API
|
||||
let debounceTimer = null;
|
||||
function handleAdvancedInput() {
|
||||
// 设置手动编辑标志
|
||||
isManualEditing = true;
|
||||
|
||||
// 立即触发 emit,保证值实时同步
|
||||
emit('update:modelValue', advancedExpression.value);
|
||||
|
||||
// 使用防抖延迟验证
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(async () => {
|
||||
if (!advancedExpression.value.trim()) {
|
||||
validationMessage.value = '';
|
||||
nextExecutions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
await validateAndPreview(advancedExpression.value);
|
||||
}, 500); // 500ms 防抖延迟
|
||||
}
|
||||
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 cron 表达式并设置对应的模式
|
||||
function parseCronExpression(cron) {
|
||||
if (!cron) return;
|
||||
|
||||
advancedExpression.value = cron;
|
||||
|
||||
// 尝试匹配快速模式: 0 20 * * *
|
||||
if (cron === '0 20 * * *') {
|
||||
mode.value = 'quick';
|
||||
selectedQuick.value = '20:00';
|
||||
validateAndPreview(cron);
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试解析为自定义模式
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length === 5) {
|
||||
const [minute, hour, day, month, dow] = parts;
|
||||
|
||||
// 检查是否是简单的每天或工作日/周末模式
|
||||
if (day === '*' && month === '*') {
|
||||
const hourNum = parseInt(hour);
|
||||
const minuteNum = parseInt(minute);
|
||||
|
||||
if (
|
||||
!isNaN(hourNum) &&
|
||||
!isNaN(minuteNum) &&
|
||||
hourNum >= 0 &&
|
||||
hourNum < 24 &&
|
||||
minuteNum >= 0 &&
|
||||
minuteNum < 60
|
||||
) {
|
||||
mode.value = 'custom';
|
||||
customTime.value = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
|
||||
customTimeValue.value = dayjs(customTime.value, 'HH:mm');
|
||||
|
||||
// 识别频率
|
||||
if (dow === '*') {
|
||||
customFrequency.value = 'daily';
|
||||
} else if (dow === '1-5') {
|
||||
customFrequency.value = 'weekday';
|
||||
} else if (dow === '0,6' || dow === '6,0') {
|
||||
customFrequency.value = 'weekend';
|
||||
} else {
|
||||
// 不支持的星期模式,使用高级模式
|
||||
mode.value = 'advanced';
|
||||
}
|
||||
|
||||
validateAndPreview(cron);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 其他情况使用高级模式
|
||||
mode.value = 'advanced';
|
||||
validateAndPreview(cron);
|
||||
}
|
||||
|
||||
// 初始化 - 解析传入的 cron 表达式
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newVal => {
|
||||
// 如果正在手动编辑高级模式,跳过自动解析
|
||||
if (isManualEditing) {
|
||||
isManualEditing = false; // 重置标志
|
||||
return;
|
||||
}
|
||||
|
||||
if (newVal) {
|
||||
parseCronExpression(newVal);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 组件卸载时清理防抖定时器,防止内存泄漏
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* === Material Design 3 样式重写 === */
|
||||
|
||||
.crontab-editor {
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
background-color: var(--md-sys-color-surface-container-lowest);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.crontab-editor:focus-within {
|
||||
border-color: var(--md-sys-color-primary);
|
||||
box-shadow: 0 0 0 1px var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
/* 模式选择标签 */
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
padding: 10px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1px;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mode-tab:hover {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
background-color: rgba(76, 175, 80, 0.04);
|
||||
}
|
||||
|
||||
.mode-tab.active {
|
||||
color: var(--md-sys-color-primary);
|
||||
border-bottom-color: var(--md-sys-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 模式内容区域 */
|
||||
.mode-content {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* 快速选项 */
|
||||
.quick-option {
|
||||
padding: 16px;
|
||||
background-color: var(--md-sys-color-surface);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.quick-option:hover {
|
||||
border-color: var(--md-sys-color-outline);
|
||||
box-shadow:
|
||||
0px 1px 2px 0px rgba(0, 0, 0, 0.3),
|
||||
0px 1px 3px 1px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
margin-left: 12px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
/* 表达式输入 */
|
||||
.expression-input {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
margin-top: 8px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.help-text a {
|
||||
color: var(--md-sys-color-secondary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.help-text a:hover {
|
||||
color: var(--md-sys-color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 预览区域 */
|
||||
.preview-section {
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.preview-section h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
|
||||
.execution-list {
|
||||
margin: 0;
|
||||
padding-left: 24px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
.execution-list li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 验证消息 */
|
||||
.validation-message {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.25px;
|
||||
border: 1px solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.validation-message.success {
|
||||
background-color: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.validation-message.error {
|
||||
background-color: var(--md-sys-color-error-container);
|
||||
color: var(--md-sys-color-on-error-container);
|
||||
border-color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
.validation-message.info {
|
||||
background-color: var(--md-sys-color-surface-container-high);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
border-color: var(--md-sys-color-outline-variant);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div class="field-config-editor space-y-4">
|
||||
<!-- Row 1: Display Name and Field Type -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<a-form-item label="显示名称" class="mb-0">
|
||||
<a-input
|
||||
:value="modelValue.display_name"
|
||||
placeholder="在表单中显示的名称"
|
||||
allow-clear
|
||||
@change="e => updateField('display_name', e.target.value)"
|
||||
/>
|
||||
<span class="text-xs text-on-surface-variant mt-1">显示名称</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="字段类型" class="mb-0">
|
||||
<a-select
|
||||
:value="modelValue.field_type"
|
||||
placeholder="选择输入控件类型"
|
||||
class="w-full"
|
||||
@change="handleFieldTypeChange"
|
||||
>
|
||||
<a-select-option label="📝 单行文本" value="text" />
|
||||
<a-select-option label="📄 多行文本" value="textarea" />
|
||||
<a-select-option label="🔢 数字输入" value="number" />
|
||||
<a-select-option label="📋 下拉选择" value="select" />
|
||||
</a-select>
|
||||
<span class="text-xs text-on-surface-variant mt-1">用户填写时使用的输入控件</span>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Value Type and Default Value -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<a-form-item label="值类型" class="mb-0">
|
||||
<a-select
|
||||
:value="modelValue.value_type"
|
||||
placeholder="选择数据类型"
|
||||
class="w-full"
|
||||
@change="value => updateField('value_type', value)"
|
||||
>
|
||||
<a-select-option label="字符串 (string)" value="string">
|
||||
<span class="text-xs text-on-surface-variant">字符串 (string)</span>
|
||||
</a-select-option>
|
||||
<a-select-option label="整数 (int)" value="int">
|
||||
<span class="text-xs text-on-surface-variant">整数 (int)</span>
|
||||
</a-select-option>
|
||||
<a-select-option label="浮点数 (double)" value="double">
|
||||
<span class="text-xs text-on-surface-variant">浮点数 (double)</span>
|
||||
</a-select-option>
|
||||
<a-select-option label="布尔值 (bool)" value="bool">
|
||||
<span class="text-xs text-on-surface-variant">布尔值 (bool)</span>
|
||||
</a-select-option>
|
||||
<a-select-option label="JSON对象 (json)" value="json">
|
||||
<span class="text-xs text-on-surface-variant">JSON对象 (json) - 用于Values字段</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<span class="text-xs text-on-surface-variant mt-1">数据存储时的类型</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="默认值" class="mb-0">
|
||||
<a-input
|
||||
v-if="modelValue.value_type !== 'json'"
|
||||
:value="modelValue.default_value"
|
||||
placeholder="字段的默认值"
|
||||
allow-clear
|
||||
@change="e => updateField('default_value', e.target.value)"
|
||||
/>
|
||||
<a-textarea
|
||||
v-else
|
||||
:value="modelValue.default_value"
|
||||
placeholder="字段的默认值"
|
||||
:rows="3"
|
||||
allow-clear
|
||||
@change="e => updateField('default_value', e.target.value)"
|
||||
/>
|
||||
<span class="text-xs text-on-surface-variant mt-1">
|
||||
<template v-if="modelValue.value_type === 'json'">
|
||||
<p>输入JSON对象,会自动序列化为字符串</p>
|
||||
<p>如:{"key1":value1,"key2":value2}</p>
|
||||
</template>
|
||||
<template v-else> 用户未填写时使用此值 </template>
|
||||
</span>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Placeholder -->
|
||||
<a-form-item label="占位符提示" class="mb-0">
|
||||
<a-input
|
||||
:value="modelValue.placeholder"
|
||||
placeholder="输入框的灰色提示文本"
|
||||
allow-clear
|
||||
@change="e => updateField('placeholder', e.target.value)"
|
||||
/>
|
||||
<span class="text-xs text-on-surface-variant mt-1">占位符</span>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Row 4: Switches -->
|
||||
<div
|
||||
class="grid grid-cols-2 gap-4 p-3 bg-surface-container-low rounded-md3 border border-outline-variant"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-on-surface">是否必填</label>
|
||||
<p class="text-xs text-on-surface-variant">用户必须填写此字段</p>
|
||||
</div>
|
||||
<a-switch
|
||||
:checked="modelValue.required"
|
||||
:disabled="modelValue.hidden"
|
||||
@change="handleRequiredChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-on-surface">是否隐藏</label>
|
||||
<p class="text-xs text-on-surface-variant">直接使用默认值,不在表单中显示</p>
|
||||
</div>
|
||||
<a-switch :checked="modelValue.hidden" @change="handleHiddenChange" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-alert v-if="modelValue.hidden" message="💡 提示" type="info" :closable="false" class="mt-3">
|
||||
<template #description>
|
||||
<p class="text-xs">
|
||||
隐藏字段将自动使用默认值,不会在创建任务表单中显示。请确保设置了合适的默认值。
|
||||
</p>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- Options for select type -->
|
||||
<div v-if="modelValue.field_type === 'select'" class="border-t pt-4 mt-4">
|
||||
<a-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-surface-container rounded-md3"
|
||||
>
|
||||
<span class="text-xs text-on-surface-variant w-8">{{ index + 1 }}.</span>
|
||||
<a-input
|
||||
:value="option.label"
|
||||
placeholder="显示文本(如:健康)"
|
||||
size="small"
|
||||
class="flex-1"
|
||||
@change="e => updateOption(index, 'label', e.target.value)"
|
||||
/>
|
||||
<a-input
|
||||
:value="option.value"
|
||||
placeholder="选项值(如:healthy)"
|
||||
size="small"
|
||||
class="flex-1"
|
||||
@change="e => updateOption(index, 'value', e.target.value)"
|
||||
/>
|
||||
<a-button size="small" danger @click="removeOption(index)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-button size="small" type="primary" class="w-full" @click="addOption">
|
||||
<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>
|
||||
添加选项
|
||||
</a-button>
|
||||
|
||||
<p class="text-xs text-on-surface-variant mt-2">
|
||||
💡 提示:显示文本是用户看到的内容,选项值是实际保存的数据
|
||||
</p>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import { DeleteOutlined } from '@ant-design/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>
|
||||
/* 样式已移至全局 CSS (style.css) 以保持统一性 */
|
||||
</style>
|
||||
@@ -0,0 +1,630 @@
|
||||
<template>
|
||||
<div
|
||||
class="field-tree-node border border-outline-variant rounded-md3 p-4 bg-surface shadow-md3-1 hover:shadow-md3-2 transition-shadow"
|
||||
>
|
||||
<!-- 普通字段 -->
|
||||
<div v-if="isFieldConfig" class="field-config">
|
||||
<div class="flex items-center justify-between mb-3 pb-2 border-b border-outline-variant">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
|
||||
@click="isCollapsed = !isCollapsed"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-on-surface-variant 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-primary" 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-primary">{{ fieldKey }}</span>
|
||||
<a-tag type="primary" size="small">普通字段</a-tag>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a-button size="small" title="上移" @click="handleMove('up')">
|
||||
<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>
|
||||
</a-button>
|
||||
<a-button size="small" title="下移" @click="handleMove('down')">
|
||||
<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>
|
||||
</a-button>
|
||||
<a-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>
|
||||
删除
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="!isCollapsed" class="bg-surface-container-low rounded-md3 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-outline-variant">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
|
||||
@click="isCollapsed = !isCollapsed"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-on-surface-variant 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-secondary" 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-secondary">{{ fieldKey }}</span>
|
||||
<a-tag type="warning" size="small">数组字段</a-tag>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a-button size="small" title="上移" @click="handleMove('up')">
|
||||
<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>
|
||||
</a-button>
|
||||
<a-button size="small" title="下移" @click="handleMove('down')">
|
||||
<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>
|
||||
</a-button>
|
||||
<a-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>
|
||||
添加元素
|
||||
</a-button>
|
||||
<a-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>
|
||||
删除
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="!isCollapsed">
|
||||
<div
|
||||
v-if="localFieldConfig.length === 0"
|
||||
class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline"
|
||||
>
|
||||
<p class="text-sm text-on-surface-variant mb-2">数组为空</p>
|
||||
<a-button size="small" type="primary" @click="addArrayItem">添加第一个元素</a-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3 mt-3">
|
||||
<div
|
||||
v-for="(item, index) in localFieldConfig"
|
||||
:key="index"
|
||||
class="border border-outline-variant rounded-md3 p-3 bg-surface-container"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-semibold text-secondary">元素 #{{ index + 1 }}</span>
|
||||
<a-button size="small" type="danger" plain @click="removeArrayItem(index)">
|
||||
删除元素
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 如果数组元素是字段配置对象,直接渲染为字段编辑器 -->
|
||||
<div
|
||||
v-if="typeof item === 'object' && !Array.isArray(item) && 'display_name' in item"
|
||||
class="bg-surface rounded-md3 p-3"
|
||||
>
|
||||
<FieldConfigEditor
|
||||
:model-value="item"
|
||||
:field-key="`元素${index + 1}`"
|
||||
@update:model-value="updateArrayItemField(index, $event)"
|
||||
/>
|
||||
</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)"
|
||||
/>
|
||||
|
||||
<a-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>
|
||||
添加字段
|
||||
</a-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-outline-variant">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
|
||||
@click="isCollapsed = !isCollapsed"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-on-surface-variant 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-accent" 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-accent">{{ fieldKey }}</span>
|
||||
<a-tag type="success" size="small">对象字段</a-tag>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a-button size="small" title="上移" @click="handleMove('up')">
|
||||
<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>
|
||||
</a-button>
|
||||
<a-button size="small" title="下移" @click="handleMove('down')">
|
||||
<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>
|
||||
</a-button>
|
||||
<a-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>
|
||||
添加子字段
|
||||
</a-button>
|
||||
<a-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>
|
||||
删除
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="!isCollapsed">
|
||||
<div
|
||||
v-if="Object.keys(localFieldConfig).length === 0"
|
||||
class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline"
|
||||
>
|
||||
<p class="text-sm text-on-surface-variant mb-2">对象为空</p>
|
||||
<a-button size="small" type="primary" @click="addFieldToObject"
|
||||
>添加第一个子字段</a-button
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3 mt-3 pl-4 border-l-4 border-accent">
|
||||
<!-- 递归渲染对象中的字段 -->
|
||||
<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>
|
||||
|
||||
<!-- 添加字段对话框 -->
|
||||
<a-modal
|
||||
v-model:open="addFieldDialogVisible"
|
||||
:title="currentArrayIndex === -1 ? '添加数组元素' : '添加字段'"
|
||||
width="400px"
|
||||
>
|
||||
<a-form>
|
||||
<a-form-item :label="currentArrayIndex === -1 ? '字段名(可选)' : '字段名'">
|
||||
<a-input
|
||||
v-model:value="newFieldName"
|
||||
:placeholder="
|
||||
currentArrayIndex === -1
|
||||
? '留空则作为数组元素,填写则作为对象字段'
|
||||
: '例如: FieldId, Values, Texts'
|
||||
"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="元素类型">
|
||||
<a-radio-group v-model:value="newFieldType">
|
||||
<a-radio value="field">普通字段</a-radio>
|
||||
<a-radio value="array">数组字段</a-radio>
|
||||
<a-radio value="object">对象字段</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="addFieldDialogVisible = false">取消</a-button>
|
||||
<a-button type="primary" @click="confirmAddField">确定</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
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;
|
||||
message.success({ content: '数组元素添加成功', duration: 2 });
|
||||
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;
|
||||
message.success({ content: '带命名字段的对象添加成功', duration: 2 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 其他情况需要字段名
|
||||
if (!newFieldName.value) {
|
||||
message.warning({ content: '请输入字段名', duration: 2 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAddingToObject.value) {
|
||||
// 添加到对象字段
|
||||
if (localFieldConfig.value[newFieldName.value]) {
|
||||
message.warning({ content: '该字段已存在', duration: 2 });
|
||||
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]) {
|
||||
message.warning({ content: '该字段已存在', duration: 2 });
|
||||
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;
|
||||
message.success({ content: '字段添加成功', duration: 2 });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-tree-node {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="layout-container">
|
||||
<Navbar />
|
||||
<div class="main-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import Navbar from './Navbar.vue';
|
||||
import { useTokenMonitor } from '@/composables/useTokenMonitor';
|
||||
|
||||
// 启动全局 Token 监控
|
||||
const { startMonitoring } = useTokenMonitor();
|
||||
|
||||
onMounted(() => {
|
||||
startMonitoring();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--md-sys-color-surface-container-lowest) 0%,
|
||||
var(--md-sys-color-surface-container-low) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,473 @@
|
||||
<template>
|
||||
<div
|
||||
class="navbar-wrapper sticky top-0 z-50"
|
||||
:style="{
|
||||
backgroundColor: isDark ? '#1c1b1f' : '#ffffff',
|
||||
boxShadow: isDark
|
||||
? '0 2px 8px rgba(0, 0, 0, 0.6), 0 4px 16px rgba(0, 0, 0, 0.4)'
|
||||
: '0 2px 8px rgba(0, 0, 0, 0.15), 0 4px 16px rgba(0, 0, 0, 0.1)',
|
||||
borderBottom: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.08)',
|
||||
}"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<CheckCircleOutlined class="text-white text-xl" />
|
||||
</div>
|
||||
<span class="text-xl font-bold text-gradient">接龙自动打卡</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Desktop Navigation Links -->
|
||||
<div v-if="!isMobile" class="hidden md:flex items-center space-x-2">
|
||||
<router-link v-slot="{ isActive }" to="/dashboard" custom>
|
||||
<a
|
||||
:class="[
|
||||
'nav-button px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
|
||||
isActive
|
||||
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
|
||||
: 'text-on-surface',
|
||||
]"
|
||||
@click="router.push('/dashboard')"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<HomeOutlined />
|
||||
<span>仪表盘</span>
|
||||
</div>
|
||||
</a>
|
||||
</router-link>
|
||||
|
||||
<router-link v-slot="{ isActive }" to="/tasks" custom>
|
||||
<a
|
||||
:class="[
|
||||
'nav-button px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
|
||||
isActive
|
||||
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
|
||||
: 'text-on-surface',
|
||||
]"
|
||||
@click="router.push('/tasks')"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FileTextOutlined />
|
||||
<span>任务管理</span>
|
||||
</div>
|
||||
</a>
|
||||
</router-link>
|
||||
|
||||
<router-link v-slot="{ isActive }" to="/records" custom>
|
||||
<a
|
||||
:class="[
|
||||
'nav-button px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
|
||||
isActive
|
||||
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
|
||||
: 'text-on-surface',
|
||||
]"
|
||||
@click="router.push('/records')"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<UnorderedListOutlined />
|
||||
<span>打卡记录</span>
|
||||
</div>
|
||||
</a>
|
||||
</router-link>
|
||||
|
||||
<!-- Admin Dropdown Menu -->
|
||||
<a-dropdown v-if="authStore.isAdmin" :trigger="['hover']">
|
||||
<a
|
||||
:class="[
|
||||
'admin-nav-button px-4 py-2 rounded-full font-medium transition-all flex items-center space-x-2 cursor-pointer',
|
||||
isAdminPath
|
||||
? 'bg-secondary-100 dark:bg-secondary-900/30 text-secondary-700 dark:text-secondary-400'
|
||||
: 'text-on-surface',
|
||||
]"
|
||||
>
|
||||
<SettingOutlined />
|
||||
<span>管理后台</span>
|
||||
<DownOutlined class="text-xs" />
|
||||
</a>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="users" @click="router.push('/admin/users')">
|
||||
<UserOutlined />
|
||||
<span class="ml-2">用户管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="templates" @click="router.push('/admin/templates')">
|
||||
<FileOutlined />
|
||||
<span class="ml-2">模板管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="records" @click="router.push('/admin/records')">
|
||||
<CheckSquareOutlined />
|
||||
<span class="ml-2">打卡记录</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="stats" @click="router.push('/admin/stats')">
|
||||
<BarChartOutlined />
|
||||
<span class="ml-2">统计信息</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="logs" @click="router.push('/admin/logs')">
|
||||
<FileTextOutlined />
|
||||
<span class="ml-2">系统日志</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Menu & Mobile Hamburger -->
|
||||
<div class="flex items-center space-x-2 md:space-x-4">
|
||||
<!-- Token Status Indicator (Desktop & Mobile) -->
|
||||
<a-tooltip v-if="showTokenStatus" :title="tokenStatusTooltip">
|
||||
<div
|
||||
class="navbar-item px-2 md:px-3 py-1.5 rounded-full cursor-pointer transition-all flex items-center space-x-1 md:space-x-2"
|
||||
@click="handleTokenStatusClick"
|
||||
>
|
||||
<a-badge :status="tokenBadgeStatus" />
|
||||
<ClockCircleOutlined :class="[tokenIconClass, 'text-sm md:text-base']" />
|
||||
<span class="text-xs md:text-sm hidden sm:inline">{{ tokenBadgeText }}</span>
|
||||
<!-- 过期时显示刷新按钮(响应式设计) -->
|
||||
<a-button
|
||||
v-if="remainingMinutes !== null && remainingMinutes < 0"
|
||||
type="primary"
|
||||
size="small"
|
||||
class="!text-xs !px-2 md:!px-3"
|
||||
@click.stop="handleRefreshToken"
|
||||
>
|
||||
<span class="hidden sm:inline">刷新</span>
|
||||
<ReloadOutlined class="sm:hidden" />
|
||||
</a-button>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- Theme Toggle Button -->
|
||||
<a-tooltip :title="isDark ? '切换到亮色模式' : '切换到暗色模式'" placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
class="navbar-item w-10 h-10 rounded-full flex items-center justify-center transition-all"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<BulbFilled v-if="isDark" class="text-xl text-yellow-400" />
|
||||
<BulbOutlined v-else class="text-xl text-on-surface" />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- Desktop User Menu -->
|
||||
<a-dropdown v-if="!isMobile" :trigger="['hover']">
|
||||
<a
|
||||
class="navbar-item flex items-center space-x-3 px-4 py-2 rounded-full transition-all cursor-pointer"
|
||||
>
|
||||
<a-avatar :style="{ backgroundColor: '#f56a00' }">
|
||||
{{ userInitial }}
|
||||
</a-avatar>
|
||||
<span class="hidden md:block font-medium text-on-surface">{{
|
||||
authStore.user?.alias || '用户'
|
||||
}}</span>
|
||||
<DownOutlined class="text-xs text-on-surface-variant" />
|
||||
</a>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="info" disabled>
|
||||
<div class="px-2 py-1">
|
||||
<p class="text-sm font-medium text-on-surface">{{ authStore.user?.alias }}</p>
|
||||
<p class="text-xs text-on-surface-variant mt-1">
|
||||
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
|
||||
</p>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="settings" @click="router.push('/settings')">
|
||||
<SettingOutlined />
|
||||
<span class="ml-2">个人设置</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="logout" danger @click="handleLogout">
|
||||
<LogoutOutlined />
|
||||
<span class="ml-2">退出登录</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<!-- Mobile Hamburger Button -->
|
||||
<button
|
||||
v-if="isMobile"
|
||||
type="button"
|
||||
class="navbar-item w-10 h-10 rounded-full flex items-center justify-center transition-all"
|
||||
@click="drawerVisible = true"
|
||||
>
|
||||
<MenuOutlined class="text-xl text-on-surface" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Drawer -->
|
||||
<a-drawer v-model:open="drawerVisible" placement="left" :width="280" title="菜单">
|
||||
<!-- User Info in Drawer -->
|
||||
<div class="mb-6 pb-4 border-b border-outline-variant">
|
||||
<div class="flex items-center space-x-3">
|
||||
<a-avatar :size="48" :style="{ backgroundColor: '#f56a00' }">
|
||||
{{ userInitial }}
|
||||
</a-avatar>
|
||||
<div>
|
||||
<p class="font-medium text-on-surface">{{ authStore.user?.alias || '用户' }}</p>
|
||||
<p class="text-xs text-on-surface-variant">
|
||||
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation Menu -->
|
||||
<a-menu mode="inline" :selected-keys="[currentMenuKey]" @click="handleMenuClick">
|
||||
<a-menu-item key="dashboard">
|
||||
<template #icon><HomeOutlined /></template>
|
||||
仪表盘
|
||||
</a-menu-item>
|
||||
<a-menu-item key="tasks">
|
||||
<template #icon><FileTextOutlined /></template>
|
||||
任务管理
|
||||
</a-menu-item>
|
||||
<a-menu-item key="records">
|
||||
<template #icon><UnorderedListOutlined /></template>
|
||||
打卡记录
|
||||
</a-menu-item>
|
||||
|
||||
<!-- Admin Menu Group -->
|
||||
<a-sub-menu v-if="authStore.isAdmin" key="admin">
|
||||
<template #icon><SettingOutlined /></template>
|
||||
<template #title>管理后台</template>
|
||||
<a-menu-item key="admin-users">
|
||||
<template #icon><UserOutlined /></template>
|
||||
用户管理
|
||||
</a-menu-item>
|
||||
<a-menu-item key="admin-templates">
|
||||
<template #icon><FileOutlined /></template>
|
||||
模板管理
|
||||
</a-menu-item>
|
||||
<a-menu-item key="admin-records">
|
||||
<template #icon><CheckSquareOutlined /></template>
|
||||
打卡记录
|
||||
</a-menu-item>
|
||||
<a-menu-item key="admin-stats">
|
||||
<template #icon><BarChartOutlined /></template>
|
||||
统计信息
|
||||
</a-menu-item>
|
||||
<a-menu-item key="admin-logs">
|
||||
<template #icon><FileTextOutlined /></template>
|
||||
系统日志
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
|
||||
<a-menu-divider />
|
||||
|
||||
<a-menu-item key="settings">
|
||||
<template #icon><SettingOutlined /></template>
|
||||
个人设置
|
||||
</a-menu-item>
|
||||
<a-menu-item key="logout" danger>
|
||||
<template #icon><LogoutOutlined /></template>
|
||||
退出登录
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-drawer>
|
||||
|
||||
<!-- Token 刷新 QR 码模态框 -->
|
||||
<QRCodeModal
|
||||
v-model:visible="qrcodeModalVisible"
|
||||
:alias="authStore.user?.alias || ''"
|
||||
@success="handleQRCodeSuccess"
|
||||
@error="handleQRCodeError"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useTokenMonitor } from '@/composables/useTokenMonitor';
|
||||
import { useBreakpoint } from '@/composables/useBreakpoint';
|
||||
import { useTheme } from '@/composables/useTheme';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import QRCodeModal from './QRCodeModal.vue';
|
||||
import {
|
||||
MenuOutlined,
|
||||
HomeOutlined,
|
||||
FileTextOutlined,
|
||||
UnorderedListOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
FileOutlined,
|
||||
CheckSquareOutlined,
|
||||
BarChartOutlined,
|
||||
LogoutOutlined,
|
||||
DownOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
BulbOutlined,
|
||||
BulbFilled,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const userStore = useUserStore();
|
||||
const { isMobile } = useBreakpoint();
|
||||
const { getRemainingMinutes, tokenStatus, stopMonitoring } = useTokenMonitor();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const qrcodeModalVisible = ref(false);
|
||||
|
||||
const isAdminPath = computed(() => route.path.startsWith('/admin'));
|
||||
|
||||
const userInitial = computed(() => {
|
||||
const name = authStore.user?.alias || 'U';
|
||||
return name.charAt(0).toUpperCase();
|
||||
});
|
||||
|
||||
// Token 状态计算
|
||||
const remainingMinutes = computed(() => {
|
||||
return getRemainingMinutes();
|
||||
});
|
||||
|
||||
const showTokenStatus = computed(() => {
|
||||
if (!authStore.isAuthenticated || !tokenStatus.value) return false;
|
||||
|
||||
const mins = remainingMinutes.value;
|
||||
// 显示条件:Token 即将过期(60分钟内)或已过期(5分钟内)
|
||||
if (mins === null) return false;
|
||||
return mins <= 60 || (mins < 0 && Math.abs(mins) <= 5);
|
||||
});
|
||||
|
||||
const tokenBadgeStatus = computed(() => {
|
||||
const mins = remainingMinutes.value;
|
||||
if (mins === null) return 'default';
|
||||
if (mins < 0) return 'error'; // 已过期
|
||||
if (mins <= 10) return 'error'; // 10分钟内过期
|
||||
if (mins <= 30) return 'warning'; // 30分钟内过期
|
||||
return 'processing'; // 正常但快过期
|
||||
});
|
||||
|
||||
const tokenBadgeText = computed(() => {
|
||||
const mins = remainingMinutes.value;
|
||||
if (mins === null) return '';
|
||||
if (mins < 0) return 'Token 已过期';
|
||||
if (mins < 60) return `Token 剩余:${mins}分钟`;
|
||||
return '';
|
||||
});
|
||||
|
||||
const tokenIconClass = computed(() => {
|
||||
const mins = remainingMinutes.value;
|
||||
if (mins === null) return 'text-on-surface-variant';
|
||||
if (mins < 0) return 'text-red-500 dark:text-red-400'; // 已过期
|
||||
if (mins <= 10) return 'text-red-500 dark:text-red-400 animate-pulse'; // 10分钟内,闪烁
|
||||
if (mins <= 30) return 'text-orange-500 dark:text-orange-400'; // 30分钟内
|
||||
return 'text-blue-500 dark:text-blue-400'; // 正常
|
||||
});
|
||||
|
||||
const tokenStatusTooltip = computed(() => {
|
||||
const mins = remainingMinutes.value;
|
||||
if (mins === null) return 'Token 状态未知';
|
||||
if (mins < 0) {
|
||||
const expiredMins = Math.abs(mins);
|
||||
return `登录凭证已过期 ${expiredMins} 分钟,点击右侧按钮刷新`;
|
||||
}
|
||||
if (mins < 60) {
|
||||
return `Token 剩余时间:${mins} 分钟,过期后可刷新`;
|
||||
}
|
||||
return 'Token 状态正常';
|
||||
});
|
||||
|
||||
const handleTokenStatusClick = () => {
|
||||
const mins = remainingMinutes.value;
|
||||
|
||||
// Token 已过期时提醒刷新
|
||||
if (mins !== null && mins < 0) {
|
||||
message.info({ content: 'Token 已过期,请进行刷新', duration: 3 });
|
||||
}
|
||||
// Token 未过期时,点击无效果
|
||||
};
|
||||
|
||||
const currentMenuKey = computed(() => {
|
||||
const path = route.path;
|
||||
if (path.startsWith('/admin/users')) return 'admin-users';
|
||||
if (path.startsWith('/admin/templates')) return 'admin-templates';
|
||||
if (path.startsWith('/admin/records')) return 'admin-records';
|
||||
if (path.startsWith('/admin/stats')) return 'admin-stats';
|
||||
if (path.startsWith('/admin/logs')) return 'admin-logs';
|
||||
if (path.startsWith('/dashboard')) return 'dashboard';
|
||||
if (path.startsWith('/tasks')) return 'tasks';
|
||||
if (path.startsWith('/records')) return 'records';
|
||||
if (path.startsWith('/settings')) return 'settings';
|
||||
return '';
|
||||
});
|
||||
|
||||
const handleMenuClick = ({ key }) => {
|
||||
const routes = {
|
||||
dashboard: '/dashboard',
|
||||
tasks: '/tasks',
|
||||
records: '/records',
|
||||
'admin-users': '/admin/users',
|
||||
'admin-templates': '/admin/templates',
|
||||
'admin-records': '/admin/records',
|
||||
'admin-stats': '/admin/stats',
|
||||
'admin-logs': '/admin/logs',
|
||||
settings: '/settings',
|
||||
};
|
||||
|
||||
if (key === 'logout') {
|
||||
handleLogout();
|
||||
} else if (routes[key]) {
|
||||
router.push(routes[key]);
|
||||
drawerVisible.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
Modal.confirm({
|
||||
title: '提示',
|
||||
content: '确定要退出登录吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk() {
|
||||
// 停止 token 监控
|
||||
stopMonitoring();
|
||||
|
||||
// 清除登录状态
|
||||
authStore.logout();
|
||||
router.push('/login');
|
||||
drawerVisible.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 处理 Token 刷新
|
||||
const handleRefreshToken = () => {
|
||||
qrcodeModalVisible.value = true;
|
||||
};
|
||||
|
||||
// 处理 QR 码扫码成功
|
||||
const handleQRCodeSuccess = async () => {
|
||||
message.success({ content: 'Token 刷新成功', duration: 3 });
|
||||
qrcodeModalVisible.value = false;
|
||||
|
||||
// 刷新用户信息和 Token 状态
|
||||
try {
|
||||
await authStore.fetchCurrentUser();
|
||||
await userStore.fetchTokenStatus();
|
||||
} catch (error) {
|
||||
console.error('刷新用户信息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理 QR 码扫码失败
|
||||
const handleQRCodeError = error => {
|
||||
message.error({ content: error?.message || 'Token 刷新失败', duration: 4 });
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="dialogVisible"
|
||||
title="QQ 扫码登录"
|
||||
:width="isMobile ? '100%' : 400"
|
||||
:style="isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : {}"
|
||||
:mask-closable="false"
|
||||
:footer="null"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<div class="qrcode-container">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="status === 'loading'" class="status-container">
|
||||
<a-spin size="large" />
|
||||
<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>
|
||||
<a-progress :percent="progress" :show-info="false" />
|
||||
<p class="countdown-text">{{ countdown }}s</p>
|
||||
</div>
|
||||
|
||||
<!-- 扫码成功 -->
|
||||
<div v-else-if="status === 'success'" class="status-container">
|
||||
<CheckCircleFilled class="status-icon success-icon" />
|
||||
<p class="status-text success">登录成功!</p>
|
||||
</div>
|
||||
|
||||
<!-- 二维码过期 -->
|
||||
<div v-else-if="status === 'expired'" class="status-container">
|
||||
<WarningFilled class="status-icon warning-icon" />
|
||||
<p class="status-text">二维码已过期</p>
|
||||
<a-button type="primary" class="mt-4" @click="refreshQRCode">刷新二维码</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 失败 -->
|
||||
<div v-else-if="status === 'failed'" class="status-container">
|
||||
<CloseCircleFilled class="status-icon error-icon" />
|
||||
<p class="status-text error">{{ errorMessage }}</p>
|
||||
<a-button type="primary" class="mt-4" @click="refreshQRCode">重试</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useBreakpoint } from '@/composables/useBreakpoint';
|
||||
import { usePollStatus } from '@/composables/usePollStatus';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { CheckCircleFilled, WarningFilled, CloseCircleFilled } from '@ant-design/icons-vue';
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
alias: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success', 'error']);
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { isMobile } = useBreakpoint();
|
||||
|
||||
// 使用轮询 composable
|
||||
const { startPolling: startQRPolling, stopPolling } = usePollStatus({
|
||||
interval: 2000,
|
||||
maxRetries: 90, // 3分钟 = 180秒 / 2秒间隔 = 90次
|
||||
backoff: false,
|
||||
});
|
||||
|
||||
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 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';
|
||||
|
||||
// 开始轮询扫码状态(使用 composable)
|
||||
startQRPolling(
|
||||
async () => {
|
||||
const result = await authStore.checkQRCodeStatus(sessionId.value);
|
||||
|
||||
// 检查是否完成(成功、过期或失败)
|
||||
const completed =
|
||||
result.status === 'expired' || result.status === 'failed' || result.success;
|
||||
|
||||
return {
|
||||
completed,
|
||||
success: result.success === true,
|
||||
data: result,
|
||||
};
|
||||
},
|
||||
{
|
||||
onSuccess: result => {
|
||||
status.value = 'success';
|
||||
stopCountdown();
|
||||
message.success({ content: '登录成功!', duration: 2 });
|
||||
|
||||
// 延迟关闭对话框
|
||||
setTimeout(() => {
|
||||
emit('success', result.user);
|
||||
handleClose();
|
||||
}, 1500);
|
||||
},
|
||||
onFailure: result => {
|
||||
if (result.status === 'expired') {
|
||||
status.value = 'expired';
|
||||
} else {
|
||||
status.value = 'failed';
|
||||
errorMessage.value = result.message || '扫码失败';
|
||||
}
|
||||
stopCountdown();
|
||||
},
|
||||
onTimeout: () => {
|
||||
status.value = 'expired';
|
||||
stopCountdown();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
startCountdown();
|
||||
} catch (error) {
|
||||
status.value = 'failed';
|
||||
errorMessage.value = error.message || '获取二维码失败';
|
||||
emit('error', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 开始倒计时
|
||||
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();
|
||||
|
||||
// 如果有未完成的会话,取消它
|
||||
if (sessionId.value && status.value !== 'success') {
|
||||
try {
|
||||
authStore.cancelQRCodeSession(sessionId.value);
|
||||
} catch (error) {
|
||||
console.error('取消会话失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 监听对话框显示状态
|
||||
watch(
|
||||
() => props.visible,
|
||||
visible => {
|
||||
if (visible) {
|
||||
fetchQRCode();
|
||||
} else {
|
||||
stopPolling();
|
||||
stopCountdown();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 组件卸载时清理定时器,防止内存泄漏
|
||||
onBeforeUnmount(() => {
|
||||
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-icon {
|
||||
font-size: 60px;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.dark .success-icon {
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.dark .warning-icon {
|
||||
color: #ffb74d;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.dark .error-icon {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
margin-top: 20px;
|
||||
font-size: 16px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
.status-text.success {
|
||||
color: #4caf50;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dark .status-text.success {
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
.status-text.error {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.dark .status-text.error {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
.qrcode-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qrcode-image {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background-color: var(--md-sys-color-surface);
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
.countdown-text {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<a-card class="md3-card text-center" style="padding: 48px 20px">
|
||||
<!-- 图标 -->
|
||||
<div v-if="icon" class="mb-6">
|
||||
<component :is="icon" class="text-8xl mx-auto" :class="iconColorClass" />
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<h3 class="md3-title-large text-on-surface mb-2">
|
||||
{{ title || '暂无数据' }}
|
||||
</h3>
|
||||
|
||||
<!-- 描述 -->
|
||||
<p class="md3-body-medium text-on-surface-variant mb-6">
|
||||
{{ description || '当前没有内容可显示' }}
|
||||
</p>
|
||||
|
||||
<!-- 操作按钮(可选) -->
|
||||
<div v-if="$slots.action || actionText">
|
||||
<slot name="action">
|
||||
<a-button v-if="actionText" type="primary" :loading="loading" @click="handleAction">
|
||||
<template v-if="actionIcon" #icon>
|
||||
<component :is="actionIcon" />
|
||||
</template>
|
||||
{{ actionText }}
|
||||
</a-button>
|
||||
</slot>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 图标组件
|
||||
*/
|
||||
icon: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
|
||||
/**
|
||||
* 标题文本
|
||||
*/
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
/**
|
||||
* 描述文本
|
||||
*/
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
/**
|
||||
* 操作按钮文本
|
||||
*/
|
||||
actionText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
/**
|
||||
* 操作按钮图标
|
||||
*/
|
||||
actionIcon: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载状态
|
||||
*/
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* 图标颜色
|
||||
*/
|
||||
iconColor: {
|
||||
type: String,
|
||||
default: 'neutral',
|
||||
validator: v => ['primary', 'neutral', 'success', 'warning', 'error'].includes(v),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
|
||||
const handleAction = () => {
|
||||
emit('action');
|
||||
};
|
||||
|
||||
const iconColorClass = computed(() => {
|
||||
const colors = {
|
||||
primary: 'text-primary',
|
||||
neutral: 'text-on-surface-variant',
|
||||
success: 'text-green-500',
|
||||
warning: 'text-orange-500',
|
||||
error: 'text-error',
|
||||
};
|
||||
return colors[props.iconColor];
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div v-if="loading" class="loading-state">
|
||||
<!-- 卡片骨架屏 -->
|
||||
<div v-if="type === 'card'" class="grid grid-cols-1 gap-4">
|
||||
<a-card v-for="i in count" :key="i" class="md3-card">
|
||||
<a-skeleton :active="true" :paragraph="{ rows: paragraphRows }" :avatar="showAvatar" />
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 列表骨架屏 -->
|
||||
<div v-else-if="type === 'list'" class="space-y-4">
|
||||
<a-card v-for="i in count" :key="i" class="md3-card">
|
||||
<a-skeleton :active="true" :paragraph="{ rows: 1 }" :avatar="showAvatar" />
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 表格骨架屏 -->
|
||||
<a-card v-else-if="type === 'table'" class="md3-card">
|
||||
<a-skeleton :active="true" :paragraph="{ rows: count * 2 }" />
|
||||
</a-card>
|
||||
|
||||
<!-- 默认骨架屏 -->
|
||||
<a-card v-else class="md3-card">
|
||||
<a-skeleton :active="true" :paragraph="{ rows: paragraphRows }" :avatar="showAvatar" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
/**
|
||||
* 是否显示加载状态
|
||||
*/
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* 骨架屏类型
|
||||
*/
|
||||
type: {
|
||||
type: String,
|
||||
default: 'card',
|
||||
validator: v => ['card', 'list', 'table', 'default'].includes(v),
|
||||
},
|
||||
|
||||
/**
|
||||
* 骨架屏数量
|
||||
*/
|
||||
count: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
|
||||
/**
|
||||
* 段落行数
|
||||
*/
|
||||
paragraphRows: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
},
|
||||
|
||||
/**
|
||||
* 是否显示头像
|
||||
*/
|
||||
showAvatar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-state {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<a-card
|
||||
class="md3-card animate-slide-up transition-standard hover:elevation-3"
|
||||
:style="{ animationDelay }"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- 数值和标签 -->
|
||||
<div class="flex-1">
|
||||
<p class="md3-label-medium text-on-surface-variant mb-1">{{ label }}</p>
|
||||
<p class="md3-headline-medium" :class="valueColorClass">
|
||||
{{ formattedValue }}
|
||||
</p>
|
||||
<p v-if="subtitle" class="md3-body-small text-on-surface-variant mt-1">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 图标 -->
|
||||
<div
|
||||
v-if="icon"
|
||||
class="w-12 h-12 rounded-md3 flex items-center justify-center flex-shrink-0 ml-4"
|
||||
:class="iconBgClass"
|
||||
>
|
||||
<component :is="icon" :class="iconColorClass" class="text-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 趋势指示器(可选) -->
|
||||
<div v-if="trend !== undefined" class="mt-3 pt-3 border-t border-outline-variant">
|
||||
<div class="flex items-center text-sm">
|
||||
<component :is="trendIcon" :class="trendColorClass" class="mr-1" />
|
||||
<span :class="trendColorClass" class="md3-label-small">
|
||||
{{ trendText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, MinusOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 卡片标签
|
||||
*/
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* 显示的数值
|
||||
*/
|
||||
value: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* 副标题/描述
|
||||
*/
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
/**
|
||||
* 图标组件
|
||||
*/
|
||||
icon: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
|
||||
/**
|
||||
* 颜色主题
|
||||
*/
|
||||
color: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
validator: v => ['primary', 'success', 'warning', 'error', 'info', 'neutral'].includes(v),
|
||||
},
|
||||
|
||||
/**
|
||||
* 动画延迟(秒)
|
||||
*/
|
||||
delay: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
|
||||
/**
|
||||
* 格式化函数
|
||||
*/
|
||||
formatter: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
|
||||
/**
|
||||
* 趋势值(正数上升,负数下降,0持平)
|
||||
*/
|
||||
trend: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
/**
|
||||
* 趋势文本
|
||||
*/
|
||||
trendText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
// 动画延迟
|
||||
const animationDelay = computed(() => `${props.delay}s`);
|
||||
|
||||
// 格式化数值
|
||||
const formattedValue = computed(() => {
|
||||
if (props.formatter) {
|
||||
return props.formatter(props.value);
|
||||
}
|
||||
return props.value;
|
||||
});
|
||||
|
||||
// 颜色映射
|
||||
const colorClasses = {
|
||||
primary: {
|
||||
value: 'text-primary',
|
||||
iconBg: 'bg-primary-100 dark:bg-primary-900/30',
|
||||
icon: 'text-primary',
|
||||
},
|
||||
success: {
|
||||
value: 'text-green-600 dark:text-green-400',
|
||||
iconBg: 'bg-green-100 dark:bg-green-900/30',
|
||||
icon: 'text-green-600 dark:text-green-400',
|
||||
},
|
||||
warning: {
|
||||
value: 'text-orange-600 dark:text-orange-400',
|
||||
iconBg: 'bg-orange-100 dark:bg-orange-900/30',
|
||||
icon: 'text-orange-600 dark:text-orange-400',
|
||||
},
|
||||
error: {
|
||||
value: 'text-error',
|
||||
iconBg: 'bg-red-100 dark:bg-red-900/30',
|
||||
icon: 'text-error',
|
||||
},
|
||||
info: {
|
||||
value: 'text-secondary',
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
icon: 'text-secondary',
|
||||
},
|
||||
neutral: {
|
||||
value: 'text-on-surface',
|
||||
iconBg: 'bg-surface-container',
|
||||
icon: 'text-on-surface-variant',
|
||||
},
|
||||
};
|
||||
|
||||
const valueColorClass = computed(() => colorClasses[props.color].value);
|
||||
const iconBgClass = computed(() => colorClasses[props.color].iconBg);
|
||||
const iconColorClass = computed(() => colorClasses[props.color].icon);
|
||||
|
||||
// 趋势图标和颜色
|
||||
const trendIcon = computed(() => {
|
||||
if (props.trend === undefined) return null;
|
||||
if (props.trend > 0) return ArrowUpOutlined;
|
||||
if (props.trend < 0) return ArrowDownOutlined;
|
||||
return MinusOutlined;
|
||||
});
|
||||
|
||||
const trendColorClass = computed(() => {
|
||||
if (props.trend === undefined) return '';
|
||||
if (props.trend > 0) return 'text-green-600 dark:text-green-400';
|
||||
if (props.trend < 0) return 'text-red-600 dark:text-red-400';
|
||||
return 'text-on-surface-variant';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.md3-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 通用异步操作 Composable
|
||||
* 统一处理 loading、error 状态和消息提示
|
||||
*
|
||||
* @example
|
||||
* const { loading, error, execute } = useAsyncAction()
|
||||
*
|
||||
* const handleSubmit = async () => {
|
||||
* await execute(
|
||||
* () => api.createTask(formData),
|
||||
* { successMsg: '创建成功', errorMsg: '创建失败' }
|
||||
* )
|
||||
* }
|
||||
*/
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
export function useAsyncAction(options = {}) {
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
/**
|
||||
* 执行异步操作
|
||||
* @param {Function} asyncFn - 异步函数
|
||||
* @param {Object} config - 配置选项
|
||||
* @param {string} config.successMsg - 成功提示消息
|
||||
* @param {string} config.errorMsg - 错误提示消息
|
||||
* @param {boolean} config.throwOnError - 是否抛出错误
|
||||
* @param {boolean} config.silent - 是否静默模式(不显示消息)
|
||||
* @returns {Promise} 异步函数的返回值
|
||||
*/
|
||||
const execute = async (asyncFn, config = {}) => {
|
||||
const {
|
||||
successMsg = options.successMsg,
|
||||
errorMsg = options.errorMsg,
|
||||
throwOnError = false,
|
||||
silent = false,
|
||||
} = config;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const result = await asyncFn();
|
||||
|
||||
if (!silent && successMsg) {
|
||||
message.success({ content: successMsg, duration: 3 });
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
|
||||
if (!silent) {
|
||||
const msg = err.message || err.detail || errorMsg || '操作失败';
|
||||
message.error({ content: msg, duration: 4 });
|
||||
}
|
||||
|
||||
if (throwOnError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
return null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置状态
|
||||
*/
|
||||
const reset = () => {
|
||||
loading.value = false;
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
execute,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
/**
|
||||
* 响应式断点检测 Composable
|
||||
* 基于 Ant Design 的断点系统
|
||||
* - xs: <576px (手机)
|
||||
* - sm: ≥576px (平板竖屏)
|
||||
* - md: ≥768px (平板横屏)
|
||||
* - lg: ≥992px (桌面)
|
||||
* - xl: ≥1200px (大屏)
|
||||
* - xxl: ≥1600px (超大屏)
|
||||
*/
|
||||
export function useBreakpoint() {
|
||||
const isMobile = ref(window.innerWidth < 768);
|
||||
const isTablet = ref(window.innerWidth >= 768 && window.innerWidth < 992);
|
||||
const isDesktop = ref(window.innerWidth >= 992);
|
||||
|
||||
// Ant Design 断点
|
||||
const isXs = ref(window.innerWidth < 576);
|
||||
const isSm = ref(window.innerWidth >= 576 && window.innerWidth < 768);
|
||||
const isMd = ref(window.innerWidth >= 768 && window.innerWidth < 992);
|
||||
const isLg = ref(window.innerWidth >= 992 && window.innerWidth < 1200);
|
||||
const isXl = ref(window.innerWidth >= 1200 && window.innerWidth < 1600);
|
||||
const isXxl = ref(window.innerWidth >= 1600);
|
||||
|
||||
const updateBreakpoints = () => {
|
||||
const width = window.innerWidth;
|
||||
|
||||
// 简化断点
|
||||
isMobile.value = width < 768;
|
||||
isTablet.value = width >= 768 && width < 992;
|
||||
isDesktop.value = width >= 992;
|
||||
|
||||
// Ant Design 断点
|
||||
isXs.value = width < 576;
|
||||
isSm.value = width >= 576 && width < 768;
|
||||
isMd.value = width >= 768 && width < 992;
|
||||
isLg.value = width >= 992 && width < 1200;
|
||||
isXl.value = width >= 1200 && width < 1600;
|
||||
isXxl.value = width >= 1600;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', updateBreakpoints);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateBreakpoints);
|
||||
});
|
||||
|
||||
return {
|
||||
// 简化断点(常用)
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
|
||||
// Ant Design 断点(详细)
|
||||
isXs,
|
||||
isSm,
|
||||
isMd,
|
||||
isLg,
|
||||
isXl,
|
||||
isXxl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 状态轮询 Composable
|
||||
* 支持指数退避、最大重试次数、自动清理
|
||||
*
|
||||
* @example
|
||||
* const { polling, startPolling, stopPolling } = usePollStatus({
|
||||
* interval: 2000,
|
||||
* maxRetries: 15,
|
||||
* backoff: true
|
||||
* })
|
||||
*
|
||||
* startPolling(
|
||||
* async () => {
|
||||
* const status = await api.getStatus(id)
|
||||
* return {
|
||||
* completed: status.status !== 'pending',
|
||||
* success: status.status === 'success',
|
||||
* data: status
|
||||
* }
|
||||
* },
|
||||
* {
|
||||
* onSuccess: (result) => console.log('完成', result),
|
||||
* onFailure: (error) => console.error('失败', error),
|
||||
* onTimeout: () => console.warn('超时')
|
||||
* }
|
||||
* )
|
||||
*/
|
||||
|
||||
import { ref, onUnmounted } from 'vue';
|
||||
|
||||
export function usePollStatus(options = {}) {
|
||||
const {
|
||||
interval = 2000, // 初始轮询间隔(毫秒)
|
||||
maxRetries = 15, // 最大重试次数
|
||||
backoff = false, // 是否使用指数退避
|
||||
maxBackoffInterval = 10000, // 最大退避间隔(毫秒)
|
||||
} = options;
|
||||
|
||||
const polling = ref(false);
|
||||
let pollTimer = null;
|
||||
let retryCount = 0;
|
||||
|
||||
/**
|
||||
* 开始轮询
|
||||
* @param {Function} checkFn - 检查函数,应返回 { completed, success, data }
|
||||
* @param {Object} callbacks - 回调函数
|
||||
* @param {Function} callbacks.onSuccess - 成功回调
|
||||
* @param {Function} callbacks.onFailure - 失败回调
|
||||
* @param {Function} callbacks.onTimeout - 超时回调
|
||||
*/
|
||||
const startPolling = async (checkFn, callbacks = {}) => {
|
||||
const { onSuccess, onFailure, onTimeout } = callbacks;
|
||||
|
||||
// 重置状态
|
||||
stopPolling();
|
||||
polling.value = true;
|
||||
retryCount = 0;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const result = await checkFn();
|
||||
|
||||
// 检查是否完成
|
||||
if (result.completed) {
|
||||
stopPolling();
|
||||
|
||||
if (result.success) {
|
||||
onSuccess?.(result.data || result);
|
||||
} else {
|
||||
onFailure?.(result.data || result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否超时
|
||||
retryCount++;
|
||||
if (retryCount >= maxRetries) {
|
||||
stopPolling();
|
||||
onTimeout?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算下次轮询间隔(支持指数退避)
|
||||
let nextInterval = interval;
|
||||
if (backoff) {
|
||||
// 指数退避:2s -> 4s -> 8s -> 最大10s
|
||||
nextInterval = Math.min(interval * Math.pow(2, retryCount - 1), maxBackoffInterval);
|
||||
}
|
||||
|
||||
// 继续轮询
|
||||
pollTimer = setTimeout(poll, nextInterval);
|
||||
} catch (error) {
|
||||
stopPolling();
|
||||
onFailure?.(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 立即执行第一次检查
|
||||
poll();
|
||||
};
|
||||
|
||||
/**
|
||||
* 停止轮询
|
||||
*/
|
||||
const stopPolling = () => {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
polling.value = false;
|
||||
retryCount = 0;
|
||||
};
|
||||
|
||||
// 组件卸载时自动清理
|
||||
onUnmounted(() => {
|
||||
stopPolling();
|
||||
});
|
||||
|
||||
return {
|
||||
polling,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const THEME_STORAGE_KEY = 'checkin-app-theme';
|
||||
|
||||
// 全局主题状态(单例模式)
|
||||
const theme = ref('light');
|
||||
|
||||
/**
|
||||
* 应用主题到 DOM
|
||||
*/
|
||||
const applyTheme = newTheme => {
|
||||
const html = document.documentElement;
|
||||
|
||||
if (newTheme === 'dark') {
|
||||
html.classList.add('dark');
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化主题
|
||||
* 优先级: localStorage > 系统偏好 > 默认亮色
|
||||
*/
|
||||
export const initTheme = () => {
|
||||
// 1. 尝试从 localStorage 读取
|
||||
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (savedTheme === 'light' || savedTheme === 'dark') {
|
||||
theme.value = savedTheme;
|
||||
applyTheme(savedTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 检测系统偏好
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
theme.value = 'dark';
|
||||
applyTheme('dark');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 默认亮色
|
||||
theme.value = 'light';
|
||||
applyTheme('light');
|
||||
};
|
||||
|
||||
/**
|
||||
* 监听系统主题变化
|
||||
*/
|
||||
export const watchSystemTheme = () => {
|
||||
if (!window.matchMedia) return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleChange = e => {
|
||||
// 仅在用户未手动设置主题时才跟随系统
|
||||
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (!savedTheme) {
|
||||
const systemTheme = e.matches ? 'dark' : 'light';
|
||||
theme.value = systemTheme;
|
||||
applyTheme(systemTheme);
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
|
||||
// 返回清理函数
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
};
|
||||
|
||||
/**
|
||||
* 主题管理 Composable
|
||||
* 支持亮色/暗色模式切换,并持久化到 localStorage
|
||||
*/
|
||||
export function useTheme() {
|
||||
/**
|
||||
* 切换主题
|
||||
*/
|
||||
const toggleTheme = () => {
|
||||
const newTheme = theme.value === 'light' ? 'dark' : 'light';
|
||||
theme.value = newTheme;
|
||||
applyTheme(newTheme);
|
||||
localStorage.setItem(THEME_STORAGE_KEY, newTheme);
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置指定主题
|
||||
*/
|
||||
const setTheme = newTheme => {
|
||||
if (newTheme !== 'light' && newTheme !== 'dark') {
|
||||
console.warn(`Invalid theme: ${newTheme}. Using 'light' instead.`);
|
||||
newTheme = 'light';
|
||||
}
|
||||
|
||||
theme.value = newTheme;
|
||||
applyTheme(newTheme);
|
||||
localStorage.setItem(THEME_STORAGE_KEY, newTheme);
|
||||
};
|
||||
|
||||
return {
|
||||
theme,
|
||||
toggleTheme,
|
||||
setTheme,
|
||||
isDark: computed(() => theme.value === 'dark'),
|
||||
isLight: computed(() => theme.value === 'light'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
/**
|
||||
* Token 过期监控 Composable
|
||||
*
|
||||
* 功能:
|
||||
* 1. 定时检查 Token 状态
|
||||
* 2. Token 过期后 5 分钟内提醒用户
|
||||
* 3. 为有密码的用户提供友好的过期处理
|
||||
*
|
||||
* 注意:使用单例模式,确保全局只有一个监控实例
|
||||
*/
|
||||
|
||||
// 全局单例:确保整个应用只有一个监控实例
|
||||
let monitorTimer = null;
|
||||
let warningShown = false;
|
||||
let isMonitoring = false; // 新增:防止重复启动
|
||||
|
||||
// 检查间隔(毫秒)
|
||||
const NORMAL_CHECK_INTERVAL = 15 * 60 * 1000; // 正常情况:15 分钟
|
||||
const URGENT_CHECK_INTERVAL = 5 * 60 * 1000; // Token 即将过期:5 分钟
|
||||
|
||||
export function useTokenMonitor() {
|
||||
const authStore = useAuthStore();
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
const tokenStatus = computed(() => userStore.tokenStatus);
|
||||
const hasPassword = computed(() => authStore.user?.has_password || false);
|
||||
|
||||
// 计算 Token 剩余分钟数
|
||||
const getRemainingMinutes = () => {
|
||||
if (!tokenStatus.value?.expires_at) return null;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiresAt = tokenStatus.value.expires_at;
|
||||
const diffSeconds = expiresAt - now;
|
||||
|
||||
return Math.floor(diffSeconds / 60);
|
||||
};
|
||||
|
||||
// 检查 Token 状态并显示提醒
|
||||
const checkTokenStatus = async () => {
|
||||
// 如果未登录,不检查
|
||||
if (!authStore.isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取最新的 Token 状态
|
||||
await userStore.fetchTokenStatus();
|
||||
|
||||
const remainingMinutes = getRemainingMinutes();
|
||||
|
||||
// Token 已过期(负数分钟)
|
||||
if (remainingMinutes !== null && remainingMinutes < 0) {
|
||||
const expiredMinutes = Math.abs(remainingMinutes);
|
||||
|
||||
// Token 过期后 5 分钟内提醒
|
||||
if (expiredMinutes <= 5) {
|
||||
if (hasPassword.value) {
|
||||
// 有密码的用户:友好提示
|
||||
if (!warningShown) {
|
||||
message.warning({
|
||||
content: `您的 Token 已过期 ${expiredMinutes} 分钟,部分功能可能受限。建议您扫码刷新凭证。`,
|
||||
duration: 3,
|
||||
key: 'token-expired-warning',
|
||||
});
|
||||
warningShown = true;
|
||||
}
|
||||
} else {
|
||||
// 没有密码的用户:必须重新登录
|
||||
message.error({
|
||||
content: '您的 Token 已过期,请重新扫码登录',
|
||||
duration: 5,
|
||||
key: 'token-expired-error',
|
||||
});
|
||||
|
||||
// 清除登录状态并跳转
|
||||
authStore.logout();
|
||||
router.push('/login');
|
||||
}
|
||||
} else if (expiredMinutes > 5) {
|
||||
// 过期超过 5 分钟
|
||||
if (!hasPassword.value) {
|
||||
// 没有密码的用户:强制退出
|
||||
authStore.logout();
|
||||
router.push('/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Token 即将过期(1小时内)
|
||||
else if (remainingMinutes !== null && remainingMinutes > 0 && remainingMinutes <= 60) {
|
||||
if (!warningShown) {
|
||||
message.warning({
|
||||
content: `您的 Token 将在 ${remainingMinutes} 分钟后过期,建议您及时刷新`,
|
||||
duration: 3,
|
||||
key: 'token-expiring-warning',
|
||||
});
|
||||
warningShown = true;
|
||||
}
|
||||
|
||||
// Token 即将过期时,切换到更频繁的检查(5 分钟)
|
||||
adjustCheckInterval(URGENT_CHECK_INTERVAL);
|
||||
}
|
||||
// Token 状态正常
|
||||
else if (remainingMinutes !== null && remainingMinutes > 60) {
|
||||
// 重置警告标志
|
||||
warningShown = false;
|
||||
|
||||
// 恢复正常检查频率(15 分钟)
|
||||
adjustCheckInterval(NORMAL_CHECK_INTERVAL);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查 Token 状态失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 调整检查间隔
|
||||
const adjustCheckInterval = newInterval => {
|
||||
if (monitorTimer) {
|
||||
const currentInterval = monitorTimer._idleTimeout || 0;
|
||||
|
||||
// 只有当新间隔与当前间隔不同时才重启定时器
|
||||
if (currentInterval !== newInterval) {
|
||||
clearInterval(monitorTimer);
|
||||
monitorTimer = setInterval(() => {
|
||||
checkTokenStatus();
|
||||
}, newInterval);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 启动监控
|
||||
const startMonitoring = () => {
|
||||
// 避免重复启动(单例模式)
|
||||
if (isMonitoring || monitorTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
isMonitoring = true;
|
||||
|
||||
// 立即检查一次
|
||||
checkTokenStatus();
|
||||
|
||||
// 默认使用正常检查频率(15 分钟)
|
||||
monitorTimer = setInterval(() => {
|
||||
checkTokenStatus();
|
||||
}, NORMAL_CHECK_INTERVAL);
|
||||
};
|
||||
|
||||
// 停止监控
|
||||
const stopMonitoring = () => {
|
||||
if (monitorTimer) {
|
||||
clearInterval(monitorTimer);
|
||||
monitorTimer = null;
|
||||
}
|
||||
isMonitoring = false;
|
||||
warningShown = false;
|
||||
};
|
||||
|
||||
// 手动触发检查
|
||||
const checkNow = () => {
|
||||
warningShown = false; // 重置警告标志,允许再次显示
|
||||
checkTokenStatus();
|
||||
};
|
||||
|
||||
// 组件挂载时启动监控
|
||||
onMounted(() => {
|
||||
if (authStore.isAuthenticated) {
|
||||
startMonitoring();
|
||||
}
|
||||
});
|
||||
|
||||
// 组件卸载时不停止监控(因为是全局单例)
|
||||
// onUnmounted 中不调用 stopMonitoring(),让监控持续运行
|
||||
|
||||
return {
|
||||
tokenStatus,
|
||||
hasPassword,
|
||||
startMonitoring,
|
||||
stopMonitoring,
|
||||
checkNow,
|
||||
getRemainingMinutes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
|
||||
// Ant Design Vue
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './style.css';
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
// Ant Design Vue
|
||||
app.use(Antd);
|
||||
|
||||
// 全局未捕获的 Promise 错误处理
|
||||
window.addEventListener('unhandledrejection', event => {
|
||||
console.error('未捕获的 Promise 错误:', event.reason);
|
||||
|
||||
// 显示用户友好的错误提示
|
||||
const errorMessage = event.reason?.message || event.reason || '操作失败';
|
||||
|
||||
// 只对非网络错误显示提示(网络错误已在 axios 拦截器中处理)
|
||||
if (!errorMessage.includes('网络错误') && !errorMessage.includes('请求超时')) {
|
||||
message.error({
|
||||
content: `操作失败: ${errorMessage}`,
|
||||
duration: 3,
|
||||
});
|
||||
}
|
||||
|
||||
// 阻止默认的控制台错误输出(已经用 console.error 输出了)
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// 全局错误处理(捕获 Vue 组件内的错误)
|
||||
app.config.errorHandler = (err, instance, info) => {
|
||||
console.error('Vue 错误:', err);
|
||||
console.error('错误信息:', info);
|
||||
console.error('组件实例:', instance);
|
||||
|
||||
// 显示用户友好的错误提示
|
||||
message.error({
|
||||
content: '应用发生错误,请刷新页面重试',
|
||||
duration: 3,
|
||||
});
|
||||
};
|
||||
|
||||
app.mount('#app');
|
||||
@@ -0,0 +1,162 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { userAPI } from '@/api';
|
||||
|
||||
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 status = await userAPI.getUserStatus();
|
||||
|
||||
if (!status.is_approved) {
|
||||
// 未审批用户只能访问待审批页面
|
||||
next({ name: 'PendingApproval' });
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查审批状态失败:', error);
|
||||
// 如果检查失败,允许继续访问(避免阻塞正常用户)
|
||||
}
|
||||
} else {
|
||||
// 访问待审批页面时,检查是否已审批
|
||||
try {
|
||||
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,62 @@
|
||||
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 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,133 @@
|
||||
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 || '检查扫码状态失败');
|
||||
}
|
||||
},
|
||||
|
||||
// 取消扫码会话
|
||||
async cancelQRCodeSession(sessionId) {
|
||||
try {
|
||||
await authAPI.cancelQRCodeSession(sessionId);
|
||||
} catch (error) {
|
||||
console.error('取消会话失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 验证 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,96 @@
|
||||
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,
|
||||
});
|
||||
// 后端现在返回 { records, total, skip, limit }
|
||||
this.myRecords = data.records || data;
|
||||
this.total = data.total || 0;
|
||||
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,
|
||||
});
|
||||
// 后端现在返回 { records, total, skip, limit }
|
||||
this.allRecords = data.records || data;
|
||||
this.total = data.total || 0;
|
||||
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,164 @@
|
||||
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 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) {
|
||||
// 保留原任务的 last_check_in_time 和 last_check_in_status
|
||||
const originalTask = this.tasks[index];
|
||||
this.tasks[index] = {
|
||||
...updatedTask,
|
||||
last_check_in_time: updatedTask.last_check_in_time || originalTask.last_check_in_time,
|
||||
last_check_in_status:
|
||||
updatedTask.last_check_in_status || originalTask.last_check_in_status,
|
||||
};
|
||||
}
|
||||
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) {
|
||||
const result = await api.task.getCheckInRecordStatus(recordId);
|
||||
return result;
|
||||
},
|
||||
|
||||
// 获取任务的打卡记录
|
||||
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,169 @@
|
||||
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,
|
||||
cronExpression = '0 20 * * *'
|
||||
) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const task = await templateAPI.createTaskFromTemplate({
|
||||
template_id: templateId,
|
||||
thread_id: threadId,
|
||||
field_values: fieldValues,
|
||||
task_name: taskName,
|
||||
cron_expression: cronExpression,
|
||||
});
|
||||
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,94 @@
|
||||
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 || '删除用户失败');
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,547 @@
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="dashboard-container">
|
||||
<!-- 邮箱未设置提醒 -->
|
||||
<a-alert
|
||||
v-if="!authStore.user?.email"
|
||||
message="您还未设置邮箱地址"
|
||||
type="info"
|
||||
:closable="true"
|
||||
show-icon
|
||||
style="margin-bottom: 20px"
|
||||
>
|
||||
<template #description>
|
||||
<div>
|
||||
设置邮箱后可以接收打卡任务的通知和提醒。
|
||||
<a style="margin-left: 8px; cursor: pointer" @click="goToSettings"> 立即前往设置 → </a>
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- 密码未设置提醒 -->
|
||||
<a-alert
|
||||
v-if="!authStore.user?.has_password"
|
||||
message="您还未设置登录密码"
|
||||
type="info"
|
||||
:closable="true"
|
||||
show-icon
|
||||
style="margin-bottom: 20px"
|
||||
>
|
||||
<template #description>
|
||||
<div>
|
||||
设置密码后可以使用用户名+密码快速登录。
|
||||
<a style="margin-left: 8px; cursor: pointer" @click="goToSettings"> 立即前往设置 → </a>
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- Token 已过期提醒 -->
|
||||
<a-alert
|
||||
v-if="tokenStatus && !tokenStatus.is_valid"
|
||||
message="打卡凭证已过期"
|
||||
type="warning"
|
||||
:closable="true"
|
||||
show-icon
|
||||
style="margin-bottom: 20px"
|
||||
>
|
||||
<template #description>
|
||||
<div>
|
||||
打卡凭证已过期,无法自动打卡。请扫码刷新 Token。
|
||||
<a style="margin-left: 8px; cursor: pointer" @click="qrcodeModalVisible = true">
|
||||
立即刷新 →
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- 没有打卡任务提醒 -->
|
||||
<a-alert
|
||||
v-if="!taskStore.loading && taskStore.tasks.length === 0"
|
||||
message="您还没有打卡任务"
|
||||
type="info"
|
||||
:closable="true"
|
||||
show-icon
|
||||
style="margin-bottom: 20px"
|
||||
>
|
||||
<template #description>
|
||||
<div>
|
||||
创建您的第一个打卡任务,开启自动打卡之旅。
|
||||
<a style="margin-left: 8px; cursor: pointer" @click="goToTasks"> 立即创建 → </a>
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-row :gutter="[20, 20]">
|
||||
<!-- Token 状态卡片 -->
|
||||
<a-col :xs="24" :sm="24" :md="24">
|
||||
<a-card class="status-card md3-card">
|
||||
<template #title>
|
||||
<div class="card-header">
|
||||
<KeyOutlined />
|
||||
<span>Token 状态</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="tokenStatusLoading" class="loading-container">
|
||||
<a-skeleton :active="true" :paragraph="{ rows: 3 }" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="tokenStatus" class="token-status">
|
||||
<a-descriptions :column="{ xs: 1, sm: 1, md: 2 }" bordered>
|
||||
<a-descriptions-item label="Token 状态">
|
||||
<a-tag :color="tokenStatus.is_valid ? 'success' : 'error'">
|
||||
{{ tokenStatus.is_valid ? '有效' : '无效' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
|
||||
<a-descriptions-item label="过期时间">
|
||||
{{ formatExpireTime }}
|
||||
</a-descriptions-item>
|
||||
|
||||
<a-descriptions-item label="剩余时间">
|
||||
<a-tag
|
||||
v-if="tokenStatus.is_valid"
|
||||
:color="tokenStatus.expiring_soon ? 'warning' : 'success'"
|
||||
>
|
||||
{{ formatRemainTime }}
|
||||
</a-tag>
|
||||
<a-tag v-else color="error">已过期</a-tag>
|
||||
</a-descriptions-item>
|
||||
|
||||
<a-descriptions-item label="即将过期">
|
||||
<a-tag v-if="!tokenStatus.is_valid" color="error"> 已过期 </a-tag>
|
||||
<a-tag v-else :color="tokenStatus.expiring_soon ? 'warning' : 'success'">
|
||||
{{ tokenStatus.expiring_soon ? '是' : '否' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 刷新 Token 按钮 -->
|
||||
<div style="margin-top: 24px; text-align: center">
|
||||
<!-- Token 未过期时:禁用按钮并显示提示 -->
|
||||
<a-tooltip v-if="tokenStatus.is_valid" title="Token 过期后才可以扫码刷新 Token">
|
||||
<a-button type="primary" size="large" :disabled="true">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新 Token
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- Token 已过期时:启用按钮且无提示 -->
|
||||
<a-button v-else type="primary" size="large" @click="qrcodeModalVisible = true">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新 Token
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
v-if="tokenStatus.expiring_soon"
|
||||
message="Token 即将过期"
|
||||
description="您的 Token 将在 30 分钟内过期,请在过期后及时刷新 Token!"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-top: 15px"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- 手动打卡卡片 -->
|
||||
<a-col :xs="24" :sm="24" :md="24">
|
||||
<a-card class="md3-card">
|
||||
<template #title>
|
||||
<div class="card-header">
|
||||
<CalendarOutlined />
|
||||
<span>手动打卡</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="check-in-container">
|
||||
<p class="hint">选择任务并点击下方按钮立即执行打卡操作</p>
|
||||
|
||||
<!-- 任务选择 -->
|
||||
<a-select
|
||||
v-model:value="selectedTaskId"
|
||||
placeholder="请选择要打卡的任务"
|
||||
:loading="taskStore.loading"
|
||||
style="width: 100%; max-width: 400px; margin-bottom: 20px"
|
||||
>
|
||||
<a-select-option v-for="task in taskStore.tasks" :key="task.id" :value="task.id">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center">
|
||||
<span>{{ task.name }}</span>
|
||||
<a-tag size="small" :color="task.is_active ? 'success' : 'default'">
|
||||
{{ task.is_active ? '启用' : '禁用' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="checkInLoading"
|
||||
:disabled="!selectedTaskId"
|
||||
@click="handleCheckIn"
|
||||
>
|
||||
<template #icon><CalendarOutlined /></template>
|
||||
{{ checkInLoading ? '打卡中...' : '立即打卡' }}
|
||||
</a-button>
|
||||
|
||||
<div v-if="lastCheckIn" class="last-check-in">
|
||||
<a-divider />
|
||||
<p class="label">上次打卡</p>
|
||||
<a-descriptions :column="{ xs: 1, sm: 1, md: 2 }" bordered size="small">
|
||||
<a-descriptions-item label="时间">
|
||||
{{ formatDateTime(lastCheckIn.check_in_time) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag
|
||||
:color="
|
||||
lastCheckIn.status === 'success'
|
||||
? 'success'
|
||||
: lastCheckIn.status === 'out_of_time'
|
||||
? 'default'
|
||||
: lastCheckIn.status === 'unknown'
|
||||
? 'warning'
|
||||
: 'error'
|
||||
"
|
||||
>
|
||||
{{
|
||||
lastCheckIn.status === 'success'
|
||||
? '成功'
|
||||
: lastCheckIn.status === 'out_of_time'
|
||||
? '时间范围外'
|
||||
: lastCheckIn.status === 'unknown'
|
||||
? '异常'
|
||||
: '失败'
|
||||
}}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="打卡响应" :span="{ xs: 1, sm: 1, md: 2 }">
|
||||
{{ lastCheckIn.response_text || lastCheckIn.error_message || '-' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- 用户信息卡片 -->
|
||||
<a-col :xs="24" :sm="24" :md="24">
|
||||
<a-card class="md3-card">
|
||||
<template #title>
|
||||
<div class="card-header">
|
||||
<UserOutlined />
|
||||
<span>个人信息</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-descriptions :column="{ xs: 1, sm: 1, md: 2 }" bordered>
|
||||
<a-descriptions-item label="用户名">
|
||||
{{ authStore.user?.alias }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="角色">
|
||||
<a-tag :color="authStore.isAdmin ? 'error' : 'blue'">
|
||||
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">
|
||||
{{ authStore.user?.email || '未设置' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="注册时间">
|
||||
{{ formatDateTime(authStore.user?.created_at, false) }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Modal for Token Refresh -->
|
||||
<QRCodeModal
|
||||
v-model:visible="qrcodeModalVisible"
|
||||
:alias="authStore.user?.alias || ''"
|
||||
@success="handleQRCodeSuccess"
|
||||
@error="handleQRCodeError"
|
||||
/>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { CalendarOutlined, KeyOutlined, UserOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import Layout from '@/components/Layout.vue';
|
||||
import QRCodeModal from '@/components/QRCodeModal.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';
|
||||
import { usePollStatus } from '@/composables/usePollStatus';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const userStore = useUserStore();
|
||||
const taskStore = useTaskStore();
|
||||
const checkInStore = useCheckInStore();
|
||||
|
||||
// 使用轮询 composable
|
||||
const { startPolling } = usePollStatus({
|
||||
interval: 2000, // 每 2 秒轮询一次
|
||||
maxRetries: 15, // 最多 15 次 (30 秒)
|
||||
backoff: false, // 不使用指数退避
|
||||
});
|
||||
|
||||
const tokenStatusLoading = ref(false);
|
||||
const checkInLoading = ref(false);
|
||||
const selectedTaskId = ref(null);
|
||||
const qrcodeModalVisible = ref(false);
|
||||
|
||||
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) return '-';
|
||||
|
||||
// Token 无效时,尝试从 user.jwt_exp 获取过期时间
|
||||
if (!tokenStatus.value.expires_at) {
|
||||
// 如果后端没有返回 expires_at,说明 Token 可能无效或未设置
|
||||
const jwtExp = authStore.user?.jwt_exp;
|
||||
if (jwtExp && jwtExp !== '0') {
|
||||
try {
|
||||
const timestamp = parseInt(jwtExp);
|
||||
return formatDateTime(timestamp * 1000);
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
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} 分钟`;
|
||||
});
|
||||
|
||||
// 跳转到设置页面
|
||||
const goToSettings = () => {
|
||||
router.push('/settings');
|
||||
};
|
||||
|
||||
// 跳转到任务页面
|
||||
const goToTasks = () => {
|
||||
router.push('/tasks');
|
||||
};
|
||||
|
||||
// 获取 Token 状态
|
||||
const fetchTokenStatus = async () => {
|
||||
tokenStatusLoading.value = true;
|
||||
try {
|
||||
await userStore.fetchTokenStatus();
|
||||
} catch (error) {
|
||||
message.error(error.message || '获取 Token 状态失败');
|
||||
} finally {
|
||||
tokenStatusLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 手动打卡
|
||||
const handleCheckIn = async () => {
|
||||
if (!selectedTaskId.value) {
|
||||
message.warning('请先选择要打卡的任务');
|
||||
return;
|
||||
}
|
||||
|
||||
checkInLoading.value = true;
|
||||
|
||||
try {
|
||||
// 调用异步打卡接口,立即返回 record_id
|
||||
const result = await taskStore.checkInTask(selectedTaskId.value);
|
||||
|
||||
// 获取 record_id
|
||||
const recordId = result.record_id;
|
||||
if (!recordId) {
|
||||
message.error('打卡请求失败:未获取到记录ID');
|
||||
checkInLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果初始状态就是失败,显示错误并刷新记录
|
||||
if (result.status === 'failure') {
|
||||
message.error(result.message || '打卡失败');
|
||||
checkInLoading.value = false;
|
||||
checkInStore.fetchMyRecords({ limit: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示提示消息
|
||||
message.info('打卡任务已启动,正在后台处理...');
|
||||
|
||||
// 使用轮询 composable 检查打卡状态
|
||||
startPolling(
|
||||
async () => {
|
||||
const status = await taskStore.getCheckInRecordStatus(recordId);
|
||||
return {
|
||||
completed: status.status !== 'pending',
|
||||
success: status.status === 'success',
|
||||
data: status,
|
||||
};
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
checkInLoading.value = false;
|
||||
message.success('打卡成功!');
|
||||
checkInStore.fetchMyRecords({ limit: 1 });
|
||||
},
|
||||
onFailure: statusData => {
|
||||
checkInLoading.value = false;
|
||||
// 优先使用 error_message,如果为空则使用 response_text,都为空则使用默认消息
|
||||
const errorMsg =
|
||||
(statusData.error_message && statusData.error_message.trim()) ||
|
||||
(statusData.response_text && statusData.response_text.trim()) ||
|
||||
'打卡失败';
|
||||
message.error(errorMsg);
|
||||
checkInStore.fetchMyRecords({ limit: 1 });
|
||||
},
|
||||
onTimeout: () => {
|
||||
checkInLoading.value = false;
|
||||
message.warning('打卡处理时间较长,请稍后查看打卡记录');
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('启动打卡失败:', error);
|
||||
checkInLoading.value = false;
|
||||
message.error(error.message || '启动打卡任务失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理扫码成功(Token 刷新)
|
||||
const handleQRCodeSuccess = async () => {
|
||||
try {
|
||||
// 获取最新的用户信息和 Token 状态
|
||||
await authStore.fetchCurrentUser();
|
||||
await fetchTokenStatus();
|
||||
message.success({ content: 'Token 刷新成功!', duration: 3 });
|
||||
} catch (error) {
|
||||
console.error('刷新用户信息失败:', error);
|
||||
message.error({ content: '获取最新信息失败,请刷新页面', duration: 3 });
|
||||
}
|
||||
};
|
||||
|
||||
// 处理扫码失败
|
||||
const handleQRCodeError = errorMsg => {
|
||||
message.error({ content: errorMsg || '扫码刷新 Token 失败', duration: 3 });
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
// 刷新用户信息,确保 email 和 has_password 是最新的
|
||||
try {
|
||||
await authStore.fetchCurrentUser();
|
||||
} catch (error) {
|
||||
console.error('刷新用户信息失败:', error);
|
||||
}
|
||||
|
||||
// 获取 Token 状态
|
||||
fetchTokenStatus();
|
||||
checkInStore.fetchMyRecords({ limit: 1 });
|
||||
|
||||
// 加载任务列表
|
||||
try {
|
||||
await taskStore.fetchMyTasks();
|
||||
// 如果只有一个任务,自动选中(优先选择启用的任务)
|
||||
if (taskStore.activeTasks.length === 1) {
|
||||
selectedTaskId.value = taskStore.activeTasks[0].id;
|
||||
} else if (taskStore.tasks.length === 1) {
|
||||
selectedTaskId.value = taskStore.tasks[0].id;
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || '加载任务列表失败');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.token-status {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.token-status .ant-descriptions {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.check-in-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.check-in-container .hint {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 14px;
|
||||
margin: 0 0 4px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.last-check-in {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.last-check-in .label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
margin: 12px 0 8px 0;
|
||||
}
|
||||
|
||||
.ant-alert {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,524 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<a-row justify="center" align="middle" style="height: 100%">
|
||||
<a-col :xs="22" :sm="18" :md="12" :lg="10" :xl="8">
|
||||
<a-card class="login-card">
|
||||
<template #title>
|
||||
<div class="card-header">
|
||||
<h2>接龙自动打卡系统</h2>
|
||||
<p class="subtitle">
|
||||
{{ loginMode === 'qrcode' ? 'QQ 扫码登录/注册' : '用户名密码登录' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 登录模式切换 -->
|
||||
<div class="mode-switch">
|
||||
<a-segmented v-model:value="loginMode" :options="loginModeOptions" block />
|
||||
</div>
|
||||
|
||||
<!-- QR码登录表单 -->
|
||||
<a-form
|
||||
v-if="loginMode === 'qrcode'"
|
||||
ref="qrcodeFormRef"
|
||||
:model="qrcodeForm"
|
||||
:rules="qrcodeRules"
|
||||
layout="vertical"
|
||||
@submit.prevent="handleQRCodeLogin"
|
||||
>
|
||||
<a-form-item name="alias">
|
||||
<a-input
|
||||
v-model:value="qrcodeForm.alias"
|
||||
placeholder="请输入您的用户名"
|
||||
size="large"
|
||||
autocomplete="username"
|
||||
allow-clear
|
||||
@keyup.enter="handleQRCodeLogin"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="loading"
|
||||
@click="handleQRCodeLogin"
|
||||
>
|
||||
{{ loading ? '正在登录...' : '扫码登录/注册' }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- 别名+密码登录表单 -->
|
||||
<a-form
|
||||
v-else
|
||||
ref="passwordFormRef"
|
||||
:model="passwordForm"
|
||||
:rules="passwordRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item name="alias">
|
||||
<a-input
|
||||
v-model:value="passwordForm.alias"
|
||||
placeholder="请输入您的用户名"
|
||||
size="large"
|
||||
autocomplete="username"
|
||||
allow-clear
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="password">
|
||||
<a-input-password
|
||||
v-model:value="passwordForm.password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="handlePasswordLogin"
|
||||
>
|
||||
<template #prefix>
|
||||
<KeyOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="loading"
|
||||
@click="handlePasswordLogin"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<div class="tips-link">
|
||||
<a class="link-text" @click="loginMode = 'qrcode'"> 没有密码?使用扫码登录 </a>
|
||||
</div>
|
||||
</a-form>
|
||||
|
||||
<div class="tips">
|
||||
<a-alert
|
||||
:message="loginMode === 'qrcode' ? '扫码登录提示' : '密码登录提示'"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #description>
|
||||
<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>
|
||||
</template>
|
||||
</a-alert>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 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 { message } from 'ant-design-vue';
|
||||
import { UserOutlined, KeyOutlined } from '@ant-design/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 {
|
||||
await qrcodeFormRef.value.validate();
|
||||
// 显示 QR 码弹窗
|
||||
qrcodeVisible.value = true;
|
||||
} catch {
|
||||
// 表单验证失败,不需要打印错误(由 Ant Design 自动显示错误提示)
|
||||
}
|
||||
};
|
||||
|
||||
// 密码登录
|
||||
const handlePasswordLogin = async () => {
|
||||
if (!passwordFormRef.value) return;
|
||||
|
||||
try {
|
||||
await passwordFormRef.value.validate();
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const response = await authAPI.aliasLogin(
|
||||
passwordForm.value.alias,
|
||||
passwordForm.value.password
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
// 保存 JWT token 和用户信息
|
||||
authStore.setAuth(response.token, response.user);
|
||||
|
||||
// 如果有打卡 Token 警告,显示提示(不影响网站登录)
|
||||
if (response.token_warning && response.warning_message) {
|
||||
message.warning({
|
||||
content: response.warning_message,
|
||||
duration: 2,
|
||||
});
|
||||
} else {
|
||||
message.success(`欢迎回来,${response.user.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 = msg => {
|
||||
if (!msg) {
|
||||
message.error('登录失败,请稍后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
// 用户不存在或密码错误
|
||||
if (msg.includes('用户名或密码错误')) {
|
||||
message.error('用户名或密码错误');
|
||||
return;
|
||||
}
|
||||
|
||||
// 未设置密码
|
||||
if (msg.includes('未设置密码')) {
|
||||
message.warning('该账户未设置密码,请使用扫码登录');
|
||||
return;
|
||||
}
|
||||
|
||||
// 用户不存在
|
||||
if (msg.includes('用户不存在')) {
|
||||
message.error('用户不存在,请检查用户名或使用扫码登录注册');
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他错误
|
||||
message.error(msg || '登录失败,请稍后重试');
|
||||
};
|
||||
|
||||
const handleLoginSuccess = user => {
|
||||
message.success(`欢迎回来,${user.alias}!`);
|
||||
|
||||
// 跳转到重定向页面或仪表盘
|
||||
const redirect = route.query.redirect || '/dashboard';
|
||||
router.push(redirect);
|
||||
};
|
||||
|
||||
const handleLoginError = error => {
|
||||
message.error(error.message || '登录失败');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
/* 暗色模式背景 */
|
||||
.dark .login-container {
|
||||
background: linear-gradient(135deg, #1a237e 0%, #4a148c 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* 暗色模式卡片阴影 */
|
||||
.dark .login-card {
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #303133;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
/* 暗色模式标题 */
|
||||
.dark .card-header h2 {
|
||||
color: #e6e1e5;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 10px 0 0 0;
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
/* 暗色模式副标题 */
|
||||
.dark .subtitle {
|
||||
color: #cac4d0;
|
||||
}
|
||||
|
||||
.mode-switch {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tips-link {
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.link-text {
|
||||
color: #2196f3;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.link-text:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 暗色模式链接 */
|
||||
.dark .link-text {
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
.dark .link-text:hover {
|
||||
color: #90caf9;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.tips :deep(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;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 暗色模式提示注释 */
|
||||
.dark .tip-note {
|
||||
border-top-color: #49454f;
|
||||
color: #cac4d0;
|
||||
}
|
||||
|
||||
/* 确保 Ant Design Row 占满高度 */
|
||||
.login-container :deep(.ant-row) {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.login-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tips :deep(p) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tips :deep(.ant-alert) {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏手机优化 */
|
||||
@media (max-width: 576px) {
|
||||
.login-container {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
border-radius: 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mode-switch {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.tips :deep(p) {
|
||||
font-size: 12px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 横屏优化 */
|
||||
@media (max-height: 600px) and (orientation: landscape) {
|
||||
.login-container {
|
||||
padding: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.tips :deep(p) {
|
||||
margin: 3px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mode-switch {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="not-found-container">
|
||||
<a-result status="404" title="404" sub-title="抱歉,您访问的页面不存在">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="goHome">返回首页</a-button>
|
||||
</template>
|
||||
</a-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,360 @@
|
||||
<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>
|
||||
|
||||
<a-descriptions :column="1" bordered class="mb-6">
|
||||
<a-descriptions-item label="用户名">
|
||||
{{ user?.alias || '加载中...' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">
|
||||
<template v-if="user?.email">
|
||||
{{ user.email }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag color="warning">未设置</a-tag>
|
||||
</template>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="密码">
|
||||
<template v-if="user?.has_password">
|
||||
<a-tag color="success">已设置</a-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-tag color="warning">未设置</a-tag>
|
||||
</template>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="注册时间">
|
||||
{{ formatDate(user?.created_at) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="审批状态">
|
||||
<a-tag color="warning">待审批</a-tag>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-alert message="⚠️ 审批说明" type="info" :closable="false" show-icon class="mb-6">
|
||||
<template #description>
|
||||
<ul class="tips-list">
|
||||
<li>管理员将在 <strong>24 小时内</strong> 审核您的注册申请</li>
|
||||
<li>审核通过后,您将可以使用所有功能</li>
|
||||
<li>如超过 24 小时未审批,账户将被自动删除</li>
|
||||
<li><strong>建议:</strong>审批期间可以设置邮箱和密码,方便后续使用</li>
|
||||
<li>您可以随时刷新此页面查看最新状态</li>
|
||||
</ul>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<div class="actions">
|
||||
<a-button type="primary" size="large" @click="checkStatus">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新状态
|
||||
</a-button>
|
||||
<a-button size="large" @click="showProfileModal = true">
|
||||
<template #icon><SettingOutlined /></template>
|
||||
完善信息
|
||||
</a-button>
|
||||
<a-button size="large" @click="logout">
|
||||
<template #icon><LogoutOutlined /></template>
|
||||
退出登录
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 完善信息弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showProfileModal"
|
||||
title="完善个人信息"
|
||||
:confirm-loading="profileLoading"
|
||||
width="500px"
|
||||
@ok="handleUpdateProfile"
|
||||
@cancel="resetProfileForm"
|
||||
>
|
||||
<a-form :model="profileForm" layout="vertical">
|
||||
<a-form-item label="邮箱地址(可选)" name="email">
|
||||
<a-input v-model:value="profileForm.email" placeholder="用于接收审批通知" type="email" />
|
||||
<div class="form-hint">建议设置邮箱,方便接收审批结果通知</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="新密码(可选)"
|
||||
name="new_password"
|
||||
:help="user?.has_password ? '留空表示不修改密码' : '设置密码后可以使用密码登录'"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="profileForm.new_password"
|
||||
placeholder="至少6位字符"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="profileForm.new_password" label="确认密码" name="confirm_password">
|
||||
<a-input-password
|
||||
v-model:value="profileForm.confirm_password"
|
||||
placeholder="再次输入新密码"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
v-if="user?.has_password && profileForm.new_password"
|
||||
label="当前密码"
|
||||
name="current_password"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="profileForm.current_password"
|
||||
placeholder="修改密码时需要提供当前密码"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { ReloadOutlined, LogoutOutlined, SettingOutlined } from '@ant-design/icons-vue';
|
||||
import { userAPI } from '@/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const user = ref(null);
|
||||
const showProfileModal = ref(false);
|
||||
const profileLoading = ref(false);
|
||||
|
||||
const profileForm = ref({
|
||||
email: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
current_password: '',
|
||||
});
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const response = await userAPI.getUserStatus();
|
||||
user.value = response;
|
||||
|
||||
if (response.is_approved) {
|
||||
message.success('恭喜!您的账户已通过审批');
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
message.info('仍在等待审批中');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取状态失败:', error);
|
||||
message.error('获取状态失败:' + (error.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const loadUserInfo = async () => {
|
||||
try {
|
||||
const response = await userAPI.getCurrentUser();
|
||||
user.value = response;
|
||||
// 初始化表单
|
||||
profileForm.value.email = response.email || '';
|
||||
} catch (error) {
|
||||
console.error('加载用户信息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProfile = async () => {
|
||||
// 验证
|
||||
if (profileForm.value.new_password && profileForm.value.new_password.length < 6) {
|
||||
message.error('密码至少需要 6 位字符');
|
||||
return;
|
||||
}
|
||||
|
||||
if (profileForm.value.new_password !== profileForm.value.confirm_password) {
|
||||
message.error('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
user.value?.has_password &&
|
||||
profileForm.value.new_password &&
|
||||
!profileForm.value.current_password
|
||||
) {
|
||||
message.error('修改密码时需要提供当前密码');
|
||||
return;
|
||||
}
|
||||
|
||||
profileLoading.value = true;
|
||||
|
||||
try {
|
||||
const updateData = {};
|
||||
|
||||
// 只提交有变化的字段
|
||||
if (profileForm.value.email !== (user.value?.email || '')) {
|
||||
updateData.email = profileForm.value.email || null;
|
||||
}
|
||||
|
||||
if (profileForm.value.new_password) {
|
||||
updateData.new_password = profileForm.value.new_password;
|
||||
if (user.value?.has_password) {
|
||||
updateData.current_password = profileForm.value.current_password;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有要更新的字段
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
message.info('没有需要更新的信息');
|
||||
showProfileModal.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await userAPI.updateProfile(updateData);
|
||||
message.success('个人信息更新成功');
|
||||
showProfileModal.value = false;
|
||||
resetProfileForm();
|
||||
|
||||
// 重新加载用户信息
|
||||
await loadUserInfo();
|
||||
|
||||
// 如果设置了密码,更新本地存储的用户信息
|
||||
if (updateData.new_password) {
|
||||
const currentUser = authStore.user;
|
||||
if (currentUser) {
|
||||
currentUser.has_password = true;
|
||||
localStorage.setItem('user', JSON.stringify(currentUser));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新个人信息失败:', error);
|
||||
message.error(error.message || '更新失败,请重试');
|
||||
} finally {
|
||||
profileLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetProfileForm = () => {
|
||||
profileForm.value = {
|
||||
email: user.value?.email || '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
current_password: '',
|
||||
};
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
authStore.logout();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
const formatDate = dateStr => {
|
||||
if (!dateStr) return '未知';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadUserInfo();
|
||||
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;
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.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;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="records-container">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<UnorderedListOutlined />
|
||||
<span>我的打卡记录</span>
|
||||
</div>
|
||||
<a-button type="primary" @click="handleRefresh">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-container">
|
||||
<a-row :gutter="20">
|
||||
<a-col :xs="24" :sm="8" :md="8">
|
||||
<a-statistic title="总打卡次数" :value="total" />
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="8" :md="8">
|
||||
<a-statistic
|
||||
title="成功次数"
|
||||
:value="successCount"
|
||||
:value-style="{ color: '#67c23a' }"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="8" :md="8">
|
||||
<a-statistic
|
||||
title="成功率"
|
||||
:value="parseFloat(checkInStore.successRate)"
|
||||
suffix="%"
|
||||
:precision="2"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<!-- 桌面端表格 -->
|
||||
<a-table
|
||||
v-if="!isMobile"
|
||||
:data-source="checkInStore.myRecords"
|
||||
:columns="columns"
|
||||
:loading="checkInStore.loading"
|
||||
:pagination="false"
|
||||
:row-key="record => record.id"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
bordered
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'check_in_time'">
|
||||
{{ formatDateTime(record.check_in_time) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag>
|
||||
<a-tag v-else-if="record.status === 'out_of_time'" color="default"
|
||||
>🕐 时间范围外</a-tag
|
||||
>
|
||||
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
|
||||
<a-tag v-else color="error">❌ 打卡失败</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'trigger_type'">
|
||||
<a-tag v-if="record.trigger_type === 'manual'" color="blue">手动</a-tag>
|
||||
<a-tag v-else-if="record.trigger_type === 'scheduled'" color="default">定时</a-tag>
|
||||
<a-tag v-else-if="record.trigger_type === 'admin'" color="orange">管理员</a-tag>
|
||||
<a-tag v-else>{{ record.trigger_type }}</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 移动端卡片视图 -->
|
||||
<a-space v-else direction="vertical" :size="16" style="width: 100%">
|
||||
<a-card
|
||||
v-for="record in checkInStore.myRecords"
|
||||
:key="record.id"
|
||||
size="small"
|
||||
:loading="checkInStore.loading"
|
||||
>
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="ID">{{ record.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="打卡时间">
|
||||
{{ formatDateTime(record.check_in_time) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag>
|
||||
<a-tag v-else-if="record.status === 'out_of_time'" color="default"
|
||||
>🕐 时间范围外</a-tag
|
||||
>
|
||||
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
|
||||
<a-tag v-else color="error">❌ 打卡失败</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="触发方式">
|
||||
<a-tag v-if="record.trigger_type === 'manual'" color="blue">手动</a-tag>
|
||||
<a-tag v-else-if="record.trigger_type === 'scheduled'" color="default">定时</a-tag>
|
||||
<a-tag v-else-if="record.trigger_type === 'admin'" color="orange">管理员</a-tag>
|
||||
<a-tag v-else>{{ record.trigger_type }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="消息">
|
||||
{{ record.response_text || '-' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</a-space>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container">
|
||||
<a-pagination
|
||||
v-model:current="checkInStore.currentPage"
|
||||
v-model:page-size="checkInStore.pageSize"
|
||||
:total="total"
|
||||
:page-size-options="['10', '20', '50', '100']"
|
||||
show-size-changer
|
||||
show-quick-jumper
|
||||
:show-total="total => `共 ${total} 条记录`"
|
||||
@change="handlePageChange"
|
||||
@show-size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { UnorderedListOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import Layout from '@/components/Layout.vue';
|
||||
import { useBreakpoint } from '@/composables/useBreakpoint';
|
||||
import { useCheckInStore } from '@/stores/checkIn';
|
||||
import { formatDateTime } from '@/utils/helpers';
|
||||
|
||||
const checkInStore = useCheckInStore();
|
||||
const { isMobile } = useBreakpoint();
|
||||
|
||||
const total = computed(() => checkInStore.total);
|
||||
|
||||
const successCount = computed(() => {
|
||||
return checkInStore.myRecords.filter(r => r.status === 'success').length;
|
||||
});
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '打卡时间',
|
||||
dataIndex: 'check_in_time',
|
||||
key: 'check_in_time',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '触发方式',
|
||||
dataIndex: 'trigger_type',
|
||||
key: 'trigger_type',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '消息',
|
||||
dataIndex: 'response_text',
|
||||
key: 'response_text',
|
||||
ellipsis: true,
|
||||
},
|
||||
];
|
||||
|
||||
// 刷新数据
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
await checkInStore.fetchMyRecords();
|
||||
message.success('刷新成功');
|
||||
} catch (error) {
|
||||
message.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,281 @@
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="settings-view">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h1 class="text-3xl font-bold text-on-surface mb-6">个人设置</h1>
|
||||
|
||||
<!-- 基本信息卡片 -->
|
||||
<a-card class="md3-card mb-6">
|
||||
<h2 class="text-xl font-bold text-on-surface mb-4 flex items-center">
|
||||
<UserOutlined class="mr-2" />
|
||||
基本信息
|
||||
</h2>
|
||||
|
||||
<a-descriptions :column="1" bordered>
|
||||
<a-descriptions-item label="用户ID">{{ user?.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="当前用户名">{{ user?.alias }}</a-descriptions-item>
|
||||
<a-descriptions-item label="角色">
|
||||
<a-tag :color="user?.role === 'admin' ? 'error' : 'success'">
|
||||
{{ user?.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="密码状态">
|
||||
<a-tag :color="hasPassword ? 'success' : 'warning'">
|
||||
{{ hasPassword ? '已设置' : '未设置' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">
|
||||
{{ formatDate(user?.created_at) }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<!-- 修改邮箱 -->
|
||||
<a-card class="md3-card mb-6">
|
||||
<h2 class="text-xl font-bold text-on-surface mb-4 flex items-center">
|
||||
<EditOutlined class="mr-2" />
|
||||
修改个人信息
|
||||
</h2>
|
||||
|
||||
<a-form ref="profileFormRef" :model="profileForm" :rules="profileRules" layout="vertical">
|
||||
<a-form-item label="邮箱" name="email">
|
||||
<a-input
|
||||
v-model:value="profileForm.email"
|
||||
placeholder="请输入邮箱地址(可选)"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-alert
|
||||
message="用户名无法修改"
|
||||
description="用户名只能由管理员修改,如需修改请联系管理员"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 24px"
|
||||
/>
|
||||
|
||||
<a-form-item style="margin-top: 8px">
|
||||
<a-space>
|
||||
<a-button type="primary" :loading="profileLoading" @click="handleUpdateProfile">
|
||||
保存
|
||||
</a-button>
|
||||
<a-button @click="resetProfileForm">重置</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<!-- 设置/修改密码 -->
|
||||
<a-card class="md3-card">
|
||||
<h2 class="text-xl font-bold text-on-surface mb-4 flex items-center">
|
||||
<KeyOutlined class="mr-2" />
|
||||
{{ hasPassword ? '修改密码' : '设置密码' }}
|
||||
</h2>
|
||||
|
||||
<a-alert
|
||||
v-if="!hasPassword"
|
||||
message="您还未设置密码"
|
||||
description="设置密码后,您可以使用用户名+密码的方式快速登录"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<a-form :model="passwordForm" layout="vertical">
|
||||
<a-form-item v-if="hasPassword" label="当前密码">
|
||||
<a-input-password
|
||||
v-model:value="passwordForm.currentPassword"
|
||||
placeholder="请输入当前密码"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="新密码">
|
||||
<a-input-password
|
||||
v-model:value="passwordForm.newPassword"
|
||||
placeholder="请输入新密码(至少6个字符)"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="确认新密码">
|
||||
<a-input-password
|
||||
v-model:value="passwordForm.confirmPassword"
|
||||
placeholder="请再次输入新密码"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item style="margin-top: 8px">
|
||||
<a-space>
|
||||
<a-button type="primary" :loading="passwordLoading" @click="handleUpdatePassword">
|
||||
{{ hasPassword ? '修改密码' : '设置密码' }}
|
||||
</a-button>
|
||||
<a-button @click="resetPasswordForm">重置</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { UserOutlined, EditOutlined, KeyOutlined } from '@ant-design/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) {
|
||||
message.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,
|
||||
});
|
||||
|
||||
message.success('个人信息修改成功');
|
||||
await loadUserInfo();
|
||||
} catch (error) {
|
||||
if (error.errorFields) return; // 验证错误
|
||||
const errorMsg = error.response?.data?.detail || error.message || '修改失败';
|
||||
message.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) {
|
||||
message.error('请输入当前密码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!passwordForm.value.newPassword) {
|
||||
message.error('请输入新密码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.value.newPassword.length < 6) {
|
||||
message.error('密码至少需要6个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!passwordForm.value.confirmPassword) {
|
||||
message.error('请再次输入新密码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
|
||||
message.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);
|
||||
|
||||
message.success(hasPassword.value ? '密码修改成功' : '密码设置成功');
|
||||
hasPassword.value = true;
|
||||
resetPasswordForm();
|
||||
} catch (error) {
|
||||
const errorMsg = error.response?.data?.detail || error.message || '操作失败';
|
||||
message.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>
|
||||
.settings-view {
|
||||
min-height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="task-records-view">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<a-button type="link" class="mb-4 flex items-center" @click="router.back()">
|
||||
<template #icon><LeftOutlined /></template>
|
||||
返回任务列表
|
||||
</a-button>
|
||||
|
||||
<a-card v-if="currentTask" class="md3-card">
|
||||
<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-on-surface-variant">
|
||||
<span class="flex items-center">
|
||||
<NumberOutlined class="mr-1" />
|
||||
接龙 ID: {{ getThreadId(currentTask) }}
|
||||
</span>
|
||||
<a-tag :color="currentTask.is_active ? 'success' : 'default'">
|
||||
{{ currentTask.is_active ? '启用中' : '已禁用' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<a-button type="primary" :loading="checkInLoading" @click="handleManualCheckIn">
|
||||
{{ checkInLoading ? '打卡中...' : '立即打卡' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<a-row :gutter="[16, 16]" class="mb-6">
|
||||
<a-col :xs="12" :sm="8" :md="4">
|
||||
<a-card class="md3-card animate-slide-up">
|
||||
<p class="text-sm text-on-surface-variant mb-1">总打卡次数</p>
|
||||
<p class="text-2xl font-bold text-on-surface">{{ recordStats.total }}</p>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="4">
|
||||
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.05s">
|
||||
<p class="text-sm text-on-surface-variant mb-1">成功次数</p>
|
||||
<p class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{{ recordStats.success }}
|
||||
</p>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="4">
|
||||
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.1s">
|
||||
<p class="text-sm text-on-surface-variant mb-1">时间范围外</p>
|
||||
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ recordStats.outOfTime }}
|
||||
</p>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="4">
|
||||
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.15s">
|
||||
<p class="text-sm text-on-surface-variant mb-1">失败次数</p>
|
||||
<p class="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||
{{ recordStats.failure }}
|
||||
</p>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="4">
|
||||
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.2s">
|
||||
<p class="text-sm text-on-surface-variant mb-1">异常次数</p>
|
||||
<p class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||
{{ recordStats.unknown }}
|
||||
</p>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="4">
|
||||
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.25s">
|
||||
<p class="text-sm text-on-surface-variant mb-1">成功率</p>
|
||||
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{{ recordStats.successRate }}%
|
||||
</p>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- Filters -->
|
||||
<a-card class="md3-card mb-6">
|
||||
<a-space wrap :size="[16, 16]">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-on-surface">状态筛选:</span>
|
||||
<a-radio-group
|
||||
v-model:value="filterStatus"
|
||||
button-style="solid"
|
||||
size="small"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-radio-button value="">全部</a-radio-button>
|
||||
<a-radio-button value="success">成功</a-radio-button>
|
||||
<a-radio-button value="out_of_time">时间范围外</a-radio-button>
|
||||
<a-radio-button value="failure">失败</a-radio-button>
|
||||
<a-radio-button value="unknown">异常</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-on-surface">触发方式:</span>
|
||||
<a-radio-group
|
||||
v-model:value="filterTrigger"
|
||||
button-style="solid"
|
||||
size="small"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-radio-button value="">全部</a-radio-button>
|
||||
<a-radio-button value="scheduler">自动</a-radio-button>
|
||||
<a-radio-button value="manual">手动</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<a-button size="small" @click="fetchRecords">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
|
||||
<!-- Records List -->
|
||||
<div v-if="loading" class="space-y-4">
|
||||
<a-card v-for="i in 5" :key="i">
|
||||
<a-skeleton :active="true" :paragraph="{ rows: 3 }" />
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<a-card
|
||||
v-else-if="records.length === 0"
|
||||
class="md3-card text-center"
|
||||
style="padding: 48px 20px"
|
||||
>
|
||||
<FileTextOutlined class="text-8xl text-on-surface-variant opacity-30 mb-4" />
|
||||
<h3 class="text-xl font-semibold text-on-surface mb-2">暂无打卡记录</h3>
|
||||
<p class="text-on-surface-variant">当前筛选条件下没有找到任何打卡记录</p>
|
||||
</a-card>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<a-card
|
||||
v-for="record in records"
|
||||
:key="record.id"
|
||||
class="md3-card 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 flex-wrap">
|
||||
<h3 class="text-lg font-semibold text-on-surface">打卡记录 #{{ record.id }}</h3>
|
||||
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag>
|
||||
<a-tag v-else-if="record.status === 'out_of_time'" color="default"
|
||||
>🕐 时间范围外</a-tag
|
||||
>
|
||||
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
|
||||
<a-tag v-else color="error">❌ 打卡失败</a-tag>
|
||||
<a-tag :color="record.trigger_type === 'scheduled' ? 'blue' : 'orange'">
|
||||
{{ record.trigger_type === 'scheduled' ? '自动触发' : '手动触发' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="flex items-center text-sm text-on-surface-variant">
|
||||
<ClockCircleOutlined class="mr-1" />
|
||||
{{ formatDateTime(record.check_in_time) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Record Details -->
|
||||
<div
|
||||
class="bg-surface-container-high dark:bg-surface-container rounded-lg p-4 space-y-2"
|
||||
>
|
||||
<div v-if="record.response_text" class="flex items-start">
|
||||
<span class="text-sm font-medium text-on-surface-variant w-20">响应:</span>
|
||||
<span class="text-sm text-on-surface flex-1">{{ record.response_text }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="record.error_message" class="flex items-start">
|
||||
<span class="text-sm font-medium text-error w-20">错误:</span>
|
||||
<span class="text-sm text-error flex-1">{{ record.error_message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="!loading && records.length > 0" class="mt-6 flex justify-center">
|
||||
<a-pagination
|
||||
v-model:current="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-size-options="['10', '20', '50', '100']"
|
||||
show-size-changer
|
||||
show-quick-jumper
|
||||
:show-total="total => `共 ${total} 条记录`"
|
||||
@change="handlePageChange"
|
||||
@show-size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
LeftOutlined,
|
||||
NumberOutlined,
|
||||
FileTextOutlined,
|
||||
ClockCircleOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import Layout from '@/components/Layout.vue';
|
||||
import { useTaskStore } from '@/stores/task';
|
||||
import { formatDateTime } from '@/utils/helpers';
|
||||
import { usePollStatus } from '@/composables/usePollStatus';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const taskStore = useTaskStore();
|
||||
|
||||
// 使用轮询 composable
|
||||
const { startPolling } = usePollStatus({
|
||||
interval: 2000,
|
||||
maxRetries: 15,
|
||||
backoff: false,
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
// 从 payload_config 中提取 ThreadId
|
||||
const getThreadId = task => {
|
||||
if (!task || !task.payload_config) return '未知';
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(task.payload_config);
|
||||
return payload.ThreadId || '未知';
|
||||
} catch (e) {
|
||||
console.error('解析 payload_config 失败:', e);
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取任务详情
|
||||
const fetchTaskDetail = async () => {
|
||||
try {
|
||||
currentTask.value = await taskStore.fetchTask(taskId.value);
|
||||
} catch (error) {
|
||||
message.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);
|
||||
|
||||
// 后端现在返回 { records, total, skip, limit }
|
||||
if (response.records) {
|
||||
records.value = response.records;
|
||||
total.value = response.total || 0;
|
||||
} else if (Array.isArray(response)) {
|
||||
// 兼容旧格式
|
||||
records.value = response;
|
||||
total.value = response.length;
|
||||
} else {
|
||||
records.value = [];
|
||||
total.value = 0;
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || '获取打卡记录失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 手动打卡
|
||||
const handleManualCheckIn = async () => {
|
||||
checkInLoading.value = true;
|
||||
|
||||
try {
|
||||
// 调用异步打卡接口,立即返回 record_id
|
||||
const result = await taskStore.checkInTask(taskId.value);
|
||||
|
||||
// 获取 record_id
|
||||
const recordId = result.record_id;
|
||||
if (!recordId) {
|
||||
message.error('打卡请求失败:未获取到记录ID');
|
||||
checkInLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果初始状态就是失败,显示错误并刷新记录列表
|
||||
if (result.status === 'failure') {
|
||||
const errorMsg =
|
||||
(result.error_message && result.error_message.trim()) ||
|
||||
(result.response_text && result.response_text.trim()) ||
|
||||
'打卡失败';
|
||||
message.error(errorMsg);
|
||||
checkInLoading.value = false;
|
||||
await fetchRecords();
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示提示消息
|
||||
message.info('打卡任务已启动,正在后台处理...');
|
||||
|
||||
// 使用轮询 composable 检查打卡状态
|
||||
startPolling(
|
||||
async () => {
|
||||
const status = await taskStore.getCheckInRecordStatus(recordId);
|
||||
return {
|
||||
completed: status.status !== 'pending',
|
||||
success: status.status === 'success',
|
||||
data: status,
|
||||
};
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
checkInLoading.value = false;
|
||||
message.success('打卡成功!');
|
||||
await fetchRecords();
|
||||
},
|
||||
onFailure: async statusData => {
|
||||
checkInLoading.value = false;
|
||||
// 优先使用 error_message,如果为空则使用 response_text,都为空则使用默认消息
|
||||
const errorMsg =
|
||||
(statusData.error_message && statusData.error_message.trim()) ||
|
||||
(statusData.response_text && statusData.response_text.trim()) ||
|
||||
'打卡失败';
|
||||
message.error(errorMsg);
|
||||
await fetchRecords();
|
||||
},
|
||||
onTimeout: () => {
|
||||
checkInLoading.value = false;
|
||||
message.warning('打卡处理时间较长,请稍后查看打卡记录');
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('启动打卡失败:', error);
|
||||
checkInLoading.value = false;
|
||||
message.error(error.message || '启动打卡任务失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 筛选变化
|
||||
const handleFilterChange = () => {
|
||||
currentPage.value = 1;
|
||||
fetchRecords();
|
||||
};
|
||||
|
||||
// 分页变化
|
||||
const handlePageChange = () => {
|
||||
fetchRecords();
|
||||
};
|
||||
|
||||
const handleSizeChange = () => {
|
||||
currentPage.value = 1;
|
||||
fetchRecords();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchTaskDetail();
|
||||
await fetchRecords();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Additional component-specific styles if needed */
|
||||
</style>
|
||||
@@ -0,0 +1,838 @@
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="tasks-view">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- Header Section -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold text-gradient mb-2">任务管理</h1>
|
||||
<p class="text-on-surface-variant">管理您的自动打卡任务</p>
|
||||
</div>
|
||||
<a-button type="primary" size="large" class="shadow-md3-3" @click="openCreateDialog">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
创建任务
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<a-row :gutter="[16, 16]" class="mb-6">
|
||||
<a-col :xs="24" :sm="8" :md="8">
|
||||
<a-card class="md3-card animate-slide-up">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-on-surface-variant mb-1">总任务数</p>
|
||||
<p class="text-3xl font-bold text-primary">{{ taskStore.taskStats.total }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-md3 flex items-center justify-center"
|
||||
>
|
||||
<FileTextOutlined class="text-2xl text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="8" :md="8">
|
||||
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.1s">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-on-surface-variant mb-1">启用中</p>
|
||||
<p class="text-3xl font-bold text-green-600 dark:text-green-400">
|
||||
{{ taskStore.taskStats.active }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-md3 flex items-center justify-center"
|
||||
>
|
||||
<CheckCircleOutlined class="text-2xl text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :sm="8" :md="8">
|
||||
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.2s">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-on-surface-variant mb-1">已禁用</p>
|
||||
<p class="text-3xl font-bold text-on-surface-variant">
|
||||
{{ taskStore.taskStats.inactive }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-12 h-12 bg-surface-container-high rounded-md3 flex items-center justify-center"
|
||||
>
|
||||
<StopOutlined class="text-2xl text-on-surface-variant" />
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- Tasks List -->
|
||||
<div v-if="loading">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col v-for="i in 6" :key="i" :xs="24" :sm="12" :lg="8">
|
||||
<a-card>
|
||||
<a-skeleton :active="true" :paragraph="{ rows: 4 }" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<a-card
|
||||
v-else-if="taskStore.tasks.length === 0"
|
||||
class="md3-card text-center"
|
||||
style="padding: 48px 20px"
|
||||
>
|
||||
<FileTextOutlined class="text-8xl text-on-surface-variant opacity-30 mb-4" />
|
||||
<h3 class="text-xl font-semibold text-on-surface mb-2">暂无任务</h3>
|
||||
<p class="text-on-surface-variant mb-6">
|
||||
点击右上角的"创建任务"按钮开始添加您的第一个打卡任务
|
||||
</p>
|
||||
<a-button type="primary" @click="openCreateDialog"> 创建第一个任务 </a-button>
|
||||
</a-card>
|
||||
|
||||
<a-row v-else :gutter="[16, 16]">
|
||||
<a-col v-for="task in taskStore.tasks" :key="task.id" :xs="24" :sm="12" :lg="8">
|
||||
<a-card
|
||||
class="md3-card 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-on-surface mb-1">
|
||||
{{ task.name || '未命名任务' }}
|
||||
</h3>
|
||||
<a-divider style="margin: 8px 0" />
|
||||
<p class="text-sm text-on-surface-variant">任务 ID: {{ task.id }}</p>
|
||||
</div>
|
||||
<a-tag :color="task.is_active ? 'success' : 'default'">
|
||||
{{ task.is_active ? '启用' : '禁用' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<!-- Task Details -->
|
||||
<div class="space-y-2 mb-4">
|
||||
<div class="flex items-center text-sm text-on-surface-variant">
|
||||
<TagOutlined class="mr-2" />
|
||||
接龙ID: {{ getThreadId(task) }}
|
||||
</div>
|
||||
<div class="flex items-center text-sm text-on-surface-variant">
|
||||
<ClockCircleOutlined class="mr-2" />
|
||||
最后打卡:
|
||||
{{ task.last_check_in_time ? formatDateTime(task.last_check_in_time) : '未打卡' }}
|
||||
</div>
|
||||
<div class="flex items-center text-sm">
|
||||
<CheckCircleOutlined class="mr-2 text-on-surface-variant" />
|
||||
<span
|
||||
v-if="task.last_check_in_status"
|
||||
:class="{
|
||||
'text-green-600 dark:text-green-400 font-medium':
|
||||
task.last_check_in_status === 'success',
|
||||
'text-blue-600 dark:text-blue-400 font-medium':
|
||||
task.last_check_in_status === 'out_of_time',
|
||||
'text-red-600 dark:text-red-400 font-medium':
|
||||
task.last_check_in_status === 'failure',
|
||||
'text-yellow-600 dark:text-yellow-400 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-on-surface-variant">暂无打卡记录</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Actions -->
|
||||
<div class="flex gap-2 pt-4 border-t border-outline-variant">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="checkInLoading[task.id]"
|
||||
class="flex-1"
|
||||
@click.stop="handleCheckIn(task.id)"
|
||||
>
|
||||
{{ checkInLoading[task.id] ? '打卡中...' : '立即打卡' }}
|
||||
</a-button>
|
||||
<a-button size="small" class="flex-1" @click.stop="toggleTaskStatus(task)">
|
||||
{{ task.is_active ? '禁用' : '启用' }}
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
ghost
|
||||
class="icon-button"
|
||||
@click.stop="editTask(task)"
|
||||
>
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
<a-button danger size="small" class="icon-button" @click.stop="deleteTask(task)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Task Dialog -->
|
||||
<a-modal
|
||||
v-model:open="showCreateDialog"
|
||||
:title="editingTask ? '编辑任务' : '从模板创建任务'"
|
||||
:width="isMobile ? '100%' : 700"
|
||||
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<!-- 只显示从模板创建 -->
|
||||
<div v-if="!editingTask">
|
||||
<div v-if="loadingTemplates" class="text-center py-8">
|
||||
<a-spin size="large" />
|
||||
<p class="text-on-surface-variant mt-2">加载模板中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTemplates.length === 0" class="text-center py-8">
|
||||
<p class="text-on-surface-variant">暂无可用模板</p>
|
||||
<p class="text-sm text-on-surface-variant opacity-70 mt-2">请联系管理员创建模板</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Template Selection -->
|
||||
<a-form-item v-if="!selectedTemplate" label="选择模板">
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div
|
||||
v-for="template in activeTemplates"
|
||||
:key="template.id"
|
||||
class="border border-outline-variant rounded-lg p-4 cursor-pointer hover:border-primary hover:bg-primary-container/10 transition-all"
|
||||
@click="selectTemplate(template)"
|
||||
>
|
||||
<h4 class="font-semibold text-on-surface mb-1">{{ template.name }}</h4>
|
||||
<p class="text-sm text-on-surface-variant">
|
||||
{{ template.description || '无描述' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Template Form -->
|
||||
<a-form
|
||||
v-if="selectedTemplate"
|
||||
ref="templateFormRef"
|
||||
:model="templateTaskForm"
|
||||
layout="vertical"
|
||||
>
|
||||
<div class="mb-4 p-3 bg-blue-50 rounded-lg flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<FileTextOutlined class="text-blue-600 mr-2" />
|
||||
<span class="text-sm font-medium text-blue-900"
|
||||
>使用模板:{{ selectedTemplate.name }}</span
|
||||
>
|
||||
</div>
|
||||
<a-button size="small" type="link" @click="selectedTemplate = null"
|
||||
>更换模板</a-button
|
||||
>
|
||||
</div>
|
||||
|
||||
<a-form-item label="任务名称" name="task_name">
|
||||
<a-input
|
||||
v-model:value="templateTaskForm.task_name"
|
||||
placeholder="可选,留空则自动生成"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="接龙 ID" name="thread_id" required>
|
||||
<a-input
|
||||
v-model:value="templateTaskForm.thread_id"
|
||||
placeholder="请输入接龙项目 ID(ThreadID) | 如果你不知道这是什么,请询问管理员"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="打卡时间表">
|
||||
<CrontabEditor v-model="templateTaskForm.cron_expression" />
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left">填写字段信息</a-divider>
|
||||
|
||||
<!-- Dynamic Fields -->
|
||||
<div v-for="(fieldConfig, key) in visibleFields" :key="key">
|
||||
<a-form-item :label="fieldConfig.display_name" :required="fieldConfig.required">
|
||||
<!-- Text Input -->
|
||||
<a-input
|
||||
v-if="fieldConfig.field_type === 'text'"
|
||||
v-model:value="templateTaskForm.field_values[key]"
|
||||
:placeholder="fieldConfig.placeholder || `请输入${fieldConfig.display_name}`"
|
||||
/>
|
||||
|
||||
<!-- Textarea -->
|
||||
<a-textarea
|
||||
v-else-if="fieldConfig.field_type === 'textarea'"
|
||||
v-model:value="templateTaskForm.field_values[key]"
|
||||
:rows="3"
|
||||
:placeholder="fieldConfig.placeholder || `请输入${fieldConfig.display_name}`"
|
||||
/>
|
||||
|
||||
<!-- Number Input -->
|
||||
<a-input-number
|
||||
v-else-if="fieldConfig.field_type === 'number'"
|
||||
v-model:value="templateTaskForm.field_values[key]"
|
||||
:placeholder="fieldConfig.placeholder || `请输入${fieldConfig.display_name}`"
|
||||
style="width: 100%"
|
||||
/>
|
||||
|
||||
<!-- Select -->
|
||||
<a-select
|
||||
v-else-if="fieldConfig.field_type === 'select'"
|
||||
v-model:value="templateTaskForm.field_values[key]"
|
||||
:placeholder="fieldConfig.placeholder || `请选择${fieldConfig.display_name}`"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="option in fieldConfig.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<span v-if="fieldConfig.default_value" class="text-xs text-on-surface-variant mt-1">
|
||||
默认值: {{ fieldConfig.default_value }}
|
||||
</span>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Mode Form - 简化版,只显示任务名称和启用状态 -->
|
||||
<a-form
|
||||
v-if="editingTask"
|
||||
ref="taskFormRef"
|
||||
:model="taskForm"
|
||||
:rules="taskRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="任务名称" name="name">
|
||||
<a-input v-model:value="taskForm.name" placeholder="请输入任务名称(例如:公司打卡)" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="启用状态">
|
||||
<a-switch v-model:checked="taskForm.is_active" />
|
||||
<span class="ml-2 text-sm text-on-surface-variant">
|
||||
{{ taskForm.is_active ? '启用自动打卡' : '禁用自动打卡(仍可手动打卡)' }}
|
||||
</span>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 新增:Crontab 编辑器 -->
|
||||
<a-form-item label="打卡时间表">
|
||||
<CrontabEditor v-model="taskForm.cron_expression" />
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left">任务 Payload 配置(只读)</a-divider>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-on-surface-variant">完整的打卡请求配置</span>
|
||||
<a-button size="small" type="primary" ghost @click="copyPayload">
|
||||
<template #icon><CopyOutlined /></template>
|
||||
复制
|
||||
</a-button>
|
||||
</div>
|
||||
<a-textarea
|
||||
v-model:value="formattedPayload"
|
||||
:rows="12"
|
||||
readonly
|
||||
class="font-mono text-xs"
|
||||
style="resize: vertical; min-height: 200px; max-height: 400px"
|
||||
/>
|
||||
<p class="text-xs text-on-surface-variant mt-1">
|
||||
💡 此配置由模板自动生成,如需修改请删除任务后从模板重新创建
|
||||
</p>
|
||||
</div>
|
||||
</a-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<a-button @click="showCreateDialog = false">取消</a-button>
|
||||
<a-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ submitting ? '提交中...' : editingTask ? '保存修改' : '创建任务' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed, watch } from 'vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
PlusOutlined,
|
||||
FileTextOutlined,
|
||||
CheckCircleOutlined,
|
||||
StopOutlined,
|
||||
TagOutlined,
|
||||
ClockCircleOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
CopyOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import Layout from '@/components/Layout.vue';
|
||||
import CrontabEditor from '@/components/CrontabEditor.vue';
|
||||
import { useBreakpoint } from '@/composables/useBreakpoint';
|
||||
import { useTaskStore } from '@/stores/task';
|
||||
import { useTemplateStore } from '@/stores/template';
|
||||
import { copyToClipboard, formatDateTime } from '@/utils/helpers';
|
||||
import { usePollStatus } from '@/composables/usePollStatus';
|
||||
|
||||
const router = useRouter();
|
||||
const taskStore = useTaskStore();
|
||||
const templateStore = useTemplateStore();
|
||||
const { isMobile } = useBreakpoint();
|
||||
|
||||
// 使用轮询 composable
|
||||
const { startPolling } = usePollStatus({
|
||||
interval: 2000,
|
||||
maxRetries: 15,
|
||||
backoff: false,
|
||||
});
|
||||
|
||||
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 loadingTemplates = ref(false);
|
||||
const activeTemplates = ref([]);
|
||||
const selectedTemplate = ref(null);
|
||||
const templatePreview = ref(null); // 存储从 preview 接口获取的合并后配置
|
||||
|
||||
// Edit task form (仅用于编辑任务)
|
||||
const taskForm = reactive({
|
||||
name: '',
|
||||
thread_id: '',
|
||||
is_active: true,
|
||||
payload_config: '',
|
||||
cron_expression: '0 20 * * *',
|
||||
});
|
||||
|
||||
// Template create form
|
||||
const templateTaskForm = reactive({
|
||||
task_name: '',
|
||||
thread_id: '',
|
||||
field_values: {},
|
||||
cron_expression: '0 20 * * *',
|
||||
});
|
||||
|
||||
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 {
|
||||
return taskForm.payload_config;
|
||||
}
|
||||
});
|
||||
|
||||
// Copy payload to clipboard
|
||||
const copyPayload = async () => {
|
||||
const success = await copyToClipboard(formattedPayload.value);
|
||||
if (success) {
|
||||
message.success('Payload 已复制到剪贴板');
|
||||
} else {
|
||||
message.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 {
|
||||
message.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) {
|
||||
message.error(error.message || '加载模板失败');
|
||||
} finally {
|
||||
loadingTemplates.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Select template
|
||||
const selectTemplate = template => {
|
||||
selectedTemplate.value = template;
|
||||
};
|
||||
|
||||
// 从 payload_config 中提取 ThreadId
|
||||
const getThreadId = task => {
|
||||
if (!task.payload_config) return '未知';
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(task.payload_config);
|
||||
return payload.ThreadId || '未知';
|
||||
} catch (e) {
|
||||
console.error('解析 payload_config 失败:', e);
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
// 加载任务列表
|
||||
const fetchTasks = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
await taskStore.fetchMyTasks();
|
||||
} catch (error) {
|
||||
message.error(error.message || '加载任务列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 查看任务详情
|
||||
const viewTask = task => {
|
||||
router.push(`/tasks/${task.id}/records`);
|
||||
};
|
||||
|
||||
// 编辑任务
|
||||
const editTask = task => {
|
||||
editingTask.value = task;
|
||||
|
||||
// 从 payload_config 中提取 thread_id
|
||||
let threadId = '';
|
||||
try {
|
||||
const payload = JSON.parse(task.payload_config || '{}');
|
||||
threadId = payload.ThreadId || '';
|
||||
} catch (e) {
|
||||
console.error('解析 payload_config 失败:', e);
|
||||
}
|
||||
|
||||
Object.assign(taskForm, {
|
||||
name: task.name,
|
||||
thread_id: threadId,
|
||||
is_active: task.is_active,
|
||||
payload_config: task.payload_config || '{}',
|
||||
cron_expression: task.cron_expression || '0 20 * * *',
|
||||
});
|
||||
showCreateDialog.value = true;
|
||||
};
|
||||
|
||||
// 删除任务
|
||||
const deleteTask = task => {
|
||||
Modal.confirm({
|
||||
title: '删除确认',
|
||||
content: `确定要删除任务"${task.name || task.id}"吗?此操作不可恢复。`,
|
||||
okText: '确定删除',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await taskStore.deleteTask(task.id);
|
||||
message.success('任务删除成功');
|
||||
await fetchTasks();
|
||||
} catch (error) {
|
||||
message.error(error.message || '删除任务失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 切换任务状态
|
||||
const toggleTaskStatus = async task => {
|
||||
try {
|
||||
await taskStore.toggleTask(task.id);
|
||||
message.success(task.is_active ? '任务已禁用' : '任务已启用');
|
||||
} catch (error) {
|
||||
message.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) {
|
||||
message.error('打卡请求失败:未获取到记录ID');
|
||||
checkInLoading.value[taskId] = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果初始状态就是失败,显示错误并刷新任务列表
|
||||
if (result.status === 'failure') {
|
||||
message.error(result.message || '打卡失败');
|
||||
checkInLoading.value[taskId] = false;
|
||||
await fetchTasks();
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示提示消息
|
||||
message.info('打卡任务已启动,正在后台处理...');
|
||||
|
||||
// 使用轮询 composable 检查打卡状态
|
||||
startPolling(
|
||||
async () => {
|
||||
const status = await taskStore.getCheckInRecordStatus(recordId);
|
||||
return {
|
||||
completed: status.status !== 'pending',
|
||||
success: status.status === 'success',
|
||||
data: status,
|
||||
};
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
checkInLoading.value[taskId] = false;
|
||||
message.success('打卡成功!');
|
||||
await fetchTasks();
|
||||
},
|
||||
onFailure: async statusData => {
|
||||
checkInLoading.value[taskId] = false;
|
||||
// 优先使用 error_message,如果为空则使用 response_text,都为空则使用默认消息
|
||||
const errorMsg =
|
||||
(statusData.error_message && statusData.error_message.trim()) ||
|
||||
(statusData.response_text && statusData.response_text.trim()) ||
|
||||
'打卡失败';
|
||||
message.error(errorMsg);
|
||||
await fetchTasks();
|
||||
},
|
||||
onTimeout: () => {
|
||||
checkInLoading.value[taskId] = false;
|
||||
message.warning('打卡处理时间较长,请稍后查看打卡记录');
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('启动打卡失败:', error);
|
||||
checkInLoading.value[taskId] = false;
|
||||
message.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);
|
||||
message.success('任务更新成功');
|
||||
}
|
||||
// Create from template
|
||||
else {
|
||||
if (!selectedTemplate.value) {
|
||||
message.warning('请选择一个模板');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!templateTaskForm.thread_id) {
|
||||
message.warning('请输入接龙 ID');
|
||||
return;
|
||||
}
|
||||
|
||||
await templateStore.createTaskFromTemplate(
|
||||
selectedTemplate.value.id,
|
||||
templateTaskForm.thread_id,
|
||||
templateTaskForm.field_values,
|
||||
templateTaskForm.task_name || null,
|
||||
templateTaskForm.cron_expression || '0 20 * * *'
|
||||
);
|
||||
|
||||
message.success('任务创建成功');
|
||||
}
|
||||
|
||||
showCreateDialog.value = false;
|
||||
resetForm();
|
||||
await fetchTasks();
|
||||
} catch (error) {
|
||||
message.error(error.message || '操作失败');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
editingTask.value = null;
|
||||
selectedTemplate.value = null;
|
||||
|
||||
Object.assign(taskForm, {
|
||||
name: '',
|
||||
thread_id: '',
|
||||
is_active: true,
|
||||
payload_config: '',
|
||||
cron_expression: '0 20 * * *',
|
||||
});
|
||||
|
||||
templateTaskForm.task_name = '';
|
||||
templateTaskForm.thread_id = '';
|
||||
templateTaskForm.field_values = {};
|
||||
templateTaskForm.cron_expression = '0 20 * * *';
|
||||
|
||||
taskFormRef.value?.resetFields();
|
||||
};
|
||||
|
||||
// 打开创建任务对话框
|
||||
const openCreateDialog = () => {
|
||||
resetForm(); // 重置表单状态,确保不会显示编辑界面
|
||||
showCreateDialog.value = true;
|
||||
};
|
||||
|
||||
// Watch dialog open to load templates
|
||||
watch(showCreateDialog, isOpen => {
|
||||
if (isOpen && !editingTask.value) {
|
||||
loadTemplates();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchTasks();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="admin-logs-container">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<FileTextOutlined />
|
||||
<span>系统日志</span>
|
||||
</div>
|
||||
<a-button type="primary" @click="handleRefresh">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-alert
|
||||
message="日志查看"
|
||||
description="显示最新的系统日志信息(默认显示最近 200 行)"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 20px"
|
||||
/>
|
||||
|
||||
<div v-if="adminStore.loading" class="loading-container">
|
||||
<a-skeleton :active="true" :paragraph="{ rows: 10 }" />
|
||||
</div>
|
||||
|
||||
<div v-else class="logs-content">
|
||||
<a-textarea
|
||||
v-model:value="logContent"
|
||||
:rows="25"
|
||||
:readonly="true"
|
||||
placeholder="暂无日志内容"
|
||||
class="log-textarea"
|
||||
/>
|
||||
|
||||
<div class="log-info">
|
||||
<span>共 {{ logLines }} 行</span>
|
||||
<span>最后更新: {{ lastUpdate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { FileTextOutlined, ReloadOutlined } from '@ant-design/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());
|
||||
message.success({ content: '刷新成功', duration: 2 });
|
||||
} else {
|
||||
logContent.value = '无日志内容';
|
||||
}
|
||||
} catch (error) {
|
||||
message.error({ content: error.message || '刷新失败', duration: 4 });
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.log-textarea :deep(textarea) {
|
||||
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,190 @@
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="admin-records-container">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<UnorderedListOutlined />
|
||||
<span>所有打卡记录</span>
|
||||
</div>
|
||||
<a-button type="primary" @click="handleRefresh">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Desktop table -->
|
||||
<a-table
|
||||
v-if="!isMobile"
|
||||
:data-source="checkInStore.allRecords"
|
||||
:columns="columns"
|
||||
:loading="checkInStore.loading"
|
||||
:pagination="false"
|
||||
:row-key="record => record.id"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
bordered
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'check_in_time'">
|
||||
{{ formatDateTime(record.check_in_time) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag>
|
||||
<a-tag v-else-if="record.status === 'out_of_time'" color="default"
|
||||
>🕐 时间范围外</a-tag
|
||||
>
|
||||
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
|
||||
<a-tag v-else color="error">❌ 打卡失败</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'trigger_type'">
|
||||
<a-tag v-if="record.trigger_type === 'manual'" color="blue">手动</a-tag>
|
||||
<a-tag v-else-if="record.trigger_type === 'scheduled'" color="cyan">定时</a-tag>
|
||||
<a-tag v-else-if="record.trigger_type === 'admin'" color="orange">管理员</a-tag>
|
||||
<a-tag v-else>{{ record.trigger_type }}</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- Mobile card view -->
|
||||
<a-space v-else direction="vertical" :size="16" style="width: 100%">
|
||||
<a-card
|
||||
v-for="record in checkInStore.allRecords"
|
||||
:key="record.id"
|
||||
size="small"
|
||||
:loading="checkInStore.loading"
|
||||
>
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="ID">{{ record.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="用户ID">{{ record.user_id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="用户邮箱">{{
|
||||
record.user_email || '-'
|
||||
}}</a-descriptions-item>
|
||||
<a-descriptions-item label="任务名称">{{
|
||||
record.task_name || '-'
|
||||
}}</a-descriptions-item>
|
||||
<a-descriptions-item label="接龙ID">{{
|
||||
record.thread_id || '-'
|
||||
}}</a-descriptions-item>
|
||||
<a-descriptions-item label="打卡时间">{{
|
||||
formatDateTime(record.check_in_time)
|
||||
}}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag>
|
||||
<a-tag v-else-if="record.status === 'out_of_time'" color="default"
|
||||
>🕐 时间范围外</a-tag
|
||||
>
|
||||
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
|
||||
<a-tag v-else color="error">❌ 打卡失败</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="触发方式">
|
||||
<a-tag v-if="record.trigger_type === 'manual'" color="blue">手动</a-tag>
|
||||
<a-tag v-else-if="record.trigger_type === 'scheduled'" color="cyan">定时</a-tag>
|
||||
<a-tag v-else-if="record.trigger_type === 'admin'" color="orange">管理员</a-tag>
|
||||
<a-tag v-else>{{ record.trigger_type }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="消息">{{
|
||||
record.response_text || '-'
|
||||
}}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</a-space>
|
||||
|
||||
<!-- Empty state -->
|
||||
<a-empty
|
||||
v-if="!checkInStore.loading && checkInStore.allRecords.length === 0"
|
||||
description="暂无打卡记录"
|
||||
/>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="checkInStore.total > 0" class="pagination-container">
|
||||
<a-pagination
|
||||
v-model:current="checkInStore.currentPage"
|
||||
v-model:page-size="checkInStore.pageSize"
|
||||
:total="checkInStore.total"
|
||||
:page-size-options="['10', '20', '50', '100']"
|
||||
show-size-changer
|
||||
show-quick-jumper
|
||||
:show-total="total => `共 ${total} 条记录`"
|
||||
@change="handlePageChange"
|
||||
@show-size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { UnorderedListOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import Layout from '@/components/Layout.vue';
|
||||
import { useCheckInStore } from '@/stores/checkIn';
|
||||
import { useBreakpoint } from '@/composables/useBreakpoint';
|
||||
import { formatDateTime } from '@/utils/helpers';
|
||||
|
||||
const checkInStore = useCheckInStore();
|
||||
const { isMobile } = useBreakpoint();
|
||||
|
||||
// Table columns configuration
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: '用户ID', dataIndex: 'user_id', key: 'user_id', width: 100 },
|
||||
{ title: '用户邮箱', dataIndex: 'user_email', key: 'user_email', width: 180, ellipsis: true },
|
||||
{ title: '任务名称', dataIndex: 'task_name', key: 'task_name', width: 150, ellipsis: true },
|
||||
{ title: '接龙ID', dataIndex: 'thread_id', key: 'thread_id', width: 150, ellipsis: true },
|
||||
{ title: '打卡时间', dataIndex: 'check_in_time', key: 'check_in_time', width: 180 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 120 },
|
||||
{ title: '触发方式', dataIndex: 'trigger_type', key: 'trigger_type', width: 120 },
|
||||
{ title: '消息', dataIndex: 'response_text', key: 'response_text', ellipsis: true },
|
||||
];
|
||||
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
await checkInStore.fetchAllRecords();
|
||||
message.success('刷新成功');
|
||||
} catch (error) {
|
||||
message.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;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="admin-stats-container">
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="24">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="card-header">
|
||||
<BarChartOutlined />
|
||||
<span>系统统计信息</span>
|
||||
<a-button type="primary" @click="handleRefresh">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="adminStore.loading" class="loading-container">
|
||||
<a-skeleton :active="true" :paragraph="{ rows: 5 }" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="adminStore.stats" class="stats-content">
|
||||
<a-row :gutter="[20, 20]">
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-statistic title="总用户数" :value="adminStore.totalUsers">
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-statistic
|
||||
title="已审批用户数"
|
||||
:value="adminStore.activeUsers"
|
||||
:value-style="{ color: '#52c41a' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<CheckOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-statistic title="总打卡次数" :value="adminStore.totalRecords">
|
||||
<template #prefix>
|
||||
<UnorderedListOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-statistic
|
||||
title="今日打卡"
|
||||
:value="adminStore.todayRecords"
|
||||
:value-style="{ color: '#1890ff' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<CalendarOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<a-descriptions title="详细信息" :column="{ xs: 1, sm: 1, md: 2 }" bordered>
|
||||
<a-descriptions-item label="管理员数量">
|
||||
{{ adminStore.stats?.users?.admin || 0 }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="普通用户数量">
|
||||
{{ adminStore.stats?.users?.regular || 0 }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="今日成功打卡">
|
||||
<a-tag color="success">{{
|
||||
adminStore.stats?.check_in_records?.today_success || 0
|
||||
}}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="今日失败打卡">
|
||||
<a-tag color="error">{{
|
||||
adminStore.stats?.check_in_records?.today_failure || 0
|
||||
}}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="今日时间范围外">
|
||||
<a-tag color="default">{{
|
||||
adminStore.stats?.check_in_records?.today_out_of_time || 0
|
||||
}}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="今日异常打卡">
|
||||
<a-tag color="warning">{{
|
||||
adminStore.stats?.check_in_records?.today_unknown || 0
|
||||
}}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="总成功率" :span="2">
|
||||
<a-progress
|
||||
:percent="calculateSuccessRate()"
|
||||
:stroke-color="getProgressColor(calculateSuccessRate())"
|
||||
/>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
BarChartOutlined,
|
||||
ReloadOutlined,
|
||||
UserOutlined,
|
||||
CheckOutlined,
|
||||
UnorderedListOutlined,
|
||||
CalendarOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import Layout from '@/components/Layout.vue';
|
||||
import { useAdminStore } from '@/stores/admin';
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
|
||||
const getProgressColor = percentage => {
|
||||
if (percentage >= 90) return '#52c41a';
|
||||
if (percentage >= 70) return '#faad14';
|
||||
return '#ff4d4f';
|
||||
};
|
||||
|
||||
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();
|
||||
message.success('刷新成功');
|
||||
} catch (error) {
|
||||
message.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;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-header :deep(.ant-btn) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stats-content {
|
||||
padding: 20px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,796 @@
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="templates-view">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gradient mb-2">任务模板管理</h1>
|
||||
<p class="text-on-surface-variant">JSON 映射架构 - 配置即结构</p>
|
||||
</div>
|
||||
<button class="md3-button-filled" @click="showCreateDialog">
|
||||
<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">
|
||||
<a-card v-for="i in 3" :key="i" class="md3-card">
|
||||
<a-skeleton :active="true" :paragraph="{ rows: 2 }" />
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<a-card
|
||||
v-else-if="templates.length === 0"
|
||||
class="md3-card text-center"
|
||||
style="padding: 48px 20px"
|
||||
>
|
||||
<svg
|
||||
class="w-20 h-20 mx-auto text-on-surface-variant opacity-30 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-on-surface mb-2">暂无模板</h3>
|
||||
<p class="text-on-surface-variant mb-4">创建第一个模板,让用户更轻松地创建打卡任务</p>
|
||||
<button class="md3-button-filled" @click="showCreateDialog">新建模板</button>
|
||||
</a-card>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<a-card
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="md3-card hover:shadow-xl transition-all animate-slide-up"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-on-surface mb-2">{{ template.name }}</h3>
|
||||
<a-divider style="margin: 8px 0" />
|
||||
<p class="text-sm text-on-surface-variant mb-2">
|
||||
{{ template.description || '无描述' }}
|
||||
</p>
|
||||
<span :class="template.is_active ? 'md3-badge-success' : 'md3-badge-info'">
|
||||
{{ template.is_active ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 pt-3 border-t border-outline-variant space-y-2">
|
||||
<!-- 第一行:预览在左半部分居中,编辑在右半部分居中 -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="md3-button-outlined text-sm flex-shrink-0"
|
||||
@click="previewTemplate(template)"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-1.5"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="md3-button-outlined text-sm flex-shrink-0"
|
||||
@click="editTemplate(template)"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-1.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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:删除在右半部分居中,与编辑对齐 -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div></div>
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="md3-button-outlined text-sm !text-red-600 dark:!text-red-500 !border-red-600 dark:!border-red-500 hover:!bg-red-50 dark:hover:!bg-red-900/20 flex-shrink-0"
|
||||
@click="deleteTemplate(template)"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-1.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>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<a-modal
|
||||
v-model:open="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新建模板' : '编辑模板'"
|
||||
:width="dialogWidth"
|
||||
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
|
||||
:mask-closable="false"
|
||||
class="template-editor-modal"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" layout="vertical">
|
||||
<a-form-item label="模板名称" required>
|
||||
<a-input
|
||||
v-model:value="formData.name"
|
||||
placeholder="请输入模板名称"
|
||||
:maxlength="100"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="模板描述">
|
||||
<a-textarea
|
||||
v-model:value="formData.description"
|
||||
:rows="2"
|
||||
placeholder="请输入模板描述"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="父模板">
|
||||
<a-select
|
||||
v-model:value="formData.parent_id"
|
||||
placeholder="可选,继承父模板的字段配置"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="template in availableParentTemplates"
|
||||
:key="template.id"
|
||||
:value="template.id"
|
||||
:disabled="template.id === currentTemplateId"
|
||||
>
|
||||
{{ template.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="是否启用">
|
||||
<a-switch v-model:checked="formData.is_active" />
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left">
|
||||
<span class="text-lg font-bold">Payload 配置 (JSON 映射)</span>
|
||||
</a-divider>
|
||||
|
||||
<a-alert
|
||||
message="💡 JSON 映射架构"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
>
|
||||
<template #description>
|
||||
<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>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- 字段配置编辑器 -->
|
||||
<div class="field-config-editor">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-bold text-on-surface">字段配置</h3>
|
||||
<a-dropdown>
|
||||
<a-button type="primary">
|
||||
添加字段
|
||||
<DownOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="handleAddField">
|
||||
<a-menu-item key="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>
|
||||
普通字段
|
||||
</a-menu-item>
|
||||
<a-menu-item key="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>
|
||||
数组字段
|
||||
</a-menu-item>
|
||||
<a-menu-item key="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>
|
||||
对象字段
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- 递归渲染字段树 -->
|
||||
<div
|
||||
v-if="Object.keys(formData.field_config).length === 0"
|
||||
class="text-center py-12 border-2 border-dashed border-outline-variant rounded-lg bg-surface-container"
|
||||
>
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto text-on-surface-variant opacity-40 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-on-surface mb-2">暂无字段配置</h3>
|
||||
<p class="text-sm text-on-surface-variant">点击上方"添加字段"开始配置模板</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<FieldTreeNode
|
||||
v-for="(config, key) in formData.field_config"
|
||||
:key="`${fieldConfigVersion}-${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 预览 -->
|
||||
<a-divider orientation="left">
|
||||
<span class="text-lg font-bold">JSON 预览</span>
|
||||
</a-divider>
|
||||
|
||||
<div
|
||||
class="bg-surface-container 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>
|
||||
</a-form>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="dialogVisible = false">取消</a-button>
|
||||
<a-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '更新' }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- Add Field Dialog -->
|
||||
<a-modal
|
||||
v-model:open="addFieldDialogVisible"
|
||||
:title="`添加${fieldTypeLabel}`"
|
||||
:width="isMobile ? '100%' : 500"
|
||||
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
|
||||
>
|
||||
<a-form @submit.prevent="confirmAddField">
|
||||
<a-form-item label="字段名">
|
||||
<a-input
|
||||
v-model:value="newFieldName"
|
||||
placeholder="例如: Id, Group1, DateTarget"
|
||||
@keyup.enter="confirmAddField"
|
||||
/>
|
||||
<span class="text-xs text-on-surface-variant mt-1 block">
|
||||
💡 字段名将保持原样,不会进行大小写转换
|
||||
</span>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="addFieldDialogVisible = false">取消</a-button>
|
||||
<a-button type="primary" @click="confirmAddField">确定</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- Preview Dialog -->
|
||||
<a-modal
|
||||
v-model:open="previewDialogVisible"
|
||||
title="模板预览"
|
||||
:width="previewDialogWidth"
|
||||
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
|
||||
>
|
||||
<div v-if="previewData" class="space-y-4">
|
||||
<div class="bg-surface-container rounded p-4">
|
||||
<h4 class="font-semibold mb-2 text-on-surface">生成的 Payload(使用默认值):</h4>
|
||||
<pre
|
||||
class="text-xs bg-surface text-on-surface p-3 rounded border border-outline-variant overflow-auto max-h-96"
|
||||
>{{ JSON.stringify(previewData.preview_payload, null, 2) }}</pre
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-container rounded p-4">
|
||||
<h4 class="font-semibold mb-2 text-on-surface">字段配置:</h4>
|
||||
<pre
|
||||
class="text-xs bg-surface text-on-surface p-3 rounded border border-outline-variant overflow-auto max-h-96"
|
||||
>{{ JSON.stringify(previewData.field_config, null, 2) }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="previewDialogVisible = false">关闭</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { DownOutlined } from '@ant-design/icons-vue';
|
||||
import Layout from '@/components/Layout.vue';
|
||||
import FieldTreeNode from '@/components/FieldTreeNode.vue';
|
||||
import { useTemplateStore } from '@/stores/template';
|
||||
import { useBreakpoint } from '@/composables/useBreakpoint';
|
||||
|
||||
const templateStore = useTemplateStore();
|
||||
const { isMobile, isTablet } = useBreakpoint();
|
||||
|
||||
// 计算对话框宽度 - 响应式设计
|
||||
const dialogWidth = computed(() => {
|
||||
if (isMobile.value) return '100%';
|
||||
if (isTablet.value) return 900;
|
||||
return 1200;
|
||||
});
|
||||
|
||||
const previewDialogWidth = computed(() => {
|
||||
if (isMobile.value) return '100%';
|
||||
if (isTablet.value) return 800;
|
||||
return 1000;
|
||||
});
|
||||
|
||||
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 fieldConfigVersion = ref(0); // 用于强制刷新字段列表
|
||||
|
||||
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) {
|
||||
message.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) {
|
||||
message.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);
|
||||
message.success('模板创建成功');
|
||||
} else {
|
||||
await templateStore.updateTemplate(currentTemplateId.value, templateData);
|
||||
message.success('模板更新成功');
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
await fetchTemplates();
|
||||
} catch (error) {
|
||||
message.error(error.message || '操作失败');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTemplate = template => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除模板"${template.name}"吗?此操作不可撤销。`,
|
||||
okText: '删除',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await templateStore.deleteTemplate(template.id);
|
||||
message.success('模板删除成功');
|
||||
await fetchTemplates();
|
||||
} catch (error) {
|
||||
message.error(error.message || '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const previewTemplate = async template => {
|
||||
try {
|
||||
previewData.value = await templateStore.previewTemplate(template.id);
|
||||
previewDialogVisible.value = true;
|
||||
} catch (error) {
|
||||
message.error(error.message || '预览失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddField = ({ key }) => {
|
||||
newFieldType.value = key;
|
||||
newFieldName.value = '';
|
||||
addFieldDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const confirmAddField = () => {
|
||||
if (!newFieldName.value) {
|
||||
message.warning('请输入字段名');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.value.field_config[newFieldName.value]) {
|
||||
message.warning('该字段已存在');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建一个新对象,确保新字段被添加到末尾
|
||||
const newConfig = { ...formData.value.field_config };
|
||||
|
||||
// 创建对应类型的字段
|
||||
if (newFieldType.value === 'field') {
|
||||
newConfig[newFieldName.value] = createDefaultFieldConfig();
|
||||
} else if (newFieldType.value === 'array') {
|
||||
newConfig[newFieldName.value] = [];
|
||||
} else if (newFieldType.value === 'object') {
|
||||
newConfig[newFieldName.value] = {};
|
||||
}
|
||||
|
||||
// 替换整个 field_config 以确保顺序和响应性
|
||||
formData.value.field_config = newConfig;
|
||||
fieldConfigVersion.value++; // 强制刷新
|
||||
|
||||
addFieldDialogVisible.value = false;
|
||||
message.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 => {
|
||||
// 通过路径删除嵌套字段
|
||||
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;
|
||||
fieldConfigVersion.value++; // 强制刷新
|
||||
};
|
||||
|
||||
const moveField = (path, direction) => {
|
||||
// 通过路径移动字段
|
||||
if (!path || path.length === 0) return;
|
||||
|
||||
// 如果是根级别字段,直接重建整个 field_config
|
||||
if (path.length === 1) {
|
||||
const fieldKey = path[0];
|
||||
const keys = Object.keys(formData.value.field_config);
|
||||
const currentIndex = keys.indexOf(fieldKey);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
console.error('❌ 字段不存在:', fieldKey);
|
||||
return;
|
||||
}
|
||||
|
||||
let targetIndex = currentIndex;
|
||||
if (direction === 'up' && currentIndex > 0) {
|
||||
targetIndex = currentIndex - 1;
|
||||
} else if (direction === 'down' && currentIndex < keys.length - 1) {
|
||||
targetIndex = currentIndex + 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// 交换键的位置
|
||||
const temp = keys[currentIndex];
|
||||
keys[currentIndex] = keys[targetIndex];
|
||||
keys[targetIndex] = temp;
|
||||
|
||||
// 重建整个 field_config - 使用深拷贝确保完全新的对象
|
||||
const newConfig = {};
|
||||
keys.forEach(key => {
|
||||
// 深拷贝每个字段配置
|
||||
newConfig[key] = JSON.parse(JSON.stringify(formData.value.field_config[key]));
|
||||
});
|
||||
|
||||
// 替换整个 formData,而不只是 field_config
|
||||
formData.value = {
|
||||
...formData.value,
|
||||
field_config: newConfig,
|
||||
};
|
||||
fieldConfigVersion.value++;
|
||||
return;
|
||||
}
|
||||
|
||||
// 嵌套字段的情况(保留原有逻辑)
|
||||
const newConfig = JSON.parse(JSON.stringify(formData.value.field_config));
|
||||
|
||||
// 导航到目标的父容器
|
||||
let parent = newConfig;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
parent = parent[path[i]];
|
||||
if (!parent) {
|
||||
console.error('❌ 路径无效:', path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const fieldKey = path[path.length - 1];
|
||||
|
||||
if (Array.isArray(parent)) {
|
||||
// 数组情况:直接交换元素
|
||||
const index = Number(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) {
|
||||
console.error('❌ 字段不存在:', fieldKey);
|
||||
return;
|
||||
}
|
||||
|
||||
let targetIndex = currentIndex;
|
||||
if (direction === 'up' && currentIndex > 0) {
|
||||
targetIndex = currentIndex - 1;
|
||||
} else if (direction === 'down' && currentIndex < keys.length - 1) {
|
||||
targetIndex = currentIndex + 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// 交换键数组中的位置
|
||||
const temp = keys[currentIndex];
|
||||
keys[currentIndex] = keys[targetIndex];
|
||||
keys[targetIndex] = temp;
|
||||
|
||||
// 重建父对象
|
||||
const reorderedParent = {};
|
||||
keys.forEach(key => {
|
||||
reorderedParent[key] = parent[key];
|
||||
});
|
||||
|
||||
// 替换父容器的所有属性
|
||||
Object.keys(parent).forEach(key => delete parent[key]);
|
||||
Object.assign(parent, reorderedParent);
|
||||
}
|
||||
|
||||
// 强制触发响应性更新
|
||||
formData.value.field_config = newConfig;
|
||||
fieldConfigVersion.value++;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchTemplates();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-config-editor {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.template-editor-modal :deep(.ant-modal-body) {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,607 @@
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="admin-users-container">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<UserOutlined />
|
||||
<span>用户管理</span>
|
||||
</div>
|
||||
<a-space class="actions">
|
||||
<a-button type="primary" @click="handleCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
创建用户
|
||||
</a-button>
|
||||
<a-button @click="handleRefresh">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<a-tabs v-model:active-key="activeTab" @change="handleTabChange">
|
||||
<!-- 待审批用户 Tab -->
|
||||
<a-tab-pane key="pending" tab="待审批用户">
|
||||
<!-- 桌面端表格 -->
|
||||
<a-table
|
||||
v-if="!isMobile"
|
||||
:data-source="pendingUsers"
|
||||
:columns="pendingColumns"
|
||||
:loading="loading"
|
||||
:row-key="record => record.id"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
bordered
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'created_at'">
|
||||
{{ formatDateTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button type="primary" size="small" @click="handleApprove(record)">
|
||||
通过
|
||||
</a-button>
|
||||
<a-button danger size="small" @click="handleReject(record)"> 拒绝 </a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 移动端卡片视图 -->
|
||||
<a-space v-else direction="vertical" :size="16" style="width: 100%">
|
||||
<a-card v-for="user in pendingUsers" :key="user.id" size="small" :loading="loading">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="ID">{{ user.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="用户名">{{ user.alias }}</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">{{ user.email || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="注册时间">{{
|
||||
formatDateTime(user.created_at)
|
||||
}}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-space class="mt-3" style="width: 100%">
|
||||
<a-button type="primary" size="small" block @click="handleApprove(user)"
|
||||
>通过</a-button
|
||||
>
|
||||
<a-button danger size="small" block @click="handleReject(user)">拒绝</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
<a-empty v-if="!loading && pendingUsers.length === 0" description="暂无数据" />
|
||||
</a-space>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 所有用户 Tab -->
|
||||
<a-tab-pane key="all" tab="所有用户">
|
||||
<!-- 桌面端表格 -->
|
||||
<a-table
|
||||
v-if="!isMobile"
|
||||
:data-source="userStore.users"
|
||||
:columns="allColumns"
|
||||
:loading="loading"
|
||||
:row-key="record => record.id"
|
||||
:row-selection="rowSelection"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
bordered
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'role'">
|
||||
<a-tag :color="record.role === 'admin' ? 'error' : 'blue'">
|
||||
{{ record.role === 'admin' ? '管理员' : '用户' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'is_approved'">
|
||||
<a-tag :color="record.is_approved ? 'success' : 'warning'">
|
||||
{{ record.is_approved ? '已审批' : '待审批' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'jwt_exp'">
|
||||
{{
|
||||
record.jwt_exp && record.jwt_exp !== '0'
|
||||
? formatDateTime(parseInt(record.jwt_exp) * 1000)
|
||||
: '-'
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'created_at'">
|
||||
{{ formatDateTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button type="primary" size="small" @click="handleEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button danger size="small" @click="handleDelete(record)"> 删除 </a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 移动端卡片视图 -->
|
||||
<a-space v-else direction="vertical" :size="16" style="width: 100%">
|
||||
<a-card
|
||||
v-for="user in userStore.users"
|
||||
:key="user.id"
|
||||
size="small"
|
||||
:loading="loading"
|
||||
>
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="ID">{{ user.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="用户名">{{ user.alias }}</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">{{ user.email || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="角色">
|
||||
<a-tag :color="user.role === 'admin' ? 'error' : 'blue'">
|
||||
{{ user.role === 'admin' ? '管理员' : '用户' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="审批状态">
|
||||
<a-tag :color="user.is_approved ? 'success' : 'warning'">
|
||||
{{ user.is_approved ? '已审批' : '待审批' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="Token过期">
|
||||
{{
|
||||
user.jwt_exp && user.jwt_exp !== '0'
|
||||
? formatDateTime(parseInt(user.jwt_exp) * 1000)
|
||||
: '-'
|
||||
}}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{
|
||||
formatDateTime(user.created_at)
|
||||
}}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-space class="mt-3" style="width: 100%">
|
||||
<a-button type="primary" size="small" block @click="handleEdit(user)"
|
||||
>编辑</a-button
|
||||
>
|
||||
<a-button danger size="small" block @click="handleDelete(user)">删除</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</a-space>
|
||||
|
||||
<!-- 批量操作 -->
|
||||
<div v-if="selectedUsers.length > 0" class="batch-actions">
|
||||
<a-alert
|
||||
:message="`已选择 ${selectedUsers.length} 个用户`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
>
|
||||
<template #description>
|
||||
<a-space style="margin-top: 10px">
|
||||
<a-button type="primary" size="small" @click="handleBatchApprove">
|
||||
批量审批
|
||||
</a-button>
|
||||
<a-button danger size="small" @click="handleBatchDelete"> 批量删除 </a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-alert>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
|
||||
<!-- 创建/编辑用户对话框 -->
|
||||
<a-modal
|
||||
v-model:open="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '创建用户' : '编辑用户'"
|
||||
:width="isMobile ? '100%' : 600"
|
||||
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="formRules" layout="vertical">
|
||||
<a-form-item label="用户名" name="alias">
|
||||
<a-input v-model:value="formData.alias" placeholder="请输入用户名" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="邮箱" name="email">
|
||||
<a-input v-model:value="formData.email" placeholder="请输入邮箱" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="角色" name="role">
|
||||
<a-select v-model:value="formData.role" placeholder="请选择角色">
|
||||
<a-select-option value="user">用户</a-select-option>
|
||||
<a-select-option value="admin">管理员</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="审批状态" name="is_approved">
|
||||
<a-switch v-model:checked="formData.is_approved" />
|
||||
<span class="form-hint">是否已审批通过</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="密码" name="password">
|
||||
<a-input-password
|
||||
v-model:value="formData.password"
|
||||
:placeholder="dialogMode === 'create' ? '请输入密码' : '留空则不修改密码'"
|
||||
/>
|
||||
<span v-if="dialogMode === 'edit'" class="form-hint"> 留空则不修改密码 </span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="dialogMode === 'edit'" label="重置密码">
|
||||
<a-switch v-model:checked="formData.reset_password" />
|
||||
<span v-if="formData.reset_password" class="form-hint-danger">
|
||||
⚠️ 将重置为默认密码
|
||||
</span>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="dialogVisible = false">取消</a-button>
|
||||
<a-button type="primary" :loading="submitting" @click="handleSubmit"> 确定 </a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { UserOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import Layout from '@/components/Layout.vue';
|
||||
import { useBreakpoint } from '@/composables/useBreakpoint';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { adminAPI } from '@/api/index';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const { isMobile } = useBreakpoint();
|
||||
|
||||
// 状态
|
||||
const loading = ref(false);
|
||||
const activeTab = ref('all'); // 默认展示所有用户
|
||||
const pendingUsers = ref([]);
|
||||
const selectedUsers = ref([]);
|
||||
const selectedRowKeys = 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 pendingColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: '用户名', dataIndex: 'alias', key: 'alias', ellipsis: true },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email', ellipsis: true },
|
||||
{ title: '注册时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||
{ title: '操作', key: 'actions', width: 200, fixed: 'right' },
|
||||
];
|
||||
|
||||
// 所有用户表格列
|
||||
const allColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: '用户名', dataIndex: 'alias', key: 'alias', ellipsis: true },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email', ellipsis: true },
|
||||
{ title: '角色', dataIndex: 'role', key: 'role', width: 100 },
|
||||
{ title: '审批状态', dataIndex: 'is_approved', key: 'is_approved', width: 100 },
|
||||
{ title: 'Token 过期时间', dataIndex: 'jwt_exp', key: 'jwt_exp', width: 180 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||
{ title: '操作', key: 'actions', width: 200, fixed: 'right' },
|
||||
];
|
||||
|
||||
// 行选择配置
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedRowKeys,
|
||||
onChange: (keys, rows) => {
|
||||
selectedRowKeys.value = keys;
|
||||
selectedUsers.value = rows;
|
||||
},
|
||||
};
|
||||
|
||||
// 获取待审批用户
|
||||
const fetchPendingUsers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
pendingUsers.value = await adminAPI.getPendingUsers();
|
||||
} catch (error) {
|
||||
message.error(error.message || '获取待审批用户失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Tab 切换
|
||||
const handleTabChange = tab => {
|
||||
if (tab === 'pending') {
|
||||
fetchPendingUsers();
|
||||
} else {
|
||||
handleRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
// 审批通过用户
|
||||
const handleApprove = async user => {
|
||||
Modal.confirm({
|
||||
title: '审批确认',
|
||||
content: `确认通过用户 "${user.alias}" 的审批吗?`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await adminAPI.approveUser(user.id);
|
||||
message.success('审批成功');
|
||||
fetchPendingUsers();
|
||||
} catch (error) {
|
||||
message.error(error.message || '审批失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 拒绝用户
|
||||
const handleReject = async user => {
|
||||
Modal.confirm({
|
||||
title: '拒绝确认',
|
||||
content: `确认拒绝用户 "${user.alias}" 的申请吗?拒绝后将删除该用户。`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await adminAPI.rejectUser(user.id);
|
||||
message.success('已拒绝并删除用户');
|
||||
fetchPendingUsers();
|
||||
} catch (error) {
|
||||
message.error(error.message || '操作失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 刷新数据
|
||||
const handleRefresh = async () => {
|
||||
if (activeTab.value === 'pending') {
|
||||
await fetchPendingUsers();
|
||||
} else {
|
||||
loading.value = true;
|
||||
try {
|
||||
await userStore.fetchUsers();
|
||||
message.success('刷新成功');
|
||||
} catch (error) {
|
||||
message.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) {
|
||||
message.warning('不能同时设置新密码和重置密码,请选择其一');
|
||||
submitting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (dialogMode.value === 'create') {
|
||||
// 创建用户时,只发送后端 UserCreate schema 接受的字段
|
||||
const createData = {
|
||||
alias: formData.value.alias,
|
||||
role: formData.value.role,
|
||||
is_approved: formData.value.is_approved,
|
||||
};
|
||||
// 如果有邮箱,添加邮箱字段(空字符串转为 null)
|
||||
if (formData.value.email && formData.value.email.trim()) {
|
||||
createData.email = formData.value.email.trim();
|
||||
}
|
||||
// 如果有密码,添加密码字段
|
||||
if (formData.value.password && formData.value.password.trim()) {
|
||||
createData.password = formData.value.password.trim();
|
||||
}
|
||||
await userStore.createUser(createData);
|
||||
message.success('创建成功');
|
||||
} else {
|
||||
// 编辑用户时,处理空字符串字段
|
||||
const updateData = {
|
||||
...formData.value,
|
||||
// 将空字符串的邮箱转为 null
|
||||
email:
|
||||
formData.value.email && formData.value.email.trim() ? formData.value.email.trim() : null,
|
||||
// 将空字符串的密码转为 null
|
||||
password:
|
||||
formData.value.password && formData.value.password.trim()
|
||||
? formData.value.password.trim()
|
||||
: null,
|
||||
};
|
||||
await userStore.updateUser(formData.value.id, updateData);
|
||||
message.success('更新成功');
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
await handleRefresh();
|
||||
} catch (error) {
|
||||
message.error(error.message || '操作失败');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 删除用户
|
||||
const handleDelete = user => {
|
||||
Modal.confirm({
|
||||
title: '警告',
|
||||
content: `确定要删除用户 "${user.alias}" 吗?`,
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await userStore.deleteUser(user.id);
|
||||
message.success('删除成功');
|
||||
await handleRefresh();
|
||||
} catch (error) {
|
||||
message.error(error.message || '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 批量审批
|
||||
const handleBatchApprove = () => {
|
||||
Modal.confirm({
|
||||
title: '批量审批确认',
|
||||
content: `确认批量审批 ${selectedUsers.value.length} 个用户吗?`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
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 {
|
||||
failureCount++;
|
||||
}
|
||||
}
|
||||
|
||||
message.success(`批量审批完成:成功 ${successCount},失败 ${failureCount}`);
|
||||
await handleRefresh();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = () => {
|
||||
Modal.confirm({
|
||||
title: '批量删除警告',
|
||||
content: `确定要删除选中的 ${selectedUsers.value.length} 个用户吗?此操作不可恢复!`,
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
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 {
|
||||
failureCount++;
|
||||
}
|
||||
}
|
||||
|
||||
message.success(`批量删除完成:成功 ${successCount},失败 ${failureCount}`);
|
||||
await handleRefresh();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.mt-3 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class', // 启用 class 模式的暗色模式
|
||||
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: {
|
||||
// Material Design 3 Shape System
|
||||
'md3-xs': '4px', // Extra Small - chips, small tags
|
||||
'md3-sm': '8px', // Small - text fields, small components
|
||||
md3: '12px', // Medium - cards, buttons (default)
|
||||
'md3-lg': '16px', // Large - large cards, dialogs
|
||||
'md3-xl': '28px', // Extra Large - fully rounded buttons
|
||||
'md3-full': '9999px', // Full - pill shape
|
||||
},
|
||||
boxShadow: {
|
||||
// Material Design 3 Elevation System (official spec)
|
||||
'md3-0': 'none',
|
||||
'md3-1': '0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15)',
|
||||
'md3-2': '0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 2px 6px 2px rgba(0, 0, 0, 0.15)',
|
||||
'md3-3': '0px 1px 3px 0px rgba(0, 0, 0, 0.3), 0px 4px 8px 3px rgba(0, 0, 0, 0.15)',
|
||||
'md3-4': '0px 2px 3px 0px rgba(0, 0, 0, 0.3), 0px 6px 10px 4px rgba(0, 0, 0, 0.15)',
|
||||
'md3-5': '0px 4px 4px 0px rgba(0, 0, 0, 0.3), 0px 8px 12px 6px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
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,45 @@
|
||||
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: {
|
||||
host: '0.0.0.0', // Listen on all network interfaces for LAN access
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
// Manual chunking for better dependency management
|
||||
if (id.includes('node_modules')) {
|
||||
// Ant Design Vue
|
||||
if (id.includes('ant-design-vue')) {
|
||||
return 'ant-design-vue';
|
||||
}
|
||||
// Group all other vendor code together
|
||||
return 'vendor';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user