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:
2026-05-03 16:43:11 +08:00
parent 7e8852877e
commit d4d6f87730
112 changed files with 347 additions and 1596 deletions
+315
View File
@@ -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)}"
)
+192
View File
@@ -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)
# 设置限流 Cookie10 分钟)
response.set_cookie(
key="reg_limit",
value=reg_cookie,
max_age=600, # 10 分钟
httponly=True,
samesite="lax"
)
return result
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"创建扫码会话失败: {str(e)}"
)
@router.get("/qrcode_status/{session_id}", response_model=dict, summary="检查二维码扫描状态")
async def get_qrcode_status(
session_id: str,
db: Session = Depends(get_db)
):
"""
检查二维码扫描状态
- **session_id**: 会话 ID
状态说明:
- pending: 正在初始化
- waiting_scan: 等待扫描(包含二维码图片 Base64)
- success: 扫描成功(包含 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)}"
)
+234
View File
@@ -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)}"
)
+215
View File
@@ -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)
+214
View File
@@ -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
+282
View File
@@ -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)}"
)
+68
View File
@@ -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()
+124
View File
@@ -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
+64
View File
@@ -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)
+45
View File
@@ -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)
+176
View File
@@ -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",
)
+7
View File
@@ -0,0 +1,7 @@
from backend.models.database import Base, get_db, init_db
from backend.models.user import User
from backend.models.check_in_task import CheckInTask
from backend.models.check_in_record import CheckInRecord
from backend.models.task_template import TaskTemplate
__all__ = ["Base", "get_db", "init_db", "User", "CheckInTask", "CheckInRecord", "TaskTemplate"]
+31
View File
@@ -0,0 +1,31 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from backend.models.database import Base
class CheckInRecord(Base):
"""打卡记录模型"""
__tablename__ = "check_in_records"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
task_id = Column(Integer, ForeignKey("check_in_tasks.id", ondelete="CASCADE"), nullable=False, index=True, comment="任务 ID")
status = Column(String(20), nullable=False, index=True, comment="状态: success/failure/out_of_time/unknown/pending")
response_text = Column(Text, default="", comment="响应文本")
error_message = Column(Text, default="", comment="错误信息")
location = Column(Text, default="{}", comment="位置信息 JSON")
trigger_type = Column(String(50), default="scheduled", comment="触发类型: scheduled/manual/admin")
check_in_time = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True, comment="打卡时间(UTC")
# 关联任务
task = relationship("CheckInTask", back_populates="check_in_records")
# 添加复合索引:加速常见查询
__table_args__ = (
Index('ix_record_task_time', 'task_id', 'check_in_time'), # 获取任务的打卡记录(按时间排序)
Index('ix_record_status_time', 'status', 'check_in_time'), # 按状态和时间查询
)
def __repr__(self):
return f"<CheckInRecord(id={self.id}, task_id={self.task_id}, status={self.status})>"
+39
View File
@@ -0,0 +1,39 @@
from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime, ForeignKey, Index
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from backend.models.database import Base
class CheckInTask(Base):
"""打卡任务模型"""
__tablename__ = "check_in_tasks"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, comment="用户 ID")
payload_config = Column(Text, default="{}", nullable=False, comment="完整的 payload 配置 JSON(从模板生成,包含 ThreadId 和所有字段)")
name = Column(String(100), default="", comment="任务名称(用户自定义)")
is_active = Column(Boolean, default=True, comment="是否启用自动打卡(不影响手动打卡)")
cron_expression = Column(String(100), default="0 20 * * *", nullable=True, comment="Crontab 表达式(NULL 表示禁用自动打卡,否则按表达式执行)")
created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), comment="更新时间")
# 关联用户
user = relationship("User", back_populates="tasks")
# 关联打卡记录
check_in_records = relationship("CheckInRecord", back_populates="task", cascade="all, delete-orphan")
# 添加索引:加速查询
__table_args__ = (
Index('ix_task_user_active', 'user_id', 'is_active'),
Index('ix_task_cron', 'cron_expression'), # 加速查询启用了定时打卡的任务
)
def __repr__(self):
return f"<CheckInTask(id={self.id}, user_id={self.user_id}, name={self.name}, cron={self.cron_expression})>"
@property
def is_scheduled_enabled(self) -> bool:
"""判断是否启用了自动打卡(is_active 为 True 且 cron_expression 不为空)"""
return bool(self.is_active) and bool(self.cron_expression)
+52
View File
@@ -0,0 +1,52 @@
from sqlalchemy import create_engine, event
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from datetime import datetime, timezone
from backend.config import settings
# 创建数据库引擎
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False}, # SQLite 特定配置
echo=False, # 生产环境设为 False
)
# 创建会话工厂
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 创建基类
Base = declarative_base()
# SQLite timezone 修复:在加载对象后,将所有 naive datetime 转换为 UTC timezone-aware
@event.listens_for(Base, "load", propagate=True)
def receive_load(target, context):
"""在从数据库加载对象后,将所有 datetime 字段转换为 timezone-aware (UTC)"""
for attr_name in dir(target):
# 跳过私有属性和方法
if attr_name.startswith('_'):
continue
try:
attr_value = getattr(target, attr_name)
# 如果是 naive datetime,添加 UTC timezone
if isinstance(attr_value, datetime) and attr_value.tzinfo is None:
setattr(target, attr_name, attr_value.replace(tzinfo=timezone.utc))
except (AttributeError, TypeError):
# 某些属性可能无法访问或设置,跳过
continue
def get_db():
"""依赖注入:获取数据库会话"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""初始化数据库:创建所有表"""
Base.metadata.create_all(bind=engine)
+29
View File
@@ -0,0 +1,29 @@
from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from backend.models.database import Base
class TaskTemplate(Base):
"""打卡任务模板"""
__tablename__ = "task_templates"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
name = Column(String(100), nullable=False, comment="模板名称")
description = Column(Text, nullable=True, comment="模板描述")
# 父模板 ID(用于继承)
parent_id = Column(Integer, ForeignKey("task_templates.id", ondelete="SET NULL"), nullable=True, comment="父模板 ID")
# 字段配置(JSON 格式)
field_config = Column(Text, nullable=False, comment="字段配置(JSON")
is_active = Column(Boolean, default=True, comment="是否启用")
created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), comment="更新时间")
# 自引用关系:父模板和子模板
parent = relationship("TaskTemplate", remote_side=[id], backref="children")
def __repr__(self):
return f"<TaskTemplate(id={self.id}, name='{self.name}', is_active={self.is_active})>"
+46
View File
@@ -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"
+27
View File
@@ -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
+71
View File
@@ -0,0 +1,71 @@
from backend.schemas.user import (
UserBase,
UserCreate,
UserUpdate,
UserResponse,
UserWithToken,
TokenStatus,
)
from backend.schemas.auth import (
QRCodeRequest,
QRCodeResponse,
QRCodeStatusResponse,
TokenVerifyRequest,
TokenVerifyResponse,
)
from backend.schemas.check_in import (
ManualCheckInRequest,
BatchCheckInRequest,
CheckInRecordResponse,
CheckInRecordWithTaskInfo,
CheckInResultResponse,
)
from backend.schemas.task import (
TaskBase,
TaskCreate,
TaskUpdate,
TaskResponse,
)
from backend.schemas.template import (
FieldOption,
FieldConfigItem,
FieldConfig,
TemplateBase,
TemplateCreate,
TemplateUpdate,
TemplateResponse,
TaskFromTemplateRequest,
TemplatePreviewResponse,
)
__all__ = [
"UserBase",
"UserCreate",
"UserUpdate",
"UserResponse",
"UserWithToken",
"TokenStatus",
"QRCodeRequest",
"QRCodeResponse",
"QRCodeStatusResponse",
"TokenVerifyRequest",
"TokenVerifyResponse",
"ManualCheckInRequest",
"BatchCheckInRequest",
"CheckInRecordResponse",
"CheckInRecordWithTaskInfo",
"CheckInResultResponse",
"TaskBase",
"TaskCreate",
"TaskUpdate",
"TaskResponse",
"FieldOption",
"FieldConfigItem",
"FieldConfig",
"TemplateBase",
"TemplateCreate",
"TemplateUpdate",
"TemplateResponse",
"TaskFromTemplateRequest",
"TemplatePreviewResponse",
]
+49
View File
@@ -0,0 +1,49 @@
from typing import Optional
from pydantic import BaseModel, Field
class QRCodeRequest(BaseModel):
"""请求二维码 Schema"""
alias: str = Field(..., description="用户别名")
class QRCodeResponse(BaseModel):
"""二维码响应 Schema"""
session_id: str = Field(..., description="会话 ID")
qrcode_image: str = Field(..., description="二维码 Base64 图片")
class QRCodeStatusResponse(BaseModel):
"""二维码状态响应 Schema"""
status: str = Field(..., description="状态: pending/waiting_scan/success/error")
message: Optional[str] = Field(None, description="状态消息")
user_id: Optional[int] = Field(None, description="用户 ID (扫码成功时返回)")
authorization: Optional[str] = Field(None, description="Token (扫码成功时返回)")
qrcode_image: Optional[str] = Field(None, description="二维码 Base64 图片(等待扫描时返回)")
class TokenVerifyRequest(BaseModel):
"""Token 验证请求 Schema"""
authorization: str = Field(..., description="Token")
class TokenVerifyResponse(BaseModel):
"""Token 验证响应 Schema"""
is_valid: bool = Field(..., description="Token 是否有效")
message: str = Field(..., description="验证消息")
user_id: Optional[int] = Field(None, description="用户 ID")
class AliasLoginRequest(BaseModel):
"""别名+密码登录请求 Schema"""
alias: str = Field(..., min_length=2, max_length=50, description="用户别名")
password: str = Field(..., min_length=6, description="密码")
class AliasLoginResponse(BaseModel):
"""别名+密码登录响应 Schema"""
success: bool = Field(..., description="登录是否成功")
message: str = Field(..., description="登录消息")
user_id: Optional[int] = Field(None, description="用户 ID")
authorization: Optional[str] = Field(None, description="Token")
alias: Optional[str] = Field(None, description="用户别名")
+58
View File
@@ -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="每页记录数")
+28
View File
@@ -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
+148
View File
@@ -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
+148
View File
@@ -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="字段配置(用于前端渲染表单)")
+64
View File
@@ -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分钟内)
+109
View File
@@ -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)
+127
View File
@@ -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)
+85
View File
@@ -0,0 +1,85 @@
import logging
from datetime import datetime, timedelta
from typing import List, Dict, Any
from sqlalchemy.orm import Session
from backend.models import User
logger = logging.getLogger(__name__)
class AdminService:
"""管理员服务"""
@staticmethod
def get_pending_users(db: Session) -> List[User]:
"""获取待审批用户列表"""
users = db.query(User).filter(
User.is_approved == False,
User.role == "user"
).order_by(User.created_at.desc()).all()
return users
@staticmethod
def approve_user(user_id: int, db: Session) -> Dict[str, Any]:
"""审批通过用户"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return {"success": False, "message": "用户不存在"}
if user.is_approved:
return {"success": False, "message": "用户已经通过审批"}
user.is_approved = True
user.updated_at = datetime.now()
db.commit()
logger.info(f"管理员审批通过用户: {user.alias} (ID: {user.id})")
return {
"success": True,
"message": "审批成功",
"user_id": user.id
}
@staticmethod
def reject_user(user_id: int, db: Session) -> Dict[str, Any]:
"""拒绝并删除用户"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return {"success": False, "message": "用户不存在"}
alias = user.alias
db.delete(user)
db.commit()
logger.info(f"管理员拒绝用户: {alias} (ID: {user_id})")
return {
"success": True,
"message": "已拒绝并删除用户"
}
@staticmethod
def delete_expired_pending_users(db: Session) -> int:
"""删除24小时未审批的用户"""
cutoff_time = datetime.now() - timedelta(hours=24)
expired_users = db.query(User).filter(
User.is_approved == False,
User.role == "user",
User.created_at < cutoff_time
).all()
count = len(expired_users)
for user in expired_users:
logger.info(f"删除过期未审批用户: {user.alias} (ID: {user.id})")
db.delete(user)
db.commit()
return count
+628
View File
@@ -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:
# 清洗 TokenURL 解码 + 去除 Bearer 前缀(参考 v1 实现)
pure_token = unquote(token) # URL 解码
if pure_token.lower().startswith('bearer '):
pure_token = pure_token[7:] # 去除 "Bearer " 前缀
decoded = jwt.decode(pure_token, options={"verify_signature": False})
jwt_exp = str(decoded.get("exp", 0))
jwt_sub = decoded.get("sub", "")
logger.info(f"成功解析 JWT for sub={jwt_sub}, exp={jwt_exp}")
except Exception as e:
logger.error(f"解析 JWT Token 失败: {e}")
return {
"status": "error",
"message": f"Token 解析失败: {str(e)}"
}
# 查找用户(通过 jwt_sub
user = db.query(User).filter(User.jwt_sub == jwt_sub).first()
if user:
# 已注册用户:更新 Token(存储清理后的 token
# 注意:如果通过别名登录,需要验证 jwt_sub 是否匹配
if alias and alias == user.alias:
# 用户使用别名登录,验证 jwt_sub 是否一致
# 如果用户之前的 jwt_sub 不为空且与当前不一致,说明QQ号被换绑了
existing_jwt_sub = getattr(user, 'jwt_sub', '')
if isinstance(existing_jwt_sub, str) and existing_jwt_sub.strip() and existing_jwt_sub != jwt_sub:
logger.warning(f"⚠️ 用户 {user.alias} 的 jwt_sub 不匹配!数据库: {existing_jwt_sub}, 当前: {jwt_sub}")
return {
"status": "error",
"message": "QQ账号不匹配,请使用正确的QQ号扫码登录"
}
user.authorization = pure_token # 存储清理后的 token
user.jwt_exp = jwt_exp
user.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": "取消失败或会话不存在"
}
+564
View File
@@ -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:
"""
为打卡记录添加用户和任务信息
注意:如果使用了 joinedloadtask 和 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
+795
View File
@@ -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()
+386
View File
@@ -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 即将过期邮件发送失败")
# 情况2Token 已过期
# 修改逻辑:只要过期就发送提醒(不限制在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 已过期邮件发送失败")
# 情况3Token 正常(剩余时间 > 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}")
+376
View File
@@ -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)}")
+577
View File
@@ -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)}"
)
+310
View File
@@ -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()
+123
View File
@@ -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
+103
View File
@@ -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")}'
}
+127
View File
@@ -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
+148
View File
@@ -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
+317
View File
@@ -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)
}
+119
View File
@@ -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
+332
View File
@@ -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}")
+2
View File
@@ -0,0 +1,2 @@
# API Base URL (Development)
VITE_API_BASE_URL=http://localhost:8000
+3
View File
@@ -0,0 +1,3 @@
# API Base URL (Production)
# 留空,让 API 请求使用相对路径(由 Nginx 转发)
VITE_API_BASE_URL=
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
*.local
+9
View File
@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"arrowParens": "avoid",
"endOfLine": "auto"
}
+38
View File
@@ -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',
},
},
];
+14
View File
@@ -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>
+4314
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+20
View File
@@ -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

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+85
View File
@@ -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>
+248
View File
@@ -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] : [],
};
}
+75
View File
@@ -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;
+258
View File
@@ -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 新增
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

@@ -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>
+41
View File
@@ -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>
+473
View File
@@ -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,
};
}
+106
View File
@@ -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,
};
}
+53
View File
@@ -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');
+162
View File
@@ -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;
+62
View File
@@ -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;
}
},
},
});
+133
View File
@@ -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();
},
},
});
+96
View File
@@ -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 || '获取统计信息失败');
}
},
},
});
+164
View File
@@ -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;
},
},
});
+169
View File
@@ -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;
},
},
});
+94
View File
@@ -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
+145
View File
@@ -0,0 +1,145 @@
/**
* 格式化日期时间
* @param {string|Date} date - 日期
* @param {boolean} includeTime - 是否包含时间
* @returns {string}
*/
export function formatDateTime(date, includeTime = true) {
if (!date) return '-';
const d = new Date(date);
if (isNaN(d.getTime())) return '-';
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
if (!includeTime) {
return `${year}-${month}-${day}`;
}
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
const seconds = String(d.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* 格式化相对时间(多久之前)
* @param {string|Date} date - 日期
* @returns {string}
*/
export function formatRelativeTime(date) {
if (!date) return '-';
const d = new Date(date);
if (isNaN(d.getTime())) return '-';
const now = new Date();
const diff = now - d; // 毫秒差
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return '刚刚';
if (minutes < 60) return `${minutes} 分钟前`;
if (hours < 24) return `${hours} 小时前`;
if (days < 7) return `${days} 天前`;
return formatDateTime(date, false);
}
/**
* 格式化文件大小
* @param {number} bytes - 字节数
* @returns {string}
*/
export function formatFileSize(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
}
/**
* 防抖函数
* @param {Function} fn - 要防抖的函数
* @param {number} delay - 延迟时间(毫秒)
* @returns {Function}
*/
export function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
/**
* 节流函数
* @param {Function} fn - 要节流的函数
* @param {number} delay - 延迟时间(毫秒)
* @returns {Function}
*/
export function throttle(fn, delay = 300) {
let timer = null;
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime < delay) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
lastTime = now;
fn.apply(this, args);
}, delay);
} else {
lastTime = now;
fn.apply(this, args);
}
};
}
/**
* 复制文本到剪贴板
* @param {string} text - 要复制的文本
* @returns {Promise<boolean>}
*/
export async function copyToClipboard(text) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
} else {
// 降级方案
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
textArea.remove();
return true;
} catch (error) {
console.error('复制失败', error);
textArea.remove();
return false;
}
}
} catch (error) {
console.error('复制失败', error);
return false;
}
}
+547
View File
@@ -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>
+524
View File
@@ -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>
+30
View File
@@ -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>
+235
View File
@@ -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>
+281
View File
@@ -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>
+421
View File
@@ -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>
+838
View File
@@ -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>
+134
View File
@@ -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>
+181
View File
@@ -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>
+607
View File
@@ -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>
+98
View File
@@ -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: [],
};
+45
View File
@@ -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';
}
},
},
},
},
});