feat: migrate from Element Plus to Ant Design Vue and update Vite configuration for better dependency management

- Updated Vite configuration to manually chunk Ant Design Vue for improved dependency management.
- Added a comprehensive migration testing checklist for transitioning from Element Plus 2.13.0 to Ant Design Vue 4.x, covering various components and functionalities.
This commit is contained in:
2026-01-03 01:38:38 +08:00
parent 42a1046750
commit 827c9198ae
57 changed files with 5517 additions and 2982 deletions
+3
View File
@@ -9,6 +9,9 @@
# CORS 允许的前端域名(逗号分隔,生产环境必须修改)
CORS_ORIGINS=http://localhost:3000
# 前端 URL 配置(用于邮件中的链接)
FRONTEND_URL=http://localhost:3000
# 日志级别(可选:DEBUG, INFO, WARNING, ERROR
LOG_LEVEL=INFO
+1 -1
View File
@@ -670,7 +670,7 @@ CheckInApp/
- `GET /api/check_in/records/count` - 记录统计
### 管理员 (`/api/admin`)
- `POST /api/admin/batch_toggle_active` - 批量启用/禁用
- `POST /api/admin/batch_check_in` - 批量打卡
- `GET /api/admin/logs` - 系统日志
- `GET /api/admin/stats` - 系统统计
-1
View File
@@ -121,7 +121,6 @@ backend/
### 管理员 API (`/api/admin`)
- `POST /api/admin/batch_toggle_active` - 批量启用/禁用用户
- `POST /api/admin/batch_check_in` - 批量触发打卡
- `GET /api/admin/logs` - 获取系统日志
- `GET /api/admin/stats` - 获取系统统计
+21
View File
@@ -100,6 +100,27 @@ async def get_qrcode_status(
)
@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="验证 Token 有效性")
async def verify_token(
request: TokenVerifyRequest,
+8 -2
View File
@@ -2,6 +2,7 @@ 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 TaskCreate, TaskUpdate, TaskResponse
@@ -11,6 +12,11 @@ from backend.dependencies import get_current_user
router = APIRouter()
class CronValidateRequest(BaseModel):
"""Cron 表达式验证请求"""
cron_expression: str = Field(..., min_length=9, description="Crontab 表达式")
@router.post("/", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, summary="创建打卡任务")
async def create_task(
task_data: TaskCreate,
@@ -181,7 +187,7 @@ async def toggle_task(
@router.post("/validate-cron", summary="验证 Crontab 表达式")
async def validate_cron_expression(request: dict):
async def validate_cron_expression(request: CronValidateRequest):
"""
验证 Crontab 表达式并预览下一个执行时间
@@ -199,7 +205,7 @@ async def validate_cron_expression(request: dict):
"description": "每天 20:00"
}
"""
cron_expr = request.get('cron_expression', '').strip()
cron_expr = request.cron_expression.strip()
if not cron_expr:
raise HTTPException(
+35 -2
View File
@@ -138,8 +138,11 @@ async def get_current_user_token_status(
minutes_until_expiry = (exp_timestamp - current_timestamp) // 60
expiring_soon = minutes_until_expiry <= 30
except ValueError:
pass
except ValueError as e:
# jwt_exp 格式不正确,记录警告
import logging
logger = logging.getLogger(__name__)
logger.warning(f"用户 {current_user.id} ({current_user.alias}) 的 jwt_exp 格式不正确: {current_user.jwt_exp}, 错误: {e}")
return {
"is_valid": is_valid,
@@ -256,7 +259,37 @@ async def update_user(
)
try:
# 获取更新前的用户状态
old_user = UserService.get_user_by_id(user_id, db)
if not old_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=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(
+3
View File
@@ -51,6 +51,9 @@ class Settings(BaseSettings):
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 # 会话清理间隔(小时)
+52 -4
View File
@@ -1,9 +1,12 @@
from datetime import datetime
from typing import Optional
import logging
from fastapi import Depends, HTTPException, Header, status
from sqlalchemy.orm import Session
from backend.models import get_db, User
logger = logging.getLogger(__name__)
async def get_current_user(
authorization: Optional[str] = Header(None),
@@ -11,7 +14,9 @@ async def get_current_user(
) -> User:
"""
获取当前用户
从 Authorization header 中验证 Token 并返回用户
支持两种认证方式:
1. Token 认证(QQ 扫码登录)
2. User ID 认证(密码登录,格式:user_id:xxx
"""
if not authorization:
raise HTTPException(
@@ -23,6 +28,40 @@ async def get_current_user(
# 移除 "Bearer " 前缀(如果存在)
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
# 检查是否为 user_id 格式的认证(用于密码登录)
if token.startswith("user_id:"):
user_id_str = token.replace("user_id:", "")
try:
user_id = int(user_id_str)
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"},
)
# 用户ID认证成功,检查是否设置了密码
has_password = bool(user.password_hash)
if not has_password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="该账户未设置密码,请使用扫码登录",
headers={"WWW-Authenticate": "Bearer"},
)
# 密码登录的用户可以访问,无需检查 Token
return user
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的用户ID格式",
headers={"WWW-Authenticate": "Bearer"},
)
# Token 认证(原有逻辑)
# 从数据库查询用户
user = db.query(User).filter(User.authorization == token).first()
@@ -39,13 +78,22 @@ async def get_current_user(
exp_timestamp = int(user.jwt_exp)
current_timestamp = int(datetime.now().timestamp())
if current_timestamp > exp_timestamp:
# 如果用户设置了密码,允许继续使用(Token 过期但不强制退出)
has_password = bool(user.password_hash)
if has_password:
# Token 过期但有密码,允许访问,但在响应头中添加警告
# 注意:这里不抛出异常,让用户继续使用
pass
else:
# 没有密码的用户,Token 过期必须重新扫码登录
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 已过期,请重新登录",
detail="Token 已过期,请重新扫码登录",
headers={"WWW-Authenticate": "Bearer"},
)
except ValueError:
pass # jwt_exp 格式不正确,跳过验证
except ValueError as e:
# jwt_exp 格式不正确,记录警告后跳过 Token 过期验证
logger.warning(f"用户 {user.id} ({user.alias}) 的 jwt_exp 格式不正确: {user.jwt_exp}, 错误: {e}")
return user
-1
View File
@@ -18,7 +18,6 @@ class User(Base):
jwt_exp = Column(String(20), default="0", comment="Token 过期时间戳")
role = Column(String(20), default="user", index=True, comment="角色: user/admin")
is_approved = Column(Boolean, default=False, index=True, comment="是否已通过管理员审批")
registered_ip = Column(String(50), nullable=True, comment="注册时的 IP 地址")
created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), comment="更新时间")
+3 -2
View File
@@ -12,6 +12,7 @@ class UserCreate(UserBase):
"""创建用户 Schema(管理员手动创建,只需要别名)"""
role: Optional[str] = Field("user", description="角色: user/admin")
email: Optional[str] = Field(None, description="邮箱地址")
is_approved: Optional[bool] = Field(True, description="是否已审批(默认已审批)")
class UserUpdate(BaseModel):
@@ -36,7 +37,7 @@ class UserResponse(BaseModel):
"""用户响应 Schema"""
id: int
alias: str
jwt_sub: str
jwt_sub: Optional[str] = None
role: str
is_approved: bool
jwt_exp: str
@@ -58,7 +59,7 @@ class TokenStatus(BaseModel):
"""Token 状态 Schema"""
is_valid: bool
jwt_exp: str
jwt_sub: str
jwt_sub: Optional[str] = None
expires_at: Optional[int] = None # Unix 时间戳(秒)
days_until_expiry: Optional[int] = None
expiring_soon: bool = False # 是否即将过期(30分钟内)
@@ -1,72 +0,0 @@
"""
数据库迁移脚本:为 task_templates 表添加 parent_id 字段
运行方法:
python backend/scripts/migrate_add_parent_id_to_templates.py
"""
import sys
import os
from pathlib import Path
# 设置 UTF-8 编码输出(Windows 兼容)
if sys.platform == "win32":
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
# 添加项目根目录到 Python 路径
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from sqlalchemy import text
from backend.models.database import engine, SessionLocal
def migrate():
"""为 task_templates 表添加 parent_id 字段"""
print("=" * 60)
print("开始数据库迁移:添加 parent_id 字段到 task_templates 表")
print("=" * 60)
db = SessionLocal()
try:
# 检查字段是否已存在
result = db.execute(text(
"SELECT COUNT(*) FROM pragma_table_info('task_templates') WHERE name='parent_id'"
))
field_exists = result.fetchone()[0] > 0
if field_exists:
print("⚠️ parent_id 字段已存在,跳过迁移")
return
# 添加 parent_id 字段
print("📝 正在添加 parent_id 字段...")
db.execute(text(
"ALTER TABLE task_templates ADD COLUMN parent_id INTEGER"
))
db.commit()
print("✅ parent_id 字段添加成功")
# 创建外键约束(SQLite 不支持直接添加外键,需要重建表)
print("\n📝 注意:SQLite 不支持直接添加外键约束")
print(" 如需外键约束,请重建表或在下次完整迁移时处理")
print("\n" + "=" * 60)
print("✅ 数据库迁移完成!")
print("=" * 60)
except Exception as e:
print(f"\n❌ 迁移失败: {str(e)}")
db.rollback()
import traceback
traceback.print_exc()
sys.exit(1)
finally:
db.close()
if __name__ == "__main__":
migrate()
@@ -1,57 +0,0 @@
"""
添加 payload_config 字段到 check_in_tasks 表的迁移脚本
运行方式:
python backend/scripts/migrate_add_payload_config.py
.venv/Scripts/python.exe backend/scripts/migrate_add_payload_config.py
"""
import sys
import os
from pathlib import Path
# 添加项目根目录到 Python 路径
project_root = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(project_root))
from sqlalchemy import text
from backend.models.database import engine
def migrate():
"""执行迁移"""
print("开始迁移:添加 payload_config 字段...")
with engine.connect() as conn:
# 检查字段是否已存在
result = conn.execute(text("PRAGMA table_info(check_in_tasks)"))
columns = [row[1] for row in result]
if 'payload_config' in columns:
print("[OK] payload_config 字段已存在,跳过迁移")
return
# 添加 payload_config 字段(JSON 文本,存储完整的 payload 配置)
print("添加 payload_config 字段...")
conn.execute(text("""
ALTER TABLE check_in_tasks
ADD COLUMN payload_config TEXT DEFAULT '{}' NOT NULL
"""))
conn.commit()
print("[OK] payload_config 字段添加成功")
print("\n注意:现有任务的 payload_config 默认为空 JSON {}")
print(" Worker 将使用默认的固定字段值。")
print(" 新创建的任务将从模板继承完整的 payload 配置。")
if __name__ == "__main__":
try:
migrate()
print("\n[SUCCESS] 迁移完成!")
except Exception as e:
print(f"\n[ERROR] 迁移失败: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
@@ -1,132 +0,0 @@
"""
删除 check_in_tasks 表中不再需要的旧列的迁移脚本
删除的列:
- signature (VARCHAR) - 已在 payload_config 中
- texts (VARCHAR) - 已在 payload_config 中
- values (TEXT) - 已在 payload_config 中
- thread_id (VARCHAR) - 已在 payload_config 的 ThreadId 中
- email (VARCHAR) - 从 user 表的 email 字段获取
新架构只保留:
- id, user_id, payload_config, name, is_active, created_at, updated_at
运行方式:
python backend/scripts/migrate_remove_old_columns.py
venv/Scripts/python.exe backend/scripts/migrate_remove_old_columns.py
"""
import sys
import os
from pathlib import Path
# 添加项目根目录到 Python 路径
project_root = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(project_root))
from sqlalchemy import text, inspect
from backend.models.database import engine
def migrate():
"""执行迁移:删除旧列"""
print("开始迁移:删除 check_in_tasks 表中的旧列...")
print("将删除的列: signature, texts, values, thread_id, email")
print("=" * 60)
with engine.connect() as conn:
# 检查表结构
inspector = inspect(engine)
columns = [col['name'] for col in inspector.get_columns('check_in_tasks')]
print(f"\n当前表列: {', '.join(columns)}")
old_columns = ['signature', 'texts', 'values', 'thread_id', 'email']
columns_to_remove = [col for col in old_columns if col in columns]
if not columns_to_remove:
print("\n[OK] 旧列已被删除,跳过迁移")
return
print(f"\n需要删除的列: {', '.join(columns_to_remove)}")
# SQLite 不支持直接 DROP COLUMN,需要重建表
# 步骤:
# 1. 创建新表(只包含需要的列)
# 2. 复制数据
# 3. 删除旧表
# 4. 重命名新表
print("\n正在重建表结构...")
# 1. 创建新表
conn.execute(text("""
CREATE TABLE check_in_tasks_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
payload_config TEXT NOT NULL DEFAULT '{}',
name VARCHAR(100) DEFAULT '',
is_active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""))
print(" [OK] 创建新表结构")
# 2. 复制数据(只复制保留的列)
conn.execute(text("""
INSERT INTO check_in_tasks_new
(id, user_id, payload_config, name, is_active, created_at, updated_at)
SELECT
id, user_id, payload_config, name, is_active, created_at, updated_at
FROM check_in_tasks
"""))
print(" [OK] 复制数据到新表")
# 3. 删除旧表
conn.execute(text("DROP TABLE check_in_tasks"))
print(" [OK] 删除旧表")
# 4. 重命名新表
conn.execute(text("ALTER TABLE check_in_tasks_new RENAME TO check_in_tasks"))
print(" [OK] 重命名新表")
# 5. 重建索引
conn.execute(text("""
CREATE INDEX ix_check_in_tasks_user_id ON check_in_tasks(user_id)
"""))
conn.execute(text("""
CREATE INDEX ix_check_in_tasks_id ON check_in_tasks(id)
"""))
conn.execute(text("""
CREATE INDEX ix_task_user_active ON check_in_tasks(user_id, is_active)
"""))
print(" [OK] 重建索引")
conn.commit()
print("\n[SUCCESS] 表结构迁移成功!")
print("\n新的表结构:")
inspector = inspect(engine)
new_columns = [col['name'] for col in inspector.get_columns('check_in_tasks')]
print(f" 列: {', '.join(new_columns)}")
if __name__ == "__main__":
try:
migrate()
print("\n" + "=" * 60)
print("[完成] 迁移成功完成!")
print("\n数据库已更新为新架构:")
print(" - 删除了 signature, texts, values, thread_id, email 列")
print(" - 保留了 payload_config 列(存储完整的 JSON payload")
print(" - ThreadId 现在存储在 payload_config 中")
print(" - Email 现在从 user 表获取")
print("=" * 60)
except Exception as e:
print(f"\n[ERROR] 迁移失败: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
@@ -0,0 +1,135 @@
"""
删除 users 表中 registered_ip 列的迁移脚本
删除的列:
- registered_ip (VARCHAR) - 注册IP地址,不再需要
新架构中移除该字段以保护用户隐私。
运行方式:
python backend/scripts/migrate_remove_registered_ip.py
venv/Scripts/python.exe backend/scripts/migrate_remove_registered_ip.py
"""
import sys
import os
from pathlib import Path
# 添加项目根目录到 Python 路径
project_root = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(project_root))
from sqlalchemy import text, inspect
from backend.models.database import engine
def migrate():
"""执行迁移:删除 registered_ip 列"""
print("开始迁移:删除 users 表中的 registered_ip 列...")
print("=" * 60)
with engine.connect() as conn:
# 检查表结构
inspector = inspect(engine)
columns = [col['name'] for col in inspector.get_columns('users')]
print(f"\n当前表列: {', '.join(columns)}")
if 'registered_ip' not in columns:
print("\n[OK] registered_ip 列已被删除,跳过迁移")
return
print(f"\n需要删除的列: registered_ip")
# SQLite 不支持直接 DROP COLUMN,需要重建表
# 步骤:
# 1. 创建新表(不包含 registered_ip
# 2. 复制数据
# 3. 删除旧表
# 4. 重命名新表
print("\n正在重建表结构...")
# 1. 创建新表
conn.execute(text("""
CREATE TABLE users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jwt_sub VARCHAR(200) UNIQUE,
alias VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100),
password_hash VARCHAR(200),
authorization TEXT,
jwt_exp VARCHAR(20) DEFAULT '0',
role VARCHAR(20) DEFAULT 'user',
is_approved BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME
)
"""))
print(" [OK] 创建新表结构")
# 2. 复制数据(不包含 registered_ip
conn.execute(text("""
INSERT INTO users_new
(id, jwt_sub, alias, email, password_hash, authorization, jwt_exp,
role, is_approved, created_at, updated_at)
SELECT
id, jwt_sub, alias, email, password_hash, authorization, jwt_exp,
role, is_approved, created_at, updated_at
FROM users
"""))
print(" [OK] 复制数据到新表")
# 3. 删除旧表
conn.execute(text("DROP TABLE users"))
print(" [OK] 删除旧表")
# 4. 重命名新表
conn.execute(text("ALTER TABLE users_new RENAME TO users"))
print(" [OK] 重命名新表")
# 5. 重建索引
conn.execute(text("""
CREATE INDEX ix_users_jwt_sub ON users(jwt_sub)
"""))
conn.execute(text("""
CREATE INDEX ix_users_alias ON users(alias)
"""))
conn.execute(text("""
CREATE INDEX ix_users_role ON users(role)
"""))
conn.execute(text("""
CREATE INDEX ix_users_is_approved ON users(is_approved)
"""))
conn.execute(text("""
CREATE INDEX ix_users_id ON users(id)
"""))
conn.execute(text("""
CREATE INDEX ix_user_role_approved ON users(role, is_approved)
"""))
print(" [OK] 重建索引")
conn.commit()
print("\n[SUCCESS] 表结构迁移成功!")
print("\n新的表结构:")
inspector = inspect(engine)
new_columns = [col['name'] for col in inspector.get_columns('users')]
print(f" 列: {', '.join(new_columns)}")
if __name__ == "__main__":
try:
migrate()
print("\n" + "=" * 60)
print("[完成] 迁移成功完成!")
print("\n数据库已更新为新架构:")
print(" - 删除了 registered_ip 列(保护用户隐私)")
print(" - 保留了所有其他用户数据")
print("=" * 60)
except Exception as e:
print(f"\n[ERROR] 迁移失败: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
+33 -7
View File
@@ -25,7 +25,7 @@ class AuthService:
Args:
alias: 用户别名
client_ip: 客户端 IP 地址
client_ip: 客户端 IP 地址(用于会话标识)
db: 数据库会话
Returns:
@@ -42,11 +42,11 @@ class AuthService:
if existing_user:
# 检查是否为空 jwt_sub(测试账号)
if not existing_user.jwt_sub or existing_user.jwt_sub == "":
logger.warning(f"用户 {alias} 是测试账号(空 jwt_sub),禁止登录")
if not existing_user.jwt_sub:
logger.warning(f"用户 {alias} 是测试账号(未绑定 QQ),禁止扫码登录")
return {
"status": "error",
"message": "此账户为测试账号,暂未绑定 QQ,无法登录"
"message": "此账户为测试账号,暂未绑定 QQ,无法扫码登录"
}
# 老用户:刷新 Token
@@ -243,7 +243,6 @@ class AuthService:
}
# 创建新用户(待审批状态)
client_ip = session_data.get("client_ip", "")
new_user = User(
jwt_sub=jwt_sub,
alias=alias,
@@ -251,7 +250,6 @@ class AuthService:
jwt_exp=jwt_exp,
role="user",
is_approved=False, # 待审批
registered_ip=client_ip
)
db.add(new_user)
@@ -427,7 +425,9 @@ class AuthService:
"message": "登录成功",
"user_id": user.id,
"authorization": user.authorization,
"alias": user.alias
"alias": user.alias,
"role": user.role,
"is_approved": user.is_approved
}
# 如果 Token 有问题,添加警告信息
@@ -475,3 +475,29 @@ class AuthService:
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": "取消失败或会话不存在"
}
+27 -13
View File
@@ -173,8 +173,9 @@ class CheckInService:
"status": "failure",
"message": f"{error_msg},请重新扫码登录"
}
except ValueError:
pass
except ValueError as e:
# jwt_exp 格式不正确,记录警告后跳过 Token 过期验证
logger.warning(f"任务 {task.id} 的用户 jwt_exp 格式不正确: {user.jwt_exp}, 错误: {e}")
# 创建待处理记录
record_id = CheckInService.create_pending_check_in_record(task, trigger_type, db)
@@ -264,8 +265,9 @@ class CheckInService:
"message": f"{error_msg},请重新扫码登录",
"record_id": record.id
}
except ValueError:
pass
except ValueError as e:
# jwt_exp 格式不正确,记录警告后跳过 Token 过期验证
logger.warning(f"任务 {task.id} 的用户 jwt_exp 格式不正确: {user.jwt_exp}, 错误: {e}")
# 执行打卡(传递 task 对象和用户 token)
logger.info(f"🤖 调用 Selenium Worker 执行打卡...")
@@ -409,8 +411,9 @@ class CheckInService:
logger.warning(f"任务 ID: {task.id} 的用户 Token 已过期,跳过")
results["skipped"] += 1
continue
except ValueError:
pass
except ValueError as e:
# jwt_exp 格式不正确,记录警告后继续执行打卡
logger.warning(f"任务 {task.id} 的用户 jwt_exp 格式不正确: {task.user.jwt_exp}, 错误: {e}")
# 执行打卡
result = CheckInService.perform_task_check_in(task, "scheduled", db)
@@ -514,7 +517,7 @@ class CheckInService:
status: Optional[str] = None
) -> List[CheckInRecord]:
"""
获取所有打卡记录(管理员)
获取所有打卡记录(管理员)- 使用联表查询优化性能
Args:
db: 数据库会话
@@ -526,7 +529,12 @@ class CheckInService:
Returns:
打卡记录列表
"""
query = db.query(CheckInRecord)
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)
@@ -543,15 +551,18 @@ class CheckInService:
"""
为打卡记录添加用户和任务信息
注意:如果使用了 joinedloadtask 和 user 已经预加载,不会产生额外查询
Args:
record: 打卡记录对象
db: 数据库会话
db: 数据库会话(可选,仅在未使用 joinedload 时使用)
Returns:
包含额外信息的记录字典
"""
# 获取任务信息
task = db.query(CheckInTask).filter(CheckInTask.id == record.task_id).first()
# 尝试使用已加载的关联对象,如果没有则查询
task = record.task if hasattr(record, 'task') and record.task else \
db.query(CheckInTask).filter(CheckInTask.id == record.task_id).first()
# 获取用户信息
user = None
@@ -559,14 +570,17 @@ class CheckInService:
thread_id = None
if task:
user = db.query(User).filter(User.id == task.user_id).first()
# 尝试使用已加载的 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
try:
payload = json.loads(str(task.payload_config))
thread_id = payload.get('ThreadId')
except:
except (json.JSONDecodeError, KeyError, TypeError, AttributeError) as e:
logger.debug(f"解析任务 {task.id} 的 payload_config 失败: {e}")
pass
# 转换为字典并添加额外字段
+392 -49
View File
@@ -1,24 +1,33 @@
import smtplib
"""
邮件业务服务 (高级)
职能:提供业务相关的邮件操作
- 新用户注册通知
- 用户审批通知
- 打卡结果通知
- Token 到期提醒
- 调用底层 EmailNotifier 发送邮件
"""
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import List
from datetime import datetime
from typing import List
from sqlalchemy.orm import Session
from backend.config import settings
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: 收件人邮箱列表
@@ -28,39 +37,7 @@ class EmailService:
Returns:
是否发送成功
"""
# 检查邮件配置
if not all([settings.SMTP_SERVER, settings.SMTP_SENDER_EMAIL, settings.SMTP_SENDER_PASSWORD]):
logger.warning("邮件配置不完整,跳过发送邮件")
return False
try:
# 创建邮件
msg = MIMEMultipart('alternative')
msg['From'] = settings.SMTP_SENDER_EMAIL
msg['To'] = ', '.join(to_emails)
msg['Subject'] = subject
# 添加 HTML 正文
html_part = MIMEText(body_html, 'html', 'utf-8')
msg.attach(html_part)
# 连接 SMTP 服务器并发送
if settings.SMTP_USE_SSL:
server = smtplib.SMTP_SSL(settings.SMTP_SERVER, settings.SMTP_PORT)
else:
server = smtplib.SMTP(settings.SMTP_SERVER, settings.SMTP_PORT)
server.starttls()
server.login(settings.SMTP_SENDER_EMAIL, settings.SMTP_SENDER_PASSWORD)
server.sendmail(settings.SMTP_SENDER_EMAIL, to_emails, msg.as_string())
server.quit()
logger.info(f"邮件发送成功: {subject} -> {', '.join(to_emails)}")
return True
except Exception as e:
logger.error(f"邮件发送失败: {e}")
return False
return EmailNotifier.send_email(to_emails, subject, body_html)
@staticmethod
def notify_new_user_registration(user: User, db: Session) -> bool:
@@ -76,7 +53,12 @@ class EmailService:
"""
# 查询所有管理员邮箱
admins = db.query(User).filter(User.role == "admin", User.email.isnot(None)).all()
admin_emails = [admin.email for admin in admins if admin.email]
# 使用 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("没有找到管理员邮箱,无法发送通知")
@@ -85,6 +67,10 @@ class EmailService:
# 构建邮件内容
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>
@@ -161,11 +147,7 @@ class EmailService:
</tr>
<tr>
<td>注册时间</td>
<td>{user.created_at.strftime('%Y-%m-%d %H:%M:%S') if user.created_at else '未知'}</td>
</tr>
<tr>
<td>注册 IP</td>
<td>{user.registered_ip or '未记录'}</td>
<td>{created_time}</td>
</tr>
</table>
@@ -175,7 +157,7 @@ class EmailService:
<p>请登录管理后台进行审批操作。</p>
</div>
<p>登录地址:<a href="http://localhost:5173/admin/users">http://localhost:5173/admin/users</a></p>
<p>登录地址:<a href="{settings.FRONTEND_URL}/admin/users">{settings.FRONTEND_URL}/admin/users</a></p>
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
@@ -188,6 +170,366 @@ class EmailService:
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
# 计算剩余时间
try:
exp_timestamp = int(jwt_exp)
current_timestamp = int(datetime.now().timestamp())
minutes_left = (exp_timestamp - current_timestamp) // 60
except ValueError:
minutes_left = 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>在个人设置中点击"刷新凭证"</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:
"""
@@ -202,7 +544,8 @@ class EmailService:
Returns:
是否发送成功
"""
if not user.email:
user_email = user.email
if user_email is None:
logger.info(f"用户 {user.alias} 未设置邮箱,跳过打卡通知")
return False
@@ -298,4 +641,4 @@ class EmailService:
</html>
"""
return EmailService.send_email([user.email], subject, body_html)
return EmailService.send_email([str(user_email)], subject, body_html)
+4 -2
View File
@@ -13,7 +13,6 @@ from backend.config import settings
from backend.models import get_db, User, CheckInTask
from backend.services.check_in_service import CheckInService
from backend.services.admin_service import AdminService
from backend.workers.email_notifier import send_expiration_notification
logger = logging.getLogger(__name__)
@@ -182,7 +181,10 @@ def check_token_expiration():
# 使用用户账户的邮箱发送通知
if user.email:
logger.info(f"用户 {user.alias} 的 Token 即将过期,发送邮件提醒到 {user.email}...")
send_expiration_notification(user.email, user.jwt_exp)
from backend.services.email_service import EmailService
jwt_exp_value = user.jwt_exp
jwt_exp_str = str(jwt_exp_value) if jwt_exp_value is not None else "0"
EmailService.notify_token_expiring(user, jwt_exp_str)
notified_count += 1
except ValueError:
+7 -8
View File
@@ -32,10 +32,10 @@ class UserService:
# 创建用户(管理员创建的用户没有 jwt_sub,需要后续扫码绑定)
user = User(
jwt_sub="", # 空字符串表示未绑定 QQ
jwt_sub=None, # NULL 表示未绑定 QQ
alias=user_data.alias,
role=user_data.role or "user",
is_approved=True, # 管理员创建的用户默认已审批
is_approved=user_data.is_approved if user_data.is_approved is not None else True, # 使用请求中的值,默认已审批
jwt_exp="0",
authorization=None,
)
@@ -114,12 +114,11 @@ class UserService:
# 搜索过滤
if search:
query = query.filter(
or_(
User.alias.ilike(f"%{search}%"),
User.jwt_sub.ilike(f"%{search}%")
)
)
# 注意:jwt_sub 可能为 NULL,需要处理
search_conditions = [User.alias.ilike(f"%{search}%")]
# 只有当 jwt_sub 不为空时才搜索
search_conditions.append(User.jwt_sub.ilike(f"%{search}%"))
query = query.filter(or_(*search_conditions))
# 角色过滤
if role:
+35 -9
View File
@@ -9,7 +9,6 @@ from selenium.webdriver.chrome.options import Options
from typing import Dict, Any
from backend.config import settings
from backend.workers.email_notifier import send_success_notification, send_failure_notification
logger = logging.getLogger(__name__)
@@ -99,11 +98,20 @@ def get_live_x_api_payload(auth_token: str) -> str:
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
@@ -127,7 +135,8 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
try:
payload_dict = json.loads(task.payload_config) if task.payload_config else {}
signature = payload_dict.get('Signature', 'Unknown')
except:
except (json.JSONDecodeError, KeyError, TypeError, AttributeError) as e:
logger.debug(f"解析任务 {task.id} 的 payload_config 失败: {e}")
signature = 'Unknown'
logger.info(f"Selenium打卡: 正在为任务 ID: {task.id} (Signature: {signature}) 执行打卡...")
@@ -196,14 +205,21 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
logger.info(f"✉️ 任务 ID: {task.id} (Signature: {signature}) 打卡请求完成!响应: {response_text}")
# 判断响应内容(参考 V1 实现逻辑)
# 使用用户账户的邮箱,而不是任务的邮箱
email = task.user.email if task.user else None
# 情况1: 明确包含"打卡成功" → 成功
if "打卡成功" in response_text:
logger.info(f"✅ 检测到成功关键字 '打卡成功',打卡成功")
if email:
send_success_notification(email)
# 发送成功邮件通知
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",
@@ -238,8 +254,18 @@ def perform_check_in(task, user_token: str) -> Dict[str, Any]:
# 情况4: Token 失效的特征标识 → 失败
elif ("登录" in response_text):
logger.warning(f"⚠️ 检测到登录失败关键字,Token 可能已失效")
if email:
send_failure_notification(email)
# 发送失败邮件通知
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, False, "Token 已失效,需要重新授权")
except Exception as e:
logger.error(f"发送打卡失败邮件失败: {e}")
return {
"success": False,
"status": "failure",
+64 -457
View File
@@ -1,366 +1,32 @@
"""
邮件发送引擎 (底层)
职能:提供基础的 SMTP 邮件发送功能
- SMTP 服务器连接
- 邮件发送
- 配置管理
- 不包含业务逻辑
"""
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import time
import logging
from typing import List, Optional
from backend.config import settings
logger = logging.getLogger(__name__)
# --- 邮件模板 ---
EXPIRATION_HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Token 到期提醒</title>
<style>
body {{
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background-color: #f5f5f5;
line-height: 1.6;
}}
.container {{
max-width: 600px;
margin: 40px auto;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
}}
.header {{
background: linear-gradient(135deg, #f44336 0%, #e91e63 100%);
color: white;
padding: 30px 20px;
text-align: center;
}}
.header h1 {{
margin: 0;
font-size: 24px;
font-weight: 600;
}}
.content {{
padding: 30px 40px;
}}
.alert-box {{
background-color: #fff3e0;
border-left: 4px solid #ff9800;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}}
.info-item {{
margin: 16px 0;
padding: 12px;
background-color: #f9f9f9;
border-radius: 6px;
}}
.info-item strong {{
color: #333;
display: inline-block;
min-width: 100px;
}}
.highlight {{
color: #f44336;
font-weight: 600;
}}
.action-button {{
display: inline-block;
margin: 20px 0;
padding: 12px 32px;
background: linear-gradient(135deg, #f44336 0%, #e91e63 100%);
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
}}
.footer {{
background-color: #fafafa;
padding: 20px;
text-align: center;
color: #999;
font-size: 12px;
border-top: 1px solid #eeeeee;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>⚠️ Token 即将到期提醒</h1>
</div>
<div class="content">
<p>您好,</p>
<div class="alert-box">
<p>您的接龙打卡系统 <span class="highlight">Token 即将到期</span>,为避免影响自动打卡功能,请尽快刷新您的 Token。</p>
</div>
<div class="info-item">
<strong>到期时间:</strong><span class="highlight">{exp_time}</span>
</div>
<div class="info-item">
<strong>通知时间:</strong>{send_time}
</div>
<p style="margin-top: 20px; color: #666;">
请登录系统,前往 <strong>用户设置</strong> 页面刷新您的 Token,以确保自动打卡功能正常运行。
</p>
</div>
<div class="footer">
<p>此邮件由接龙自动打卡系统自动发送,请勿直接回复</p>
<p>CheckIn App V2 © 2026</p>
</div>
</div>
</body>
</html>
"""
class EmailNotifier:
"""邮件发送引擎(底层服务)"""
SUCCESS_HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>打卡成功通知</title>
<style>
body {{
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background-color: #f5f5f5;
line-height: 1.6;
}}
.container {{
max-width: 600px;
margin: 40px auto;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
}}
.header {{
background: linear-gradient(135deg, #4caf50 0%, #66bb6a 100%);
color: white;
padding: 30px 20px;
text-align: center;
}}
.header h1 {{
margin: 0;
font-size: 24px;
font-weight: 600;
}}
.content {{
padding: 30px 40px;
}}
.success-icon {{
text-align: center;
font-size: 64px;
margin: 20px 0;
}}
.success-box {{
background-color: #e8f5e9;
border-left: 4px solid #4caf50;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}}
.info-item {{
margin: 16px 0;
padding: 12px;
background-color: #f9f9f9;
border-radius: 6px;
}}
.info-item strong {{
color: #333;
display: inline-block;
min-width: 100px;
}}
.highlight {{
color: #4caf50;
font-weight: 600;
}}
.footer {{
background-color: #fafafa;
padding: 20px;
text-align: center;
color: #999;
font-size: 12px;
border-top: 1px solid #eeeeee;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>✅ 打卡成功</h1>
</div>
<div class="content">
<div class="success-icon">🎉</div>
<p>您好,</p>
<div class="success-box">
<p><strong>自动打卡已成功完成!</strong></p>
</div>
<div class="info-item">
<strong>打卡时间:</strong><span class="highlight">{send_time}</span>
</div>
<div class="info-item">
<strong>打卡状态:</strong><span class="highlight">成功 ✓</span>
</div>
<p style="margin-top: 20px; color: #666;">
您无需进行任何操作,系统已自动为您完成打卡。此邮件仅作通知。
</p>
</div>
<div class="footer">
<p>此邮件由接龙自动打卡系统自动发送,请勿直接回复</p>
<p>CheckIn App V2 © 2026</p>
</div>
</div>
</body>
</html>
"""
FAILURE_HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>打卡失败通知</title>
<style>
body {{
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background-color: #f5f5f5;
line-height: 1.6;
}}
.container {{
max-width: 600px;
margin: 40px auto;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
}}
.header {{
background: linear-gradient(135deg, #f44336 0%, #e91e63 100%);
color: white;
padding: 30px 20px;
text-align: center;
}}
.header h1 {{
margin: 0;
font-size: 24px;
font-weight: 600;
}}
.content {{
padding: 30px 40px;
}}
.error-icon {{
text-align: center;
font-size: 64px;
margin: 20px 0;
}}
.error-box {{
background-color: #ffebee;
border-left: 4px solid #f44336;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}}
.info-item {{
margin: 16px 0;
padding: 12px;
background-color: #f9f9f9;
border-radius: 6px;
}}
.info-item strong {{
color: #333;
display: inline-block;
min-width: 100px;
}}
.highlight {{
color: #f44336;
font-weight: 600;
}}
.action-box {{
background-color: #fff3e0;
padding: 16px;
margin: 20px 0;
border-radius: 6px;
border: 1px solid #ffb74d;
}}
.action-box h3 {{
margin: 0 0 12px 0;
color: #ff6f00;
font-size: 16px;
}}
.action-box ul {{
margin: 8px 0;
padding-left: 20px;
}}
.action-box li {{
margin: 6px 0;
color: #666;
}}
.footer {{
background-color: #fafafa;
padding: 20px;
text-align: center;
color: #999;
font-size: 12px;
border-top: 1px solid #eeeeee;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>❌ 打卡失败通知</h1>
</div>
<div class="content">
<div class="error-icon">⚠️</div>
<p>您好,</p>
<div class="error-box">
<p><strong>自动打卡失败,需要您的关注!</strong></p>
</div>
<div class="info-item">
<strong>失败时间:</strong>{send_time}
</div>
<div class="info-item">
<strong>失败原因:</strong><span class="highlight">Token 已失效(需要登录)</span>
</div>
<div class="action-box">
<h3>📋 需要您执行以下操作:</h3>
<ul>
<li>登录接龙自动打卡系统</li>
<li>刷新您的 Authorization Token</li>
<li>确认 Token 更新成功</li>
</ul>
</div>
<p style="margin-top: 20px; color: #666;">
Token 失效是正常现象,通常在一段时间后会自动过期。刷新 Token 后,系统将恢复自动打卡功能。
</p>
</div>
<div class="footer">
<p>此邮件由接龙自动打卡系统自动发送,请勿直接回复</p>
<p>CheckIn App V2 © 2026</p>
</div>
</div>
</body>
</html>
"""
def get_email_settings():
@staticmethod
def get_email_config() -> Optional[dict]:
"""
从环境变量读取邮件配置
如果 SMTP_SERVER、SMTP_PORT 或 SMTP_SENDER_EMAIL 有任一为空,则禁用邮件功能
Returns:
dict: 邮件配置,如果配置不完整则返回 None
"""
@@ -375,138 +41,79 @@ def get_email_settings():
# 返回配置字典
return {
'smtpserver': settings.SMTP_SERVER,
'smtpport': settings.SMTP_PORT,
'senderemail': settings.SMTP_SENDER_EMAIL,
'senderpassword': settings.SMTP_SENDER_PASSWORD,
'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
}
def _send_email(to_email: str, subject: str, html_content: str, email_settings: dict) -> bool:
@staticmethod
def send_email(
to_emails: List[str],
subject: str,
html_content: str,
from_email: Optional[str] = None
) -> bool:
"""
发送邮件
发送邮件(底层方法)
Args:
to_email: 收件人邮箱
to_emails: 收件人邮箱列表
subject: 邮件主题
html_content: HTML 邮件内容
email_settings: 邮件配置
from_email: 发件人邮箱(可选,默认使用配置中的发件人)
Returns:
是否发送成功
"""
email_config = EmailNotifier.get_email_config()
if not email_config:
logger.warning("邮件配置不完整,跳过发送邮件")
return False
try:
msg = MIMEMultipart()
msg["From"] = email_settings['senderemail']
msg["To"] = to_email
msg["Subject"] = subject
msg.attach(MIMEText(html_content, 'html', 'utf-8'))
# 创建邮件
msg = MIMEMultipart('alternative')
msg['From'] = from_email or email_config['sender_email']
msg['To'] = ', '.join(to_emails)
msg['Subject'] = subject
# 根据配置选择使用 SSL 或普通 SMTP
if email_settings.get('use_ssl', True):
with smtplib.SMTP_SSL(email_settings['smtpserver'], int(email_settings['smtpport'])) as server:
server.login(email_settings['senderemail'], email_settings['senderpassword'])
server.sendmail(msg["From"], msg["To"], msg.as_string())
# 添加 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:
with smtplib.SMTP(email_settings['smtpserver'], int(email_settings['smtpport'])) as server:
server = smtplib.SMTP(
email_config['smtp_server'],
int(email_config['smtp_port'])
)
server.starttls()
server.login(email_settings['senderemail'], email_settings['senderpassword'])
server.sendmail(msg["From"], msg["To"], msg.as_string())
logger.info(f"已成功向 {to_email} 发送邮件,主题: {subject}")
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"{to_email} 发送邮件时失败: {e}")
logger.error(f"邮件发送失败: {e}")
return False
def send_expiration_notification(email: str, jwt_exp: str) -> bool:
@staticmethod
def is_email_enabled() -> bool:
"""
发送 Token 到期提醒邮件
Args:
email: 收件人邮箱
jwt_exp: Token 过期时间戳
检查邮件功能是否启用
Returns:
是否发送成功
邮件功能是否可用
"""
email_settings = get_email_settings()
if not email_settings:
return False
return EmailNotifier.get_email_config() is not None
try:
exp_time = time.strftime("%Y年%m月%d%H:%M:%S", time.localtime(float(jwt_exp)))
send_time = time.strftime("%Y年%m月%d%H:%M:%S", time.localtime())
html = EXPIRATION_HTML_TEMPLATE.format(
name=email,
exp_time=exp_time,
send_time=send_time
)
return _send_email(email, "接龙管家Token到期通知", html, email_settings)
except Exception as e:
logger.error(f"发送过期通知邮件失败: {e}")
return False
def send_success_notification(email: str) -> bool:
"""
发送打卡成功通知邮件
Args:
email: 收件人邮箱
Returns:
是否发送成功
"""
email_settings = get_email_settings()
if not email_settings:
return False
try:
send_time = time.strftime("%Y年%m月%d%H:%M:%S", time.localtime())
html = SUCCESS_HTML_TEMPLATE.format(
name=email,
send_time=send_time
)
return _send_email(email, "自动打卡成功通知", html, email_settings)
except Exception as e:
logger.error(f"发送成功通知邮件失败: {e}")
return False
def send_failure_notification(email: str) -> bool:
"""
发送打卡失败通知邮件
Args:
email: 收件人邮箱
Returns:
是否发送成功
"""
email_settings = get_email_settings()
if not email_settings:
return False
try:
send_time = time.strftime("%Y年%m月%d%H:%M:%S", time.localtime())
html = FAILURE_HTML_TEMPLATE.format(
name=email,
send_time=send_time
)
return _send_email(email, "打卡失败 - 需要刷新Token", html, email_settings)
except Exception as e:
logger.error(f"发送失败通知邮件失败: {e}")
return False
+67 -1
View File
@@ -86,6 +86,53 @@ def get_session_data(session_id: str) -> dict:
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):
# 读取当前会话数据
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
if not content:
return False
data = json.loads(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
@@ -193,7 +240,26 @@ def get_token_headless(session_id: str, jwt_sub: str = None, alias: str = None,
current_step = "等待用户扫描登录 (Cookie 'token' 出现)"
cookie_name_to_find = "token"
logger.info(f"Selenium ({session_id}): {current_step}...")
WebDriverWait(driver, 120, 1).until(lambda d: d.get_cookie(cookie_name_to_find) is not None) # 改为 120 秒(2分钟)
# 自定义等待逻辑:每秒检查cookie和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:
+215 -190
View File
@@ -8,9 +8,9 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.2.6",
"axios": "^1.13.2",
"element-plus": "^2.13.0",
"pinia": "^3.0.4",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
@@ -36,6 +36,34 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@ant-design/colors": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz",
"integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.4.0"
}
},
"node_modules/@ant-design/icons-svg": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
"integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==",
"license": "MIT"
},
"node_modules/@ant-design/icons-vue": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@ant-design/icons-vue/-/icons-vue-7.0.1.tgz",
"integrity": "sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^6.0.0",
"@ant-design/icons-svg": "^4.2.1"
},
"peerDependencies": {
"vue": ">=3.0.3"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@@ -69,6 +97,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
@@ -91,14 +128,17 @@
"node": ">=10"
}
},
"node_modules/@element-plus/icons-vue": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.2.0"
}
"node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT"
},
"node_modules/@emotion/unitless": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
"integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
@@ -542,31 +582,6 @@
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -643,17 +658,6 @@
"node": ">= 8"
}
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz",
"integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.53",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
@@ -969,6 +973,16 @@
"win32"
]
},
"node_modules/@simonwep/pickr": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/@simonwep/pickr/-/pickr-1.8.2.tgz",
"integrity": "sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==",
"license": "MIT",
"dependencies": {
"core-js": "^3.15.1",
"nanopop": "^2.1.0"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -976,27 +990,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==",
"license": "MIT"
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz",
@@ -1147,92 +1140,44 @@
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
"license": "MIT"
},
"node_modules/@vueuse/core": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
"node_modules/ant-design-vue": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/ant-design-vue/-/ant-design-vue-4.2.6.tgz",
"integrity": "sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.11.1",
"@vueuse/shared": "10.11.1",
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
"@ant-design/colors": "^6.0.0",
"@ant-design/icons-vue": "^7.0.0",
"@babel/runtime": "^7.10.5",
"@ctrl/tinycolor": "^3.5.0",
"@emotion/hash": "^0.9.0",
"@emotion/unitless": "^0.8.0",
"@simonwep/pickr": "~1.8.0",
"array-tree-filter": "^2.1.0",
"async-validator": "^4.0.0",
"csstype": "^3.1.1",
"dayjs": "^1.10.5",
"dom-align": "^1.12.1",
"dom-scroll-into-view": "^2.0.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.15",
"resize-observer-polyfill": "^1.5.1",
"scroll-into-view-if-needed": "^2.2.25",
"shallow-equal": "^1.0.0",
"stylis": "^4.1.3",
"throttle-debounce": "^5.0.0",
"vue-types": "^3.0.0",
"warning": "^4.0.0"
},
"engines": {
"node": ">=12"
"node": ">=12.22.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
"type": "opencollective",
"url": "https://opencollective.com/ant-design-vue"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
"license": "MIT",
"dependencies": {
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
"vue": ">=3.2.0"
}
},
"node_modules/any-promise": {
@@ -1276,6 +1221,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/array-tree-filter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
"integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==",
"license": "MIT"
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
@@ -1519,6 +1470,12 @@
"node": ">= 6"
}
},
"node_modules/compute-scroll-into-view": {
"version": "1.0.20",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
"license": "MIT"
},
"node_modules/copy-anything": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
@@ -1534,6 +1491,17 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/core-js": {
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz",
"integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1582,6 +1550,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/dom-align": {
"version": "1.12.4",
"resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz",
"integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
"license": "MIT"
},
"node_modules/dom-scroll-into-view": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz",
"integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==",
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1603,31 +1583,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/element-plus": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.0.tgz",
"integrity": "sha512-qjxS+SBChvqCl6lU6ShiliLMN6WqFHiXQENYbAY3GKNflG+FS3jqn8JmQq0CBZq4koFqsi95NT1M6SL4whZfrA==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.4.1",
"@element-plus/icons-vue": "^2.3.2",
"@floating-ui/dom": "^1.0.1",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
"@types/lodash": "^4.17.20",
"@types/lodash-es": "^4.17.12",
"@vueuse/core": "^10.11.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.19",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"lodash-unified": "^1.0.3",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0"
},
"peerDependencies": {
"vue": "^3.3.0"
}
},
"node_modules/entities": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
@@ -2057,6 +2012,15 @@
"node": ">=0.12.0"
}
},
"node_modules/is-plain-object": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz",
"integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-what": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
@@ -2079,6 +2043,12 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -2111,15 +2081,16 @@
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz",
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"peerDependencies": {
"@types/lodash-es": "*",
"lodash": "*",
"lodash-es": "*"
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/magic-string": {
@@ -2140,12 +2111,6 @@
"node": ">= 0.4"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -2240,6 +2205,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nanopop": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/nanopop/-/nanopop-2.4.2.tgz",
"integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==",
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -2257,12 +2228,6 @@
"node": ">=0.10.0"
}
},
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -2581,6 +2546,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -2685,6 +2656,21 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/scroll-into-view-if-needed": {
"version": "2.2.31",
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
"integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
"license": "MIT",
"dependencies": {
"compute-scroll-into-view": "^1.0.20"
}
},
"node_modules/shallow-equal": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
"integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2703,6 +2689,12 @@
"node": ">=0.10.0"
}
},
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -2812,6 +2804,15 @@
"node": ">=0.8"
}
},
"node_modules/throttle-debounce": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
"integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
"license": "MIT",
"engines": {
"node": ">=12.22"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -3003,6 +3004,30 @@
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/vue-types/-/vue-types-3.0.2.tgz",
"integrity": "sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==",
"license": "MIT",
"dependencies": {
"is-plain-object": "3.0.1"
},
"engines": {
"node": ">=10.15.0"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
}
}
}
+2 -2
View File
@@ -9,9 +9,9 @@
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.2.6",
"axios": "^1.13.2",
"element-plus": "^2.13.0",
"pinia": "^3.0.4",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
+14 -1
View File
@@ -1,10 +1,15 @@
<template>
<a-config-provider :theme="antdTheme" :locale="zhCN">
<router-view />
</a-config-provider>
</template>
<script setup>
import { onMounted } 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 antdTheme from './antd-theme'
const authStore = useAuthStore()
@@ -30,10 +35,18 @@ onMounted(async () => {
}
html,
body,
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;
+77
View File
@@ -0,0 +1,77 @@
/**
* Ant Design Vue 主题配置
* 匹配现有 Material Design 3 色彩系统
*/
export default {
token: {
// 主色调 - 绿色(与 MD3 primary 保持一致)
colorPrimary: '#4caf50',
// 成功色
colorSuccess: '#4caf50',
// 警告色
colorWarning: '#ff9800',
// 错误色
colorError: '#f56c6c',
// 信息色 - 蓝色(与 MD3 secondary 保持一致)
colorInfo: '#2196f3',
// 边框圆角 - 与 Material Design 3 一致
borderRadius: 12,
// 字体家族
fontFamily: "'Inter', 'Segoe UI', 'Roboto', system-ui, -apple-system, sans-serif",
// 链接色
colorLink: '#2196f3',
// 字体大小
fontSize: 14,
// 行高
lineHeight: 1.5715,
},
components: {
// Card 组件定制
Card: {
borderRadiusLG: 16,
boxShadowTertiary: '0 1px 3px 1px rgba(0, 0, 0, 0.08)',
paddingLG: 24,
},
// Button 组件定制
Button: {
borderRadius: 24, // 圆角按钮,类似 MD3
controlHeight: 40,
fontSize: 14,
},
// Input 组件定制
Input: {
borderRadius: 12,
controlHeight: 40,
},
// Modal 组件定制
Modal: {
borderRadiusLG: 16,
},
// Table 组件定制
Table: {
borderRadius: 12,
},
// Tabs 组件定制
Tabs: {
borderRadius: 12,
},
},
// 算法配置
algorithm: [],
}
+34 -1
View File
@@ -34,7 +34,28 @@ client.interceptors.response.use(
const { status, data } = error.response
if (status === 401) {
// Token 过期或无效,清除登录状态
const errorDetail = data.detail || data.message || ''
// 检查用户是否设置了密码
const user = JSON.parse(localStorage.getItem('user') || '{}')
const hasPassword = user.has_password || false
// Token 过期的情况
if (errorDetail.includes('过期')) {
if (hasPassword) {
// 有密码的用户:不强制退出,只显示警告
// 不清除 localStorage,让用户继续使用
console.warn('Token 已过期,但用户设置了密码,允许继续使用')
// 返回错误但不跳转登录页
return Promise.reject({
status,
message: '登录凭证已过期,部分功能可能受限,建议刷新凭证',
data,
tokenExpired: true,
})
} else {
// 没有密码的用户:必须重新登录
localStorage.removeItem('token')
localStorage.removeItem('user')
@@ -45,6 +66,18 @@ client.interceptors.response.use(
}
}, 100)
}
} else {
// 其他 401 错误(无效 Token 等):清除登录状态
localStorage.removeItem('token')
localStorage.removeItem('user')
setTimeout(() => {
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
}, 100)
}
}
// 返回统一的错误对象
return Promise.reject({
+5
View File
@@ -14,6 +14,11 @@ export const authAPI = {
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 })
+152 -27
View File
@@ -17,45 +17,51 @@
<!-- 快速模式仅日期 20:00 -->
<div v-if="mode === 'quick'" class="mode-content">
<div class="quick-option">
<el-radio v-model="selectedQuick" label="20:00">
<a-radio-group v-model:value="selectedQuick">
<a-radio value="20:00">
<span class="option-label">每天 20:00默认</span>
<span class="option-desc">推荐的默认时间</span>
</el-radio>
</a-radio>
</a-radio-group>
</div>
</div>
<!-- 自定义模式可视化构建器 -->
<!-- 自定义模式:可视化构建器 -->
<div v-if="mode === 'custom'" class="mode-content">
<el-form label-width="120px">
<el-form-item label="时间">
<el-time-select
v-model="customTime"
:start="'00:00'"
:end="'23:30'"
step="00:30"
<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"
@change="onCustomTimeChange"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="频率">
<el-select v-model="customFrequency">
<el-option label="每天" value="daily" />
<el-option label="工作日(周一-周五)" value="weekday" />
<el-option label="周末(周六-周日)" value="weekend" />
</el-select>
</el-form-item>
</el-form>
</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">
<el-input
v-model="advancedExpression"
type="textarea"
<a-textarea
v-model:value="advancedExpression"
placeholder="输入 crontab 表达式(例如:0 20 * * *"
:rows="2"
@input="validateExpression"
@input="handleAdvancedInput"
/>
<div class="help-text">
格式: 分钟 小时 日期 月份 星期
@@ -80,7 +86,8 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ref, watch, onBeforeUnmount } from 'vue'
import dayjs from 'dayjs'
import client from '@/api/client'
const props = defineProps({
@@ -102,6 +109,7 @@ const selectedQuick = ref('20:00')
// 自定义模式
const customTime = ref('20:00')
const customTimeValue = ref(dayjs('20:00', 'HH:mm'))
const customFrequency = ref('daily')
// 高级模式
@@ -112,26 +120,62 @@ 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)
})
@@ -157,7 +201,21 @@ function buildCrontabFromCustom() {
return `${minute} ${hour} * * ${dow}`
}
async function validateExpression() {
// 处理高级模式输入 - 使用防抖以避免频繁调用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 = []
@@ -165,6 +223,7 @@ async function validateExpression() {
}
await validateAndPreview(advancedExpression.value)
}, 500) // 500ms 防抖延迟
}
async function validateAndPreview(expr) {
@@ -191,12 +250,78 @@ async function validateAndPreview(expr) {
}
}
// 初始化
// 解析 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) {
advancedExpression.value = newVal
parseCronExpression(newVal)
}
}, { immediate: true })
// 组件卸载时清理防抖定时器,防止内存泄漏
onBeforeUnmount(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
})
</script>
<style scoped>
+78 -77
View File
@@ -2,99 +2,98 @@
<div class="field-config-editor space-y-4">
<!-- Row 1: Display Name and Field Type -->
<div class="grid grid-cols-2 gap-4">
<el-form-item label="显示名称" class="mb-0">
<el-input
:model-value="modelValue.display_name"
@update:model-value="updateField('display_name', $event)"
<a-form-item label="显示名称" class="mb-0">
<a-input
:value="modelValue.display_name"
@change="e => updateField('display_name', e.target.value)"
placeholder="在表单中显示的名称"
clearable
allow-clear
/>
<span class="text-xs text-gray-500 mt-1">显示名称</span>
</el-form-item>
</a-form-item>
<el-form-item label="字段类型" class="mb-0">
<el-select
:model-value="modelValue.field_type"
@update:model-value="handleFieldTypeChange"
<a-form-item label="字段类型" class="mb-0">
<a-select
:value="modelValue.field_type"
@change="handleFieldTypeChange"
placeholder="选择输入控件类型"
class="w-full"
>
<el-option label="📝 单行文本" value="text" />
<el-option label="📄 多行文本" value="textarea" />
<el-option label="🔢 数字输入" value="number" />
<el-option label="📋 下拉选择" value="select" />
</el-select>
<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-gray-500 mt-1">用户填写时使用的输入控件</span>
</el-form-item>
</a-form-item>
</div>
<!-- Row 2: Value Type and Default Value -->
<div class="grid grid-cols-2 gap-4">
<el-form-item label="值类型" class="mb-0">
<el-select
:model-value="modelValue.value_type"
@update:model-value="updateField('value_type', $event)"
<a-form-item label="值类型" class="mb-0">
<a-select
:value="modelValue.value_type"
@change="value => updateField('value_type', value)"
placeholder="选择数据类型"
class="w-full"
>
<el-option label="字符串 (string)" value="string">
<a-select-option label="字符串 (string)" value="string">
<span class="text-xs text-gray-500">字符串 (string)</span>
</el-option>
<el-option label="整数 (int)" value="int">
</a-select-option>
<a-select-option label="整数 (int)" value="int">
<span class="text-xs text-gray-500">整数 (int)</span>
</el-option>
<el-option label="浮点数 (double)" value="double">
</a-select-option>
<a-select-option label="浮点数 (double)" value="double">
<span class="text-xs text-gray-500">浮点数 (double)</span>
</el-option>
<el-option label="布尔值 (bool)" value="bool">
</a-select-option>
<a-select-option label="布尔值 (bool)" value="bool">
<span class="text-xs text-gray-500">布尔值 (bool)</span>
</el-option>
<el-option label="JSON对象 (json)" value="json">
</a-select-option>
<a-select-option label="JSON对象 (json)" value="json">
<span class="text-xs text-gray-500">JSON对象 (json) - 用于Values字段</span>
</el-option>
</el-select>
</a-select-option>
</a-select>
<span class="text-xs text-gray-500 mt-1">数据存储时的类型</span>
</el-form-item>
</a-form-item>
<el-form-item label="默认值" class="mb-0">
<el-input
<a-form-item label="默认值" class="mb-0">
<a-input
v-if="modelValue.value_type !== 'json'"
:model-value="modelValue.default_value"
@update:model-value="updateField('default_value', $event)"
:value="modelValue.default_value"
@change="e => updateField('default_value', e.target.value)"
placeholder="字段的默认值"
clearable
allow-clear
/>
<el-input
<a-textarea
v-else
type="textarea"
:model-value="modelValue.default_value"
@update:model-value="updateField('default_value', $event)"
:value="modelValue.default_value"
@change="e => updateField('default_value', e.target.value)"
placeholder="字段的默认值"
:rows="3"
clearable
allow-clear
/>
<span class="text-xs text-gray-500 mt-1">
<template v-if="modelValue.value_type === 'json'">
<p>输入JSON对象会自动序列化为字符串</p>
<p>{"key1":value1,"key2":value2}</p>
<p>输入JSON对象,会自动序列化为字符串</p>
<p>:{"key1":value1,"key2":value2}</p>
</template>
<template v-else>
用户未填写时使用此值
</template>
</span>
</el-form-item>
</a-form-item>
</div>
<!-- Row 3: Placeholder -->
<el-form-item label="占位符提示" class="mb-0">
<el-input
:model-value="modelValue.placeholder"
@update:model-value="updateField('placeholder', $event)"
<a-form-item label="占位符提示" class="mb-0">
<a-input
:value="modelValue.placeholder"
@change="e => updateField('placeholder', e.target.value)"
placeholder="输入框的灰色提示文本"
clearable
allow-clear
/>
<span class="text-xs text-gray-500 mt-1">占位符</span>
</el-form-item>
</a-form-item>
<!-- Row 4: Switches -->
<div class="grid grid-cols-2 gap-4 p-3 bg-blue-50 rounded-lg">
@@ -103,9 +102,9 @@
<label class="text-sm font-medium text-gray-700">是否必填</label>
<p class="text-xs text-gray-500">用户必须填写此字段</p>
</div>
<el-switch
:model-value="modelValue.required"
@update:model-value="handleRequiredChange"
<a-switch
:checked="modelValue.required"
@change="handleRequiredChange"
:disabled="modelValue.hidden"
/>
</div>
@@ -115,28 +114,30 @@
<label class="text-sm font-medium text-gray-700">是否隐藏</label>
<p class="text-xs text-gray-500">直接使用默认值不在表单中显示</p>
</div>
<el-switch
:model-value="modelValue.hidden"
@update:model-value="handleHiddenChange"
<a-switch
:checked="modelValue.hidden"
@change="handleHiddenChange"
/>
</div>
</div>
<el-alert
<a-alert
v-if="modelValue.hidden"
title="💡 提示"
message="💡 提示"
type="info"
:closable="false"
class="mt-3"
>
<template #description>
<p class="text-xs">
隐藏字段将自动使用默认值不会在创建任务表单中显示请确保设置了合适的默认值
</p>
</el-alert>
</template>
</a-alert>
<!-- Options for select type -->
<div v-if="modelValue.field_type === 'select'" class="border-t pt-4 mt-4">
<el-form-item label="选项列表" class="mb-0">
<a-form-item label="选项列表" class="mb-0">
<div class="space-y-2">
<div
v-for="(option, index) in modelValue.options || []"
@@ -144,48 +145,48 @@
class="flex items-center gap-2 p-2 bg-gray-50 rounded"
>
<span class="text-xs text-gray-500 w-8">{{ index + 1 }}.</span>
<el-input
:model-value="option.label"
@update:model-value="updateOption(index, 'label', $event)"
<a-input
:value="option.label"
@change="e => updateOption(index, 'label', e.target.value)"
placeholder="显示文本(如:健康)"
size="small"
class="flex-1"
/>
<el-input
:model-value="option.value"
@update:model-value="updateOption(index, 'value', $event)"
<a-input
:value="option.value"
@change="e => updateOption(index, 'value', e.target.value)"
placeholder="选项值(如:healthy"
size="small"
class="flex-1"
/>
<el-button
<a-button
size="small"
type="danger"
:icon="Delete"
danger
@click="removeOption(index)"
circle
/>
>
<template #icon><DeleteOutlined /></template>
</a-button>
</div>
<el-button size="small" type="primary" plain @click="addOption" class="w-full">
<a-button size="small" type="primary" @click="addOption" class="w-full">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
添加选项
</el-button>
</a-button>
<p class="text-xs text-gray-500 mt-2">
💡 提示显示文本是用户看到的内容选项值是实际保存的数据
</p>
</div>
</el-form-item>
</a-form-item>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
import { Delete } from '@element-plus/icons-vue'
import { DeleteOutlined } from '@ant-design/icons-vue'
const props = defineProps({
modelValue: {
@@ -287,7 +288,7 @@ const removeOption = (index) => {
border: 1px solid #e5e7eb;
}
:deep(.el-form-item__label) {
:deep(.ant-form-item-label) {
font-weight: 500;
color: #374151;
}
+55 -55
View File
@@ -23,25 +23,25 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<span class="font-mono text-base font-bold text-blue-700">{{ fieldKey }}</span>
<el-tag type="primary" size="small">普通字段</el-tag>
<a-tag type="primary" size="small">普通字段</a-tag>
</div>
<div class="flex gap-2">
<el-button size="small" @click="handleMove('up')" title="上移">
<a-button size="small" @click="handleMove('up')" title="上移">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</el-button>
<el-button size="small" @click="handleMove('down')" title="下移">
</a-button>
<a-button size="small" @click="handleMove('down')" title="下移">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete">
</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>
删除
</el-button>
</a-button>
</div>
</div>
@@ -73,38 +73,38 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
<span class="font-mono text-base font-bold text-purple-700">{{ fieldKey }}</span>
<el-tag type="warning" size="small">数组字段</el-tag>
<a-tag type="warning" size="small">数组字段</a-tag>
</div>
<div class="flex gap-2">
<el-button size="small" @click="handleMove('up')" title="上移">
<a-button size="small" @click="handleMove('up')" title="上移">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</el-button>
<el-button size="small" @click="handleMove('down')" title="下移">
</a-button>
<a-button size="small" @click="handleMove('down')" title="下移">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</el-button>
<el-button size="small" type="primary" @click="addArrayItem">
</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>
添加元素
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete">
</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>
删除
</el-button>
</a-button>
</div>
</div>
<div v-show="!isCollapsed">
<div v-if="localFieldConfig.length === 0" class="text-center py-6 bg-purple-50 rounded-lg border border-dashed border-purple-300">
<p class="text-sm text-gray-500 mb-2">数组为空</p>
<el-button size="small" type="primary" @click="addArrayItem">添加第一个元素</el-button>
<a-button size="small" type="primary" @click="addArrayItem">添加第一个元素</a-button>
</div>
<div v-else class="space-y-3 mt-3">
@@ -115,9 +115,9 @@
>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-purple-700">元素 #{{ index + 1 }}</span>
<el-button size="small" type="danger" plain @click="removeArrayItem(index)">
<a-button size="small" type="danger" plain @click="removeArrayItem(index)">
删除元素
</el-button>
</a-button>
</div>
<!-- 如果数组元素是字段配置对象直接渲染为字段编辑器 -->
@@ -138,12 +138,12 @@
@move="$emit('move', $event)"
/>
<el-button class="w-full" size="small" type="primary" plain @click="addFieldToArrayItem(index)">
<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>
添加字段
</el-button>
</a-button>
</div>
<!-- 如果数组元素是数组递归渲染 -->
@@ -185,38 +185,38 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="font-mono text-base font-bold text-green-700">{{ fieldKey }}</span>
<el-tag type="success" size="small">对象字段</el-tag>
<a-tag type="success" size="small">对象字段</a-tag>
</div>
<div class="flex gap-2">
<el-button size="small" @click="handleMove('up')" title="上移">
<a-button size="small" @click="handleMove('up')" title="上移">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</el-button>
<el-button size="small" @click="handleMove('down')" title="下移">
</a-button>
<a-button size="small" @click="handleMove('down')" title="下移">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</el-button>
<el-button size="small" type="primary" @click="addFieldToObject">
</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>
添加子字段
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete">
</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>
删除
</el-button>
</a-button>
</div>
</div>
<div v-show="!isCollapsed">
<div v-if="Object.keys(localFieldConfig).length === 0" class="text-center py-6 bg-green-50 rounded-lg border border-dashed border-green-300">
<p class="text-sm text-gray-500 mb-2">对象为空</p>
<el-button size="small" type="primary" @click="addFieldToObject">添加第一个子字段</el-button>
<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-green-300">
@@ -236,34 +236,34 @@
</div>
<!-- 添加字段对话框 -->
<el-dialog v-model="addFieldDialogVisible" :title="currentArrayIndex === -1 ? '添加数组元素' : '添加字段'" width="400px">
<el-form>
<el-form-item :label="currentArrayIndex === -1 ? '字段名(可选)' : '字段名'">
<el-input
v-model="newFieldName"
<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'"
/>
</el-form-item>
<el-form-item label="元素类型">
<el-radio-group v-model="newFieldType">
<el-radio label="field">普通字段</el-radio>
<el-radio label="array">数组字段</el-radio>
<el-radio label="object">对象字段</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
</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>
<el-button @click="addFieldDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmAddField">确定</el-button>
<a-button @click="addFieldDialogVisible = false">取消</a-button>
<a-button type="primary" @click="confirmAddField">确定</a-button>
</template>
</el-dialog>
</a-modal>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { message } from 'ant-design-vue'
import FieldConfigEditor from './FieldConfigEditor.vue'
const props = defineProps({
@@ -400,7 +400,7 @@ const confirmAddField = () => {
}
addFieldDialogVisible.value = false
ElMessage.success('数组元素添加成功')
message.success('数组元素添加成功')
return
} else {
// 字段名不为空,添加为包含命名字段的对象
@@ -423,21 +423,21 @@ const confirmAddField = () => {
localFieldConfig.value.push(newObject)
addFieldDialogVisible.value = false
ElMessage.success('带命名字段的对象添加成功')
message.success('带命名字段的对象添加成功')
return
}
}
// 其他情况需要字段名
if (!newFieldName.value) {
ElMessage.warning('请输入字段名')
message.warning('请输入字段名')
return
}
if (isAddingToObject.value) {
// 添加到对象字段
if (localFieldConfig.value[newFieldName.value]) {
ElMessage.warning('该字段已存在')
message.warning('该字段已存在')
return
}
@@ -460,7 +460,7 @@ const confirmAddField = () => {
// 添加到数组元素
const arrayItem = localFieldConfig.value[currentArrayIndex.value]
if (arrayItem[newFieldName.value]) {
ElMessage.warning('该字段已存在')
message.warning('该字段已存在')
return
}
@@ -482,7 +482,7 @@ const confirmAddField = () => {
}
addFieldDialogVisible.value = false
ElMessage.success('字段添加成功')
message.success('字段添加成功')
}
</script>
-43
View File
@@ -1,43 +0,0 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>
+11 -2
View File
@@ -8,21 +8,30 @@
</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%;
height: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #f5f7fa 0%, #e8eef5 100%);
}
.main-content {
flex: 1;
overflow-y: auto;
background-color: #f5f5f5;
padding: 20px;
}
</style>
+335 -151
View File
@@ -6,15 +6,13 @@
<div class="flex items-center space-x-8">
<router-link to="/" class="flex items-center space-x-3 group">
<div class="w-10 h-10 bg-gradient-to-br from-primary-500 to-secondary-500 rounded-md3 flex items-center justify-center transform group-hover:scale-110 transition-transform">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<CheckCircleOutlined class="text-white text-xl" />
</div>
<span class="text-xl font-bold text-gradient">接龙自动打卡</span>
</router-link>
<!-- Navigation Links -->
<div class="hidden md:flex items-center space-x-2">
<!-- Desktop Navigation Links -->
<div v-if="!isMobile" class="hidden md:flex items-center space-x-2">
<router-link
to="/dashboard"
v-slot="{ isActive }"
@@ -30,9 +28,7 @@
]"
>
<div class="flex items-center space-x-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<HomeOutlined />
<span>仪表盘</span>
</div>
</a>
@@ -53,9 +49,7 @@
]"
>
<div class="flex items-center space-x-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<FileTextOutlined />
<span>任务管理</span>
</div>
</a>
@@ -76,156 +70,203 @@
]"
>
<div class="flex items-center space-x-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
<UnorderedListOutlined />
<span>打卡记录</span>
</div>
</a>
</router-link>
<!-- Admin Menu -->
<div v-if="authStore.isAdmin" class="relative" @mouseenter="showAdminMenu = true" @mouseleave="showAdminMenu = false">
<button
<!-- Admin Dropdown Menu -->
<a-dropdown v-if="authStore.isAdmin" :trigger="['hover']">
<a
:class="[
'px-4 py-2 rounded-full font-medium transition-all flex items-center space-x-2',
'px-4 py-2 rounded-full font-medium transition-all flex items-center space-x-2 cursor-pointer',
isAdminPath ? 'bg-secondary-100 text-secondary-700' : 'text-gray-700 hover:bg-gray-100'
]"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<SettingOutlined />
<span>管理后台</span>
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': showAdminMenu }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Admin Dropdown -->
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div v-show="showAdminMenu" class="absolute top-full left-0 mt-2 w-48 glass-effect rounded-md3 shadow-md3-3 py-2 border border-gray-200/50">
<router-link
to="/admin/users"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span>用户管理</span>
</div>
</router-link>
<router-link
to="/admin/templates"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>模板管理</span>
</div>
</router-link>
<router-link
to="/admin/records"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
<span>打卡记录</span>
</div>
</router-link>
<router-link
to="/admin/stats"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span>统计信息</span>
</div>
</router-link>
<router-link
to="/admin/logs"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>系统日志</span>
</div>
</router-link>
</div>
</transition>
</div>
<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 -->
<!-- User Menu & Mobile Hamburger -->
<div class="flex items-center space-x-4">
<!-- User Avatar and Menu -->
<div class="relative" @mouseenter="showUserMenu = true" @mouseleave="showUserMenu = false">
<button class="flex items-center space-x-3 px-4 py-2 rounded-full hover:bg-gray-100 transition-all">
<div class="w-8 h-8 bg-gradient-to-br from-accent-400 to-accent-600 rounded-full flex items-center justify-center text-white font-semibold">
{{ userInitial }}
</div>
<span class="hidden md:block font-medium text-gray-700">{{ authStore.user?.alias || '用户' }}</span>
<svg class="w-4 h-4 text-gray-500 transition-transform" :class="{ 'rotate-180': showUserMenu }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- User Dropdown -->
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
<!-- Token Status Indicator (Desktop) -->
<a-tooltip v-if="!isMobile && showTokenStatus" :title="tokenStatusTooltip">
<div
class="px-3 py-1.5 rounded-full cursor-pointer transition-all hover:bg-gray-100 flex items-center space-x-2"
@click="handleTokenStatusClick"
>
<div v-show="showUserMenu" class="absolute top-full right-0 mt-2 w-48 glass-effect rounded-md3 shadow-md3-3 py-2 border border-gray-200/50">
<div class="px-4 py-2 border-b border-gray-200/50">
<a-badge :status="tokenBadgeStatus" />
<ClockCircleOutlined :class="tokenIconClass" />
<span class="text-sm">{{ tokenBadgeText }}</span>
<!-- 过期时显示刷新按钮 -->
<a-button
v-if="remainingMinutes !== null && remainingMinutes < 0"
type="primary"
size="small"
@click.stop="handleRefreshToken"
>
刷新
</a-button>
</div>
</a-tooltip>
<!-- Desktop User Menu -->
<a-dropdown v-if="!isMobile" :trigger="['hover']">
<a class="flex items-center space-x-3 px-4 py-2 rounded-full hover:bg-gray-100 transition-all cursor-pointer">
<a-avatar :style="{ backgroundColor: '#f56a00' }">
{{ userInitial }}
</a-avatar>
<span class="hidden md:block font-medium text-gray-700">{{ authStore.user?.alias || '用户' }}</span>
<DownOutlined class="text-xs text-gray-500" />
</a>
<template #overlay>
<a-menu>
<a-menu-item key="info" disabled>
<div class="px-2 py-1">
<p class="text-sm font-medium text-gray-900">{{ authStore.user?.alias }}</p>
<p class="text-xs text-gray-500 mt-1">{{ authStore.isAdmin ? '管理员' : '普通用户' }}</p>
</div>
<button
@click="router.push('/settings')"
class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors flex items-center space-x-2"
</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" @click="handleLogout" danger>
<LogoutOutlined />
<span class="ml-2">退出登录</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<!-- Mobile Hamburger Button -->
<a-button
v-if="isMobile"
type="text"
@click="drawerVisible = true"
class="!p-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>个人设置</span>
</button>
<button
@click="handleLogout"
class="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors flex items-center space-x-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>退出登录</span>
</button>
</div>
</transition>
</div>
<MenuOutlined class="text-xl" />
</a-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-gray-200">
<div class="flex items-center space-x-3">
<a-avatar :size="48" :style="{ backgroundColor: '#f56a00' }">
{{ userInitial }}
</a-avatar>
<div>
<p class="font-medium text-gray-900">{{ authStore.user?.alias || '用户' }}</p>
<p class="text-xs text-gray-500">{{ 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>
@@ -233,14 +274,36 @@
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { useTokenMonitor } from '@/composables/useTokenMonitor'
import { useBreakpoint } from '@/composables/useBreakpoint'
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,
} from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const userStore = useUserStore()
const { isMobile } = useBreakpoint()
const { getRemainingMinutes, tokenStatus } = useTokenMonitor()
const showAdminMenu = ref(false)
const showUserMenu = ref(false)
const drawerVisible = ref(false)
const qrcodeModalVisible = ref(false)
const isAdminPath = computed(() => route.path.startsWith('/admin'))
@@ -249,19 +312,140 @@ const userInitial = computed(() => {
return name.charAt(0).toUpperCase()
})
const handleLogout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
// Token 状态计算
const remainingMinutes = computed(() => {
return getRemainingMinutes()
})
.then(() => {
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-gray-500'
if (mins < 0) return 'text-red-500' // 已过期
if (mins <= 10) return 'text-red-500 animate-pulse' // 10分钟内,闪烁
if (mins <= 30) return 'text-orange-500' // 30分钟内
return 'text-blue-500' // 正常
})
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('Token 已过期,请进行刷新')
}
// 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() {
authStore.logout()
router.push('/login')
drawerVisible.value = false
},
})
.catch(() => {
// 取消操作
})
}
// 处理 Token 刷新
const handleRefreshToken = () => {
qrcodeModalVisible.value = true
}
// 处理 QR 码扫码成功
const handleQRCodeSuccess = async () => {
message.success('Token 刷新成功')
qrcodeModalVisible.value = false
// 刷新用户信息和 Token 状态
try {
await authStore.fetchCurrentUser()
await userStore.fetchTokenStatus()
} catch (error) {
console.error('刷新用户信息失败:', error)
}
}
// 处理 QR 码扫码失败
const handleQRCodeError = (error) => {
message.error(error?.message || 'Token 刷新失败')
}
</script>
+65 -33
View File
@@ -1,17 +1,17 @@
<template>
<el-dialog
v-model="dialogVisible"
<a-modal
v-model:open="dialogVisible"
title="QQ 扫码登录"
width="400px"
:close-on-click-modal="false"
@close="handleClose"
:width="isMobile ? '100%' : 400"
:style="isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : {}"
:maskClosable="false"
@cancel="handleClose"
:footer="null"
>
<div class="qrcode-container">
<!-- 加载中 -->
<div v-if="status === 'loading'" class="status-container">
<el-icon class="is-loading" :size="60">
<Loading />
</el-icon>
<a-spin size="large" />
<p class="status-text">正在获取二维码...</p>
</div>
@@ -19,43 +19,43 @@
<div v-else-if="status === 'pending'" class="qrcode-wrapper">
<img :src="qrcodeUrl" alt="QR Code" class="qrcode-image" />
<p class="hint-text">请使用手机 QQ 扫描二维码登录</p>
<el-progress :percentage="progress" :show-text="false" />
<a-progress :percent="progress" :show-info="false" />
<p class="countdown-text">{{ countdown }}s</p>
</div>
<!-- 扫码成功 -->
<div v-else-if="status === 'success'" class="status-container">
<el-icon :size="60" color="#67c23a">
<SuccessFilled />
</el-icon>
<CheckCircleFilled class="status-icon success-icon" />
<p class="status-text success">登录成功</p>
</div>
<!-- 二维码过期 -->
<div v-else-if="status === 'expired'" class="status-container">
<el-icon :size="60" color="#e6a23c">
<WarningFilled />
</el-icon>
<WarningFilled class="status-icon warning-icon" />
<p class="status-text">二维码已过期</p>
<el-button type="primary" @click="refreshQRCode">刷新二维码</el-button>
<a-button type="primary" @click="refreshQRCode" class="mt-4">刷新二维码</a-button>
</div>
<!-- 失败 -->
<div v-else-if="status === 'failed'" class="status-container">
<el-icon :size="60" color="#f56c6c">
<CircleCloseFilled />
</el-icon>
<CloseCircleFilled class="status-icon error-icon" />
<p class="status-text error">{{ errorMessage }}</p>
<el-button type="primary" @click="refreshQRCode">重试</el-button>
<a-button type="primary" @click="refreshQRCode" class="mt-4">重试</a-button>
</div>
</div>
</el-dialog>
</a-modal>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
import { useBreakpoint } from '@/composables/useBreakpoint'
import { message } from 'ant-design-vue'
import {
CheckCircleFilled,
WarningFilled,
CloseCircleFilled,
} from '@ant-design/icons-vue'
const props = defineProps({
visible: {
@@ -71,6 +71,7 @@ const props = defineProps({
const emit = defineEmits(['update:visible', 'success', 'error'])
const authStore = useAuthStore()
const { isMobile } = useBreakpoint()
const dialogVisible = computed({
get: () => props.visible,
@@ -122,7 +123,7 @@ const startPolling = () => {
stopPolling()
stopCountdown()
ElMessage.success('登录成功!')
message.success('登录成功!')
// 延迟关闭对话框
setTimeout(() => {
@@ -194,6 +195,16 @@ const refreshQRCode = () => {
const handleClose = () => {
stopPolling()
stopCountdown()
// 如果有未完成的会话,取消它
if (sessionId.value && status.value !== 'success') {
try {
authStore.cancelQRCodeSession(sessionId.value)
} catch (error) {
console.error('取消会话失败:', error)
}
}
dialogVisible.value = false
}
@@ -209,6 +220,12 @@ watch(
}
}
)
// 组件卸载时清理定时器,防止内存泄漏
onBeforeUnmount(() => {
stopPolling()
stopCountdown()
})
</script>
<style scoped>
@@ -228,6 +245,22 @@ watch(
min-height: 300px;
}
.status-icon {
font-size: 60px;
}
.success-icon {
color: #52c41a;
}
.warning-icon {
color: #faad14;
}
.error-icon {
color: #ff4d4f;
}
.status-text {
margin-top: 20px;
font-size: 16px;
@@ -235,12 +268,12 @@ watch(
}
.status-text.success {
color: #67c23a;
color: #52c41a;
font-weight: bold;
}
.status-text.error {
color: #f56c6c;
color: #ff4d4f;
}
.qrcode-wrapper {
@@ -253,8 +286,8 @@ watch(
.qrcode-image {
width: 240px;
height: 240px;
border: 1px solid #dcdfe6;
border-radius: 4px;
border: 1px solid #d9d9d9;
border-radius: 8px;
padding: 10px;
background-color: #fff;
}
@@ -262,17 +295,16 @@ watch(
.hint-text {
margin-top: 20px;
font-size: 14px;
color: #909399;
color: #8c8c8c;
}
.countdown-text {
margin-top: 10px;
font-size: 12px;
color: #909399;
color: #8c8c8c;
}
.el-progress {
width: 100%;
margin-top: 10px;
.mt-4 {
margin-top: 16px;
}
</style>
+65
View File
@@ -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,
}
}
+161
View File
@@ -0,0 +1,161 @@
import { ref, computed, onMounted, onUnmounted } 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
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: `您的登录凭证已过期 ${expiredMinutes} 分钟,部分功能可能受限。建议您扫码刷新凭证。`,
duration: 8,
key: 'token-expired-warning',
})
warningShown = true
}
} else {
// 没有密码的用户:必须重新登录
message.error({
content: '您的登录凭证已过期,请重新扫码登录',
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: `您的登录凭证将在 ${remainingMinutes} 分钟后过期,建议您提前刷新`,
duration: 6,
key: 'token-expiring-warning',
})
warningShown = true
}
}
// Token 状态正常
else if (remainingMinutes !== null && remainingMinutes > 60) {
// 重置警告标志
warningShown = false
}
} catch (error) {
console.error('检查 Token 状态失败:', error)
}
}
// 启动监控
const startMonitoring = () => {
// 避免重复启动
if (monitorTimer) {
return
}
// 立即检查一次
checkTokenStatus()
// 每 2 分钟检查一次
monitorTimer = setInterval(() => {
checkTokenStatus()
}, 2 * 60 * 1000) // 2 分钟
}
// 停止监控
const stopMonitoring = () => {
if (monitorTimer) {
clearInterval(monitorTimer)
monitorTimer = null
}
warningShown = false
}
// 手动触发检查
const checkNow = () => {
warningShown = false // 重置警告标志,允许再次显示
checkTokenStatus()
}
// 组件挂载时启动监控
onMounted(() => {
if (authStore.isAuthenticated) {
startMonitoring()
}
})
// 组件卸载时停止监控
onUnmounted(() => {
stopMonitoring()
})
return {
tokenStatus,
hasPassword,
startMonitoring,
stopMonitoring,
checkNow,
getRemainingMinutes,
}
}
+16 -40
View File
@@ -1,23 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import {
User,
Key,
Calendar,
Refresh,
Document,
List,
Plus,
UserFilled,
DataAnalysis,
Loading,
SuccessFilled,
WarningFilled,
CircleCloseFilled
} from '@element-plus/icons-vue'
// Ant Design Vue
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import { ConfigProvider } from 'ant-design-vue'
import antdTheme from './antd-theme'
import App from './App.vue'
import router from './router'
@@ -26,29 +15,16 @@ import './style.css'
const app = createApp(App)
const pinia = createPinia()
// 按需注册 Element Plus 图标(仅注册使用的13个)
const icons = {
User,
Key,
Calendar,
Refresh,
Document,
List,
Plus,
UserFilled,
DataAnalysis,
Loading,
SuccessFilled,
WarningFilled,
CircleCloseFilled
}
for (const [key, component] of Object.entries(icons)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus, { locale: zhCn })
// Ant Design Vue with custom theme
app.use(Antd)
// Configure Ant Design globally
app.config.globalProperties.$antdConfig = {
theme: antdTheme,
locale: zhCN,
}
app.mount('#app')
-10
View File
@@ -34,16 +34,6 @@ export const useAdminStore = defineStore('admin', {
}
},
// 批量启用/禁用用户
async batchToggleActive(userIds, isActive) {
try {
const result = await adminAPI.batchToggleActive(userIds, isActive)
return result
} catch (error) {
throw new Error(error.message || '批量操作失败')
}
},
// 批量触发打卡
async batchCheckIn(userIds) {
try {
+9
View File
@@ -91,6 +91,15 @@ export const useAuthStore = defineStore('auth', {
}
},
// 取消扫码会话
async cancelQRCodeSession(sessionId) {
try {
await authAPI.cancelQRCodeSession(sessionId)
} catch (error) {
console.error('取消会话失败:', error)
}
},
// 验证 Token
async verifyToken(token) {
try {
+7 -1
View File
@@ -107,7 +107,13 @@ export const useTaskStore = defineStore('task', {
const updatedTask = await api.task.toggleTask(taskId)
const index = this.tasks.findIndex(t => t.id === taskId)
if (index !== -1) {
this.tasks[index] = updatedTask
// 保留原任务的 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) {
+297 -9
View File
@@ -3,6 +3,9 @@
@tailwind components;
@tailwind utilities;
/* Ant Design Vue Reset (imported in main.js via import, keeping this comment for reference) */
/* The actual import is: import 'ant-design-vue/dist/reset.css' */
/* Global styles */
@layer base {
:root {
@@ -131,15 +134,6 @@
}
}
/* Element Plus customization to work with Tailwind */
.el-button {
@apply transition-smooth;
}
.el-card {
@apply transition-smooth;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
@@ -153,3 +147,297 @@
::-webkit-scrollbar-thumb {
@apply bg-gray-300 rounded hover:bg-gray-400;
}
/* ========================================
Ant Design Vue Customization
======================================== */
/* Ant Design Card - Match Material Design 3 style */
.ant-card {
border-radius: 12px;
box-shadow: 0 1px 3px 1px rgba(0, 0, 0, 0.08);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.ant-card:hover {
box-shadow: 0 4px 8px 3px rgba(0, 0, 0, 0.10);
}
/* Fluent glass effect for Ant Design cards */
.ant-card.fluent-card {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* Ant Design Button - Match MD3 rounded style */
.ant-btn {
border-radius: 24px;
font-weight: 500;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.ant-btn .anticon {
display: inline-flex;
align-items: center;
vertical-align: middle;
line-height: 1;
}
.ant-btn-primary {
background: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
.ant-btn-primary:hover {
background: #45a049;
border-color: #45a049;
box-shadow: 0 2px 4px rgba(76, 175, 80, 0.3);
}
/* Ant Design Input - Match MD3 style */
.ant-input,
.ant-input-password,
.ant-select-selector {
border-radius: 12px;
transition: all 0.2s ease;
}
.ant-input:focus,
.ant-input-password:focus,
.ant-select-focused .ant-select-selector {
border-color: var(--md-sys-color-primary);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1);
}
/* Ant Design Modal - Match MD3 style */
.ant-modal-content {
border-radius: 16px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
}
.ant-modal-header {
border-radius: 16px 16px 0 0;
}
/* Ant Design Table - Match current style */
.ant-table {
border-radius: 12px;
}
.ant-table-thead > tr > th {
background: #f5f7fa;
font-weight: 600;
}
/* Ant Design Tabs */
.ant-tabs {
color: var(--md-sys-color-on-surface);
}
.ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
color: var(--md-sys-color-primary);
}
.ant-tabs-ink-bar {
background: var(--md-sys-color-primary);
}
/* Ant Design Tag - Match current style */
.ant-tag {
border-radius: 16px;
font-weight: 500;
}
/* Ant Design Progress */
.ant-progress-success-bg,
.ant-progress-bg {
background-color: var(--md-sys-color-primary);
}
/* Ant Design Descriptions */
.ant-descriptions-bordered .ant-descriptions-item-label {
background: #f5f7fa;
}
/* Ant Design Statistic */
.ant-statistic-title {
color: #64748b;
}
.ant-statistic-content {
color: var(--md-sys-color-on-surface);
}
/* Ant Design Drawer */
.ant-drawer-content {
border-radius: 16px 0 0 16px;
}
.ant-drawer-header {
border-bottom: 1px solid #e5e7eb;
}
/* Ant Design Alert - Match current style */
.ant-alert {
border-radius: 12px;
}
/* Ant Design Pagination */
.ant-pagination-item-active {
border-color: var(--md-sys-color-primary);
}
.ant-pagination-item-active a {
color: var(--md-sys-color-primary);
}
/* Responsive utilities for Ant Design */
@media (max-width: 768px) {
.ant-modal {
max-width: 100vw !important;
margin: 0;
}
.ant-modal-content {
border-radius: 0;
}
.ant-drawer-content-wrapper {
width: 280px !important;
}
/* 移动端表格优化 */
.ant-table {
font-size: 13px;
}
.ant-table-thead > tr > th {
padding: 8px 12px;
font-size: 13px;
}
.ant-table-tbody > tr > td {
padding: 8px 12px;
font-size: 13px;
}
/* 移动端表单优化 */
.ant-form-item {
margin-bottom: 16px;
}
.ant-form-item-label > label {
font-size: 13px;
}
/* 移动端卡片优化 */
.ant-card {
border-radius: 8px;
}
.ant-card-head {
padding: 12px 16px;
}
.ant-card-body {
padding: 16px;
}
/* 移动端按钮优化 */
.ant-btn {
height: 36px;
padding: 4px 15px;
font-size: 14px;
}
.ant-btn-lg {
height: 40px;
}
/* 移动端描述列表优化 */
.ant-descriptions-item-label,
.ant-descriptions-item-content {
padding: 8px 12px;
font-size: 13px;
}
}
/* 小屏手机优化 */
@media (max-width: 576px) {
/* 更小的内边距 */
.ant-card-head {
padding: 10px 12px;
font-size: 15px;
}
.ant-card-body {
padding: 12px;
}
/* 更小的表格 */
.ant-table {
font-size: 12px;
}
.ant-table-thead > tr > th,
.ant-table-tbody > tr > td {
padding: 6px 8px;
font-size: 12px;
}
/* 更小的按钮 */
.ant-btn {
height: 32px;
padding: 4px 12px;
font-size: 13px;
}
.ant-btn-lg {
height: 36px;
font-size: 14px;
}
/* Tag 优化 */
.ant-tag {
font-size: 11px;
padding: 0 6px;
}
/* 选择器优化 */
.ant-select {
font-size: 13px;
}
.ant-select-selection-item {
font-size: 13px;
}
}
/* 横屏优化 */
@media (max-height: 600px) and (orientation: landscape) {
.ant-modal-body {
max-height: 60vh;
overflow-y: auto;
}
.ant-drawer-body {
padding: 12px 16px;
}
}
/* 平板优化 */
@media (min-width: 768px) and (max-width: 992px) {
.ant-card-body {
padding: 20px;
}
.ant-table-thead > tr > th,
.ant-table-tbody > tr > td {
padding: 10px 14px;
}
}
+114 -95
View File
@@ -1,67 +1,69 @@
<template>
<Layout>
<div class="dashboard-container">
<el-row :gutter="20">
<a-row :gutter="[20, 20]">
<!-- Token 状态卡片 -->
<el-col :span="24">
<el-card class="status-card">
<template #header>
<a-col :xs="24" :sm="24" :md="24">
<a-card class="status-card">
<template #title>
<div class="card-header">
<el-icon><Key /></el-icon>
<KeyOutlined />
<span>Token 状态</span>
</div>
</template>
<div v-if="tokenStatusLoading" class="loading-container">
<el-skeleton :rows="3" animated />
<a-skeleton :active="true" :paragraph="{ rows: 3 }" />
</div>
<div v-else-if="tokenStatus" class="token-status">
<el-descriptions :column="2" border>
<el-descriptions-item label="Token 状态">
<el-tag :type="tokenStatus.is_valid ? 'success' : 'danger'">
<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 ? '有效' : '无效' }}
</el-tag>
</el-descriptions-item>
</a-tag>
</a-descriptions-item>
<el-descriptions-item label="过期时间">
<a-descriptions-item label="过期时间">
{{ formatExpireTime }}
</el-descriptions-item>
</a-descriptions-item>
<el-descriptions-item label="剩余时间">
<el-tag v-if="tokenStatus.is_valid" :type="tokenStatus.expiring_soon ? 'warning' : 'success'">
<a-descriptions-item label="剩余时间">
<a-tag v-if="tokenStatus.is_valid" :color="tokenStatus.expiring_soon ? 'warning' : 'success'">
{{ formatRemainTime }}
</el-tag>
<el-tag v-else type="danger">已过期</el-tag>
</el-descriptions-item>
</a-tag>
<a-tag v-else color="error">已过期</a-tag>
</a-descriptions-item>
<el-descriptions-item label="即将过期">
<el-tag :type="tokenStatus.expiring_soon ? 'warning' : 'success'">
<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 ? '是' : '否' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</a-tag>
</a-descriptions-item>
</a-descriptions>
<el-alert
<a-alert
v-if="tokenStatus.expiring_soon"
title="Token 即将过期"
message="Token 即将过期"
description="您的 Token 将在 30 分钟内过期,请在过期后及时刷新 Token!"
type="warning"
:closable="false"
show-icon
style="margin-top: 15px"
>
您的 Token 将在 30 分钟内过期请及时重新登录
</el-alert>
/>
</div>
</el-card>
</el-col>
</a-card>
</a-col>
<!-- 手动打卡卡片 -->
<el-col :span="24" style="margin-top: 20px">
<el-card>
<template #header>
<a-col :xs="24" :sm="24" :md="24">
<a-card>
<template #title>
<div class="card-header">
<el-icon><Calendar /></el-icon>
<CalendarOutlined />
<span>手动打卡</span>
</div>
</template>
@@ -70,101 +72,103 @@
<p class="hint">选择任务并点击下方按钮立即执行打卡操作</p>
<!-- 任务选择 -->
<el-select
v-model="selectedTaskId"
<a-select
v-model:value="selectedTaskId"
placeholder="请选择要打卡的任务"
:loading="taskStore.loading"
style="width: 100%; max-width: 400px; margin-bottom: 20px"
>
<el-option
v-for="task in taskStore.activeTasks"
<a-select-option
v-for="task in taskStore.tasks"
:key="task.id"
:label="task.name"
:value="task.id"
>
<div style="display: flex; justify-content: space-between">
<div style="display: flex; justify-content: space-between; align-items: center">
<span>{{ task.name }}</span>
<el-tag size="small" type="success">启用</el-tag>
<a-tag size="small" :color="task.is_active ? 'success' : 'default'">
{{ task.is_active ? '启用' : '禁用' }}
</a-tag>
</div>
</el-option>
</el-select>
</a-select-option>
</a-select>
<el-button
<a-button
type="primary"
size="large"
:loading="checkInLoading"
:disabled="!selectedTaskId"
:icon="Calendar"
@click="handleCheckIn"
>
<template #icon><CalendarOutlined /></template>
{{ checkInLoading ? '打卡中...' : '立即打卡' }}
</el-button>
</a-button>
<div v-if="lastCheckIn" class="last-check-in">
<el-divider />
<a-divider />
<p class="label">上次打卡</p>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="时间">
<a-descriptions :column="{ xs: 1, sm: 1, md: 2 }" bordered size="small">
<a-descriptions-item label="时间">
{{ formatDateTime(lastCheckIn.check_in_time) }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag
:type="lastCheckIn.status === 'success' ? 'success' :
lastCheckIn.status === 'out_of_time' ? 'info' :
lastCheckIn.status === 'unknown' ? 'warning' : 'danger'"
</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' ? '异常' : '失败'
}}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="打卡响应" :span="2">
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="打卡响应" :span="2">
{{ lastCheckIn.response_text || lastCheckIn.error_message || '-' }}
</el-descriptions-item>
</el-descriptions>
</a-descriptions-item>
</a-descriptions>
</div>
</div>
</el-card>
</el-col>
</a-card>
</a-col>
<!-- 用户信息卡片 -->
<el-col :span="24" style="margin-top: 20px">
<el-card>
<template #header>
<a-col :xs="24" :sm="24" :md="24">
<a-card>
<template #title>
<div class="card-header">
<el-icon><User /></el-icon>
<UserOutlined />
<span>个人信息</span>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="用户名">
<a-descriptions :column="{ xs: 1, sm: 1, md: 2 }" bordered>
<a-descriptions-item label="用户名">
{{ authStore.user?.alias }}
</el-descriptions-item>
<el-descriptions-item label="角色">
<el-tag :type="authStore.isAdmin ? 'danger' : 'primary'">
</a-descriptions-item>
<a-descriptions-item label="角色">
<a-tag :color="authStore.isAdmin ? 'error' : 'blue'">
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="邮箱" :span="2">
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="邮箱" :span="2">
{{ authStore.user?.email || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="注册时间" :span="2">
</a-descriptions-item>
<a-descriptions-item label="注册时间" :span="2">
{{ formatDateTime(authStore.user?.created_at, false) }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
</a-row>
</div>
</Layout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Calendar, Key, User } from '@element-plus/icons-vue'
import { message } from 'ant-design-vue'
import { CalendarOutlined, KeyOutlined, UserOutlined } from '@ant-design/icons-vue'
import Layout from '@/components/Layout.vue'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
@@ -218,7 +222,7 @@ const fetchTokenStatus = async () => {
try {
await userStore.fetchTokenStatus()
} catch (error) {
ElMessage.error(error.message || '获取 Token 状态失败')
message.error(error.message || '获取 Token 状态失败')
} finally {
tokenStatusLoading.value = false
}
@@ -227,7 +231,7 @@ const fetchTokenStatus = async () => {
// 手动打卡
const handleCheckIn = async () => {
if (!selectedTaskId.value) {
ElMessage.warning('请先选择要打卡的任务')
message.warning('请先选择要打卡的任务')
return
}
@@ -240,21 +244,21 @@ const handleCheckIn = async () => {
// 获取 record_id
const recordId = result.record_id
if (!recordId) {
ElMessage.error('打卡请求失败:未获取到记录ID')
message.error('打卡请求失败:未获取到记录ID')
checkInLoading.value = false
return
}
// 如果初始状态就是失败,显示错误并刷新记录
if (result.status === 'failure') {
ElMessage.error(result.message || '打卡失败')
message.error(result.message || '打卡失败')
checkInLoading.value = false
checkInStore.fetchMyRecords({ limit: 1 })
return
}
// 显示提示消息
ElMessage.info('打卡任务已启动,正在后台处理...')
message.info('打卡任务已启动,正在后台处理...')
// 用于存储 interval ID,以便在超时时清理
let pollIntervalId = null
@@ -271,12 +275,12 @@ const handleCheckIn = async () => {
if (status.status === 'success') {
// 打卡成功
ElMessage.success('打卡成功!')
message.success('打卡成功!')
checkInStore.fetchMyRecords({ limit: 1 })
} else {
// 打卡失败或其他状态 (failure, out_of_time, unknown 等)
const errorMsg = status.error_message || status.response_text || '打卡失败'
ElMessage.error(errorMsg)
message.error(errorMsg)
checkInStore.fetchMyRecords({ limit: 1 })
}
}
@@ -286,7 +290,7 @@ const handleCheckIn = async () => {
console.error('轮询状态失败:', error)
clearInterval(pollIntervalId)
checkInLoading.value = false
ElMessage.error('查询打卡状态失败')
message.error('查询打卡状态失败')
}
}, 2000) // 每 2 秒查询一次
@@ -295,14 +299,14 @@ const handleCheckIn = async () => {
if (checkInLoading.value) {
clearInterval(pollIntervalId)
checkInLoading.value = false
ElMessage.warning('打卡处理时间较长,请稍后查看打卡记录')
message.warning('打卡处理时间较长,请稍后查看打卡记录')
}
}, 30000)
} catch (error) {
console.error('启动打卡失败:', error)
checkInLoading.value = false
ElMessage.error(error.message || '启动打卡任务失败')
message.error(error.message || '启动打卡任务失败')
}
}
@@ -313,12 +317,14 @@ onMounted(async () => {
// 加载任务列表
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) {
ElMessage.error('加载任务列表失败')
message.error(error.message || '加载任务列表失败')
}
})
</script>
@@ -371,4 +377,17 @@ onMounted(async () => {
.status-card {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
/* 修复按钮图标对齐 */
:deep(.ant-btn) {
display: inline-flex;
align-items: center;
justify-content: center;
}
:deep(.ant-btn .anticon) {
display: inline-flex;
align-items: center;
vertical-align: middle;
}
</style>
+215 -84
View File
@@ -1,7 +1,9 @@
<template>
<div class="login-container">
<el-card class="login-card">
<template #header>
<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>
@@ -10,110 +12,108 @@
<!-- 登录模式切换 -->
<div class="mode-switch">
<el-segmented v-model="loginMode" :options="loginModeOptions" block />
<a-segmented v-model:value="loginMode" :options="loginModeOptions" block />
</div>
<!-- QR码登录表单 -->
<el-form
<a-form
v-if="loginMode === 'qrcode'"
:model="qrcodeForm"
:rules="qrcodeRules"
ref="qrcodeFormRef"
label-width="0"
layout="vertical"
@submit.prevent="handleQRCodeLogin"
>
<el-form-item prop="alias">
<el-input
v-model="qrcodeForm.alias"
<a-form-item name="alias">
<a-input
v-model:value="qrcodeForm.alias"
placeholder="请输入您的用户名"
size="large"
clearable
allow-clear
@keyup.enter="handleQRCodeLogin"
>
<template #prefix>
<el-icon><User /></el-icon>
<UserOutlined />
</template>
</el-input>
</el-form-item>
</a-input>
</a-form-item>
<el-form-item>
<el-button
<a-form-item>
<a-button
type="primary"
size="large"
class="login-button"
block
:loading="loading"
@click="handleQRCodeLogin"
>
{{ loading ? '正在登录...' : '扫码登录/注册' }}
</el-button>
</el-form-item>
</el-form>
</a-button>
</a-form-item>
</a-form>
<!-- 别名+密码登录表单 -->
<el-form
<a-form
v-else
:model="passwordForm"
:rules="passwordRules"
ref="passwordFormRef"
label-width="0"
layout="vertical"
>
<el-form-item prop="alias">
<el-input
v-model="passwordForm.alias"
<a-form-item name="alias">
<a-input
v-model:value="passwordForm.alias"
placeholder="请输入您的用户名"
size="large"
clearable
allow-clear
>
<template #prefix>
<el-icon><User /></el-icon>
<UserOutlined />
</template>
</el-input>
</el-form-item>
</a-input>
</a-form-item>
<el-form-item prop="password">
<el-input
v-model="passwordForm.password"
type="password"
<a-form-item name="password">
<a-input-password
v-model:value="passwordForm.password"
placeholder="请输入密码"
size="large"
show-password
clearable
@keyup.enter="handlePasswordLogin"
>
<template #prefix>
<el-icon><Key /></el-icon>
<KeyOutlined />
</template>
</el-input>
</el-form-item>
</a-input-password>
</a-form-item>
<el-form-item>
<el-button
<a-form-item>
<a-button
type="primary"
size="large"
class="login-button"
block
:loading="loading"
@click="handlePasswordLogin"
>
{{ loading ? '登录中...' : '登录' }}
</el-button>
</el-form-item>
</a-button>
</a-form-item>
<div class="tips-link">
<el-link type="info" @click="loginMode = 'qrcode'">
<a @click="loginMode = 'qrcode'" class="link-text">
没有密码使用扫码登录
</el-link>
</a>
</div>
</el-form>
</a-form>
<div class="tips">
<el-alert
:title="loginMode === 'qrcode' ? '扫码登录提示' : '密码登录提示'"
<a-alert
:message="loginMode === 'qrcode' ? '扫码登录提示' : '密码登录提示'"
type="info"
:closable="false"
show-icon
>
<template #description>
<template v-if="loginMode === 'qrcode'">
<p>1. 输入您的用户名用于标识身份</p>
<p>1. 输入您的用户名(用于标识身份)</p>
<p>2. 点击"扫码登录/注册"按钮</p>
<p>3. 使用手机 QQ 扫描弹出的二维码</p>
<p>4. 扫码成功后即可登录系统</p>
@@ -124,9 +124,12 @@
<p>2. 点击"登录"按钮直接登录</p>
<p>3. 首次使用请先扫码登录/注册然后在设置中设置密码</p>
</template>
</el-alert>
</template>
</a-alert>
</div>
</el-card>
</a-card>
</a-col>
</a-row>
<!-- QR 码弹窗 -->
<QRCodeModal
@@ -141,8 +144,8 @@
<script setup>
import { ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Key } from '@element-plus/icons-vue'
import { 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'
@@ -209,9 +212,7 @@ const handleQRCodeLogin = async () => {
if (!qrcodeFormRef.value) return
try {
const valid = await qrcodeFormRef.value.validate()
if (!valid) return
await qrcodeFormRef.value.validate()
// 显示 QR 码弹窗
qrcodeVisible.value = true
} catch (error) {
@@ -224,8 +225,7 @@ const handlePasswordLogin = async () => {
if (!passwordFormRef.value) return
try {
const valid = await passwordFormRef.value.validate()
if (!valid) return
await passwordFormRef.value.validate()
loading.value = true
@@ -242,18 +242,39 @@ const handlePasswordLogin = async () => {
role: response.role || 'user',
is_approved: response.is_approved !== false,
}
authStore.setAuth(response.authorization, user)
// 如果没有 authorization(测试账号),使用 user_id 作为认证凭据
const authToken = response.authorization || `user_id:${response.user_id}`
authStore.setAuth(authToken, user)
// 只有当有真实 authorization 时才获取完整用户信息
if (response.authorization) {
try {
await authStore.fetchCurrentUser()
} catch (err) {
console.warn('获取完整用户信息失败,使用基本信息:', err)
// 即使失败也继续登录流程
}
} else {
// 没有 authorization 的测试账号,提示用户需要扫码绑定
message.info({
content: '您正在使用密码登录模式。如需使用打卡功能,请先扫码绑定 QQ。',
duration: 5,
})
}
// 如果有 Token 警告,显示提示
if (response.token_warning && response.warning_message) {
ElMessage({
type: 'warning',
duration: 5000,
showClose: true,
message: response.warning_message,
message.warning({
content: response.warning_message,
duration: 5,
})
} else if (response.authorization) {
// 只有有 token 的用户才显示"欢迎回来"
message.success(`欢迎回来,${response.alias}`)
} else {
ElMessage.success(`欢迎回来,${response.alias}`)
// 测试账号登录成功提示
message.success(`登录成功,${response.alias}`)
}
// 跳转到重定向页面或仪表盘
@@ -273,36 +294,36 @@ const handlePasswordLogin = async () => {
}
// 处理密码登录错误
const handlePasswordLoginError = (message) => {
if (!message) {
ElMessage.error('登录失败,请稍后重试')
const handlePasswordLoginError = (msg) => {
if (!msg) {
message.error('登录失败,请稍后重试')
return
}
// 用户不存在或密码错误
if (message.includes('用户名或密码错误')) {
ElMessage.error('用户名或密码错误')
if (msg.includes('用户名或密码错误')) {
message.error('用户名或密码错误')
return
}
// 未设置密码
if (message.includes('未设置密码')) {
ElMessage.warning('该账户未设置密码,请使用扫码登录')
if (msg.includes('未设置密码')) {
message.warning('该账户未设置密码,请使用扫码登录')
return
}
// 用户不存在
if (message.includes('用户不存在')) {
ElMessage.error('用户不存在,请检查用户名或使用扫码登录注册')
if (msg.includes('用户不存在')) {
message.error('用户不存在,请检查用户名或使用扫码登录注册')
return
}
// 其他错误
ElMessage.error(message || '登录失败,请稍后重试')
message.error(msg || '登录失败,请稍后重试')
}
const handleLoginSuccess = (user) => {
ElMessage.success(`欢迎回来,${user.alias}`)
message.success(`欢迎回来,${user.alias}`)
// 跳转到重定向页面或仪表盘
const redirect = route.query.redirect || '/dashboard'
@@ -310,24 +331,33 @@ const handleLoginSuccess = (user) => {
}
const handleLoginError = (error) => {
ElMessage.error(error.message || '登录失败')
message.error(error.message || '登录失败')
}
</script>
<style scoped>
.login-container {
width: 100%;
height: 100%;
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;
}
.login-card {
width: 450px;
border-radius: 10px;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
width: 100%;
margin: 20px 0;
}
.card-header {
@@ -350,20 +380,26 @@ const handleLoginError = (error) => {
margin-bottom: 20px;
}
.login-button {
width: 100%;
}
.tips-link {
text-align: center;
margin-top: 10px;
}
.link-text {
color: #2196f3;
cursor: pointer;
text-decoration: none;
}
.link-text:hover {
text-decoration: underline;
}
.tips {
margin-top: 20px;
}
.tips p {
.tips :deep(p) {
margin: 5px 0;
font-size: 14px;
line-height: 1.5;
@@ -376,4 +412,99 @@ const handleLoginError = (error) => {
color: #606266;
font-weight: 500;
}
/* 确保 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>
+3 -3
View File
@@ -1,10 +1,10 @@
<template>
<div class="not-found-container">
<el-result icon="warning" title="404" sub-title="抱歉您访问的页面不存在">
<a-result status="404" title="404" sub-title="抱歉您访问的页面不存在">
<template #extra>
<el-button type="primary" @click="goHome">返回首页</el-button>
<a-button type="primary" @click="goHome">返回首页</a-button>
</template>
</el-result>
</a-result>
</div>
</template>
+211 -110
View File
@@ -16,55 +16,148 @@
<p>您已成功注册账户信息如下</p>
</div>
<div class="info-table">
<div class="info-row">
<div class="info-label">用户名</div>
<div class="info-value">{{ user?.alias || '加载中...' }}</div>
</div>
<div class="info-row">
<div class="info-label">注册时间</div>
<div class="info-value">{{ formatDate(user?.created_at) }}</div>
</div>
<div class="info-row">
<div class="info-label">审批状态</div>
<div class="info-value">
<span class="status-tag warning">待审批</span>
</div>
</div>
</div>
<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>
<div class="alert-box">
<div class="alert-title"> 审批说明</div>
<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>
</div>
</template>
</a-alert>
<div class="actions">
<button class="btn btn-primary" @click="checkStatus">
<a-button type="primary" size="large" @click="checkStatus">
<template #icon><ReloadOutlined /></template>
刷新状态
</button>
<button class="btn btn-default" @click="logout">
</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>
退出登录
</button>
</a-button>
</div>
</div>
</div>
<!-- 完善信息弹窗 -->
<a-modal
v-model:open="showProfileModal"
title="完善个人信息"
:confirm-loading="profileLoading"
@ok="handleUpdateProfile"
@cancel="resetProfileForm"
width="500px"
>
<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 {
@@ -72,14 +165,99 @@ const checkStatus = async () => {
user.value = response
if (response.is_approved) {
alert('恭喜!您的账户已通过审批')
message.success('恭喜!您的账户已通过审批')
router.push('/dashboard')
} else {
alert('仍在等待审批中')
message.info('仍在等待审批中')
}
} catch (error) {
console.error('获取状态失败:', error)
alert('获取状态失败:' + (error.message || '未知错误'))
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: '',
}
}
@@ -95,6 +273,7 @@ const formatDate = (dateStr) => {
}
onMounted(() => {
loadUserInfo()
checkStatus()
})
</script>
@@ -160,66 +339,10 @@ onMounted(() => {
margin-bottom: 30px;
}
.info-table {
background: #f9f9f9;
border: 1px solid #ddd;
border-radius: 5px;
overflow: hidden;
.mb-6 {
margin-bottom: 30px;
}
.info-row {
display: flex;
border-bottom: 1px solid #ddd;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
flex: 0 0 120px;
padding: 15px 20px;
background: #f5f5f5;
font-weight: bold;
color: #303133;
border-right: 1px solid #ddd;
}
.info-value {
flex: 1;
padding: 15px 20px;
color: #606266;
}
.status-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
}
.status-tag.warning {
background: #fff3cd;
color: #856404;
border: 1px solid #ffc107;
}
.alert-box {
background: #e7f3ff;
border-left: 4px solid #409eff;
padding: 20px;
margin-bottom: 30px;
border-radius: 4px;
}
.alert-title {
font-weight: bold;
margin-bottom: 10px;
color: #303133;
}
.tips-list {
text-align: left;
padding-left: 20px;
@@ -236,34 +359,12 @@ onMounted(() => {
display: flex;
gap: 15px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 12px 30px;
border: none;
border-radius: 5px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: #409eff;
color: white;
}
.btn-primary:hover {
background: #66b1ff;
}
.btn-default {
background: #f5f5f5;
color: #606266;
border: 1px solid #dcdfe6;
}
.btn-default:hover {
background: #e8e8e8;
.form-hint {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
</style>
+125 -60
View File
@@ -1,107 +1,138 @@
<template>
<Layout>
<div class="records-container">
<el-card>
<template #header>
<a-card>
<template #title>
<div class="card-header">
<div>
<el-icon><List /></el-icon>
<UnorderedListOutlined />
<span>我的打卡记录</span>
</div>
<el-button type="primary" :icon="Refresh" @click="handleRefresh">
<a-button type="primary" @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
刷新
</el-button>
</a-button>
</div>
</template>
<!-- 统计信息 -->
<div class="stats-container">
<el-row :gutter="20">
<el-col :span="8">
<el-statistic title="总打卡次数" :value="total" />
</el-col>
<el-col :span="8">
<el-statistic
<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"
:value-style="{ color: '#67c23a' }"
/>
</el-col>
<el-col :span="8">
<el-statistic
</a-col>
<a-col :xs="24" :sm="8" :md="8">
<a-statistic
title="成功率"
:value="parseFloat(checkInStore.successRate)"
suffix="%"
:precision="2"
/>
</el-col>
</el-row>
</a-col>
</a-row>
</div>
<el-divider />
<a-divider />
<!-- 记录表格 -->
<el-table
:data="checkInStore.myRecords"
v-loading="checkInStore.loading"
stripe
border
<!-- 桌面端表格 -->
<a-table
v-if="!isMobile"
:dataSource="checkInStore.myRecords"
:columns="columns"
:loading="checkInStore.loading"
:pagination="false"
:row-key="record => record.id"
:scroll="{ x: 'max-content' }"
bordered
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="check_in_time" label="打卡时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.check_in_time) }}
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'check_in_time'">
{{ formatDateTime(record.check_in_time) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag v-if="row.status === 'success'" type="success"> 打卡成功</el-tag>
<el-tag v-else-if="row.status === 'out_of_time'" type="info">🕐 时间范围外</el-tag>
<el-tag v-else-if="row.status === 'unknown'" type="warning"> 打卡异常</el-tag>
<el-tag v-else type="danger"> 打卡失败</el-tag>
<template 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>
</el-table-column>
<el-table-column prop="trigger_type" label="触发方式" width="120">
<template #default="{ row }">
<el-tag v-if="row.trigger_type === 'manual'" type="primary">手动</el-tag>
<el-tag v-else-if="row.trigger_type === 'scheduled'" type="info">定时</el-tag>
<el-tag v-else-if="row.trigger_type === 'admin'" type="warning">管理员</el-tag>
<el-tag v-else>{{ row.trigger_type }}</el-tag>
<template 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>
</el-table-column>
</template>
</a-table>
<el-table-column prop="response_text" label="消息" min-width="200" show-overflow-tooltip />
</el-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">
<el-pagination
v-model:current-page="checkInStore.currentPage"
v-model:page-size="checkInStore.pageSize"
<a-pagination
v-model:current="checkInStore.currentPage"
v-model:pageSize="checkInStore.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange"
@size-change="handleSizeChange"
:pageSizeOptions="['10', '20', '50', '100']"
show-size-changer
show-quick-jumper
:show-total="total => `${total} 条记录`"
@change="handlePageChange"
@showSizeChange="handleSizeChange"
/>
</div>
</el-card>
</a-card>
</div>
</Layout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { List, Refresh } from '@element-plus/icons-vue'
import { 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)
@@ -109,13 +140,47 @@ 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()
ElMessage.success('刷新成功')
message.success('刷新成功')
} catch (error) {
ElMessage.error(error.message || '刷新失败')
message.error(error.message || '刷新失败')
}
}
+79 -82
View File
@@ -1,144 +1,141 @@
<template>
<Layout>
<div class="min-h-screen bg-gradient-to-br from-blue-50 via-white to-green-50 p-6">
<div class="settings-view">
<div class="max-w-4xl mx-auto">
<h1 class="text-3xl font-bold text-gray-800 mb-6">个人设置</h1>
<!-- 基本信息卡片 -->
<div class="md3-card p-6 mb-6">
<h2 class="text-xl font-bold text-gray-800 mb-4 flex items-center">
<el-icon class="mr-2"><User /></el-icon>
<UserOutlined class="mr-2" />
基本信息
</h2>
<el-descriptions :column="1" border>
<el-descriptions-item label="用户ID">{{ user?.id }}</el-descriptions-item>
<el-descriptions-item label="当前名">{{ user?.alias }}</el-descriptions-item>
<el-descriptions-item label="角色">
<el-tag :type="user?.role === 'admin' ? 'danger' : 'success'">
<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' ? '管理员' : '普通用户' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="密码状态">
<el-tag :type="hasPassword ? 'success' : 'warning'">
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="密码状态">
<a-tag :color="hasPassword ? 'success' : 'warning'">
{{ hasPassword ? '已设置' : '未设置' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatDate(user?.created_at) }}
</el-descriptions-item>
</el-descriptions>
</a-descriptions-item>
</a-descriptions>
</div>
<!-- 修改邮箱 -->
<div class="md3-card p-6 mb-6">
<h2 class="text-xl font-bold text-gray-800 mb-4 flex items-center">
<el-icon class="mr-2"><Edit /></el-icon>
<EditOutlined class="mr-2" />
修改个人信息
</h2>
<el-form
<a-form
:model="profileForm"
:rules="profileRules"
ref="profileFormRef"
label-width="100px"
layout="vertical"
>
<el-form-item label="邮箱" prop="email">
<el-input
v-model="profileForm.email"
<a-form-item label="邮箱" name="email">
<a-input
v-model:value="profileForm.email"
placeholder="请输入邮箱地址(可选)"
clearable
allow-clear
/>
</el-form-item>
</a-form-item>
<el-alert
title="用户名无法修改"
<a-alert
message="用户名无法修改"
description="用户名只能由管理员修改,如需修改请联系管理员"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
>
<p>用户名只能由管理员修改如需修改请联系管理员</p>
</el-alert>
style="margin-bottom: 24px"
/>
<el-form-item>
<el-button
<a-form-item style="margin-top: 8px">
<a-space>
<a-button
type="primary"
:loading="profileLoading"
@click="handleUpdateProfile"
>
保存
</el-button>
<el-button @click="resetProfileForm">重置</el-button>
</el-form-item>
</el-form>
</a-button>
<a-button @click="resetProfileForm">重置</a-button>
</a-space>
</a-form-item>
</a-form>
</div>
<!-- 设置/修改密码 -->
<div class="md3-card p-6">
<h2 class="text-xl font-bold text-gray-800 mb-4 flex items-center">
<el-icon class="mr-2"><Key /></el-icon>
<KeyOutlined class="mr-2" />
{{ hasPassword ? '修改密码' : '设置密码' }}
</h2>
<el-alert
<a-alert
v-if="!hasPassword"
title="您还未设置密码"
message="您还未设置密码"
description="设置密码后,您可以使用用户名+密码的方式快速登录"
type="warning"
description="设置密码后,您可以使用别名+密码的方式快速登录"
class="mb-4"
show-icon
:closable="false"
/>
<el-form
<a-form
:model="passwordForm"
label-width="120px"
layout="vertical"
>
<el-form-item
<a-form-item
v-if="hasPassword"
label="当前密码"
>
<el-input
v-model="passwordForm.currentPassword"
type="password"
<a-input-password
v-model:value="passwordForm.currentPassword"
placeholder="请输入当前密码"
show-password
clearable
allow-clear
/>
</el-form-item>
</a-form-item>
<el-form-item label="新密码">
<el-input
v-model="passwordForm.newPassword"
type="password"
<a-form-item label="新密码">
<a-input-password
v-model:value="passwordForm.newPassword"
placeholder="请输入新密码(至少6个字符)"
show-password
clearable
allow-clear
/>
</el-form-item>
</a-form-item>
<el-form-item label="确认新密码">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
<a-form-item label="确认新密码">
<a-input-password
v-model:value="passwordForm.confirmPassword"
placeholder="请再次输入新密码"
show-password
clearable
allow-clear
/>
</el-form-item>
</a-form-item>
<el-form-item>
<el-button
<a-form-item style="margin-top: 8px">
<a-space>
<a-button
type="primary"
:loading="passwordLoading"
@click="handleUpdatePassword"
>
{{ hasPassword ? '修改密码' : '设置密码' }}
</el-button>
<el-button @click="resetPasswordForm">重置</el-button>
</el-form-item>
</el-form>
</a-button>
<a-button @click="resetPasswordForm">重置</a-button>
</a-space>
</a-form-item>
</a-form>
</div>
</div>
</div>
@@ -147,8 +144,8 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { User, Edit, Key } from '@element-plus/icons-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'
@@ -186,7 +183,7 @@ const loadUserInfo = async () => {
// 从后端返回的数据中获取密码状态
hasPassword.value = user.value.has_password || false
} catch (error) {
ElMessage.error(error.message || '加载用户信息失败')
message.error(error.message || '加载用户信息失败')
}
}
@@ -202,12 +199,12 @@ const handleUpdateProfile = async () => {
email: profileForm.value.email || null,
})
ElMessage.success('个人信息修改成功')
message.success('个人信息修改成功')
await loadUserInfo()
} catch (error) {
if (error.errors) return // 验证错误
if (error.errorFields) return // 验证错误
const errorMsg = error.response?.data?.detail || error.message || '修改失败'
ElMessage.error(errorMsg)
message.error(errorMsg)
} finally {
profileLoading.value = false
}
@@ -224,27 +221,27 @@ const handleUpdatePassword = async () => {
try {
// 手动验证
if (hasPassword.value && !passwordForm.value.currentPassword) {
ElMessage.error('请输入当前密码')
message.error('请输入当前密码')
return
}
if (!passwordForm.value.newPassword) {
ElMessage.error('请输入新密码')
message.error('请输入新密码')
return
}
if (passwordForm.value.newPassword.length < 6) {
ElMessage.error('密码至少需要6个字符')
message.error('密码至少需要6个字符')
return
}
if (!passwordForm.value.confirmPassword) {
ElMessage.error('请再次输入新密码')
message.error('请再次输入新密码')
return
}
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
ElMessage.error('两次输入的密码不一致')
message.error('两次输入的密码不一致')
return
}
@@ -260,12 +257,12 @@ const handleUpdatePassword = async () => {
await userAPI.updateProfile(updateData)
ElMessage.success(hasPassword.value ? '密码修改成功' : '密码设置成功')
message.success(hasPassword.value ? '密码修改成功' : '密码设置成功')
hasPassword.value = true
resetPasswordForm()
} catch (error) {
const errorMsg = error.response?.data?.detail || error.message || '操作失败'
ElMessage.error(errorMsg)
message.error(errorMsg)
} finally {
passwordLoading.value = false
}
+90 -87
View File
@@ -1,18 +1,17 @@
<template>
<Layout>
<div class="min-h-screen bg-gradient-to-br from-purple-50 via-white to-blue-50 p-6">
<div class="task-records-view">
<div class="max-w-7xl mx-auto">
<!-- Header -->
<div class="mb-8 animate-fade-in">
<button
<div class="mb-8">
<a-button
@click="router.back()"
class="mb-4 flex items-center text-gray-600 hover:text-gray-900 transition-colors"
type="link"
class="mb-4 flex items-center"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<template #icon><LeftOutlined /></template>
返回任务列表
</button>
</a-button>
<div v-if="currentTask" class="fluent-card p-6">
<div class="flex items-start justify-between">
@@ -20,102 +19,104 @@
<h1 class="text-3xl font-bold text-gradient mb-2">{{ currentTask.name || '未命名任务' }}</h1>
<div class="flex items-center gap-4 text-sm text-gray-600">
<span class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
<NumberOutlined class="mr-1" />
接龙 ID: {{ getThreadId(currentTask) }}
</span>
<span :class="currentTask.is_active ? 'status-success' : 'status-info'">
<a-tag :color="currentTask.is_active ? 'success' : 'default'">
{{ currentTask.is_active ? '启用中' : '已禁用' }}
</span>
</a-tag>
</div>
</div>
<button
<a-button
type="primary"
:loading="checkInLoading"
@click="handleManualCheckIn"
:disabled="checkInLoading"
class="md3-button-filled"
>
{{ checkInLoading ? '打卡中...' : '立即打卡' }}
</button>
</a-button>
</div>
</div>
</div>
<!-- Stats Summary -->
<div class="grid grid-cols-1 md:grid-cols-6 gap-4 mb-6">
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="12" :sm="8" :md="4">
<div class="fluent-card p-5 animate-slide-up">
<p class="text-sm text-gray-600 mb-1">总打卡次数</p>
<p class="text-2xl font-bold text-gray-800">{{ recordStats.total }}</p>
</div>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<div class="fluent-card p-5 animate-slide-up" style="animation-delay: 0.05s">
<p class="text-sm text-gray-600 mb-1">成功次数</p>
<p class="text-2xl font-bold text-green-600">{{ recordStats.success }}</p>
</div>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<div class="fluent-card p-5 animate-slide-up" style="animation-delay: 0.1s">
<p class="text-sm text-gray-600 mb-1">时间范围外</p>
<p class="text-2xl font-bold text-blue-600">{{ recordStats.outOfTime }}</p>
</div>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<div class="fluent-card p-5 animate-slide-up" style="animation-delay: 0.15s">
<p class="text-sm text-gray-600 mb-1">失败次数</p>
<p class="text-2xl font-bold text-red-600">{{ recordStats.failure }}</p>
</div>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<div class="fluent-card p-5 animate-slide-up" style="animation-delay: 0.2s">
<p class="text-sm text-gray-600 mb-1">异常次数</p>
<p class="text-2xl font-bold text-orange-600">{{ recordStats.unknown }}</p>
</div>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<div class="fluent-card p-5 animate-slide-up" style="animation-delay: 0.25s">
<p class="text-sm text-gray-600 mb-1">成功率</p>
<p class="text-2xl font-bold text-purple-600">{{ recordStats.successRate }}%</p>
</div>
</div>
</a-col>
</a-row>
<!-- Filters -->
<div class="fluent-card p-4 mb-6">
<div class="flex flex-wrap items-center gap-4">
<a-space wrap :size="[16, 16]">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700">状态筛选:</span>
<el-radio-group v-model="filterStatus" size="small" @change="handleFilterChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="success">成功</el-radio-button>
<el-radio-button label="out_of_time">时间范围外</el-radio-button>
<el-radio-button label="failure">失败</el-radio-button>
<el-radio-button label="unknown">异常</el-radio-button>
</el-radio-group>
<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-gray-700">触发方式:</span>
<el-radio-group v-model="filterTrigger" size="small" @change="handleFilterChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="scheduler">自动</el-radio-button>
<el-radio-button label="manual">手动</el-radio-button>
</el-radio-group>
<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>
<div class="flex-1"></div>
<el-button size="small" @click="fetchRecords">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<a-button size="small" @click="fetchRecords">
<template #icon><ReloadOutlined /></template>
刷新
</el-button>
</div>
</a-button>
</a-space>
</div>
<!-- Records List -->
<div v-if="loading" class="space-y-4">
<div v-for="i in 5" :key="i" class="fluent-card p-6">
<div class="skeleton h-6 w-1/4 mb-3"></div>
<div class="skeleton h-4 w-full mb-2"></div>
<div class="skeleton h-4 w-3/4"></div>
</div>
<a-card v-for="i in 5" :key="i">
<a-skeleton :active="true" :paragraph="{ rows: 3 }" />
</a-card>
</div>
<div v-else-if="records.length === 0" class="fluent-card p-12 text-center">
<svg class="w-20 h-20 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<FileTextOutlined class="text-8xl text-gray-300 mb-4" />
<h3 class="text-xl font-semibold text-gray-700 mb-2">暂无打卡记录</h3>
<p class="text-gray-500">当前筛选条件下没有找到任何打卡记录</p>
</div>
@@ -128,34 +129,32 @@
>
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<div class="flex items-center gap-3 mb-2 flex-wrap">
<h3 class="text-lg font-semibold text-gray-800">
打卡记录 #{{ record.id }}
</h3>
<span
<a-tag
v-if="record.status === 'success'"
class="status-success"
> 打卡成功</span>
<span
color="success"
> 打卡成功</a-tag>
<a-tag
v-else-if="record.status === 'out_of_time'"
class="status-info"
>🕐 时间范围外</span>
<span
color="default"
>🕐 时间范围外</a-tag>
<a-tag
v-else-if="record.status === 'unknown'"
class="status-warning"
> 打卡异常</span>
<span
color="warning"
> 打卡异常</a-tag>
<a-tag
v-else
class="status-error"
> 打卡失败</span>
<span :class="record.trigger_type === 'scheduled' ? 'status-info' : 'status-warning'">
color="error"
> 打卡失败</a-tag>
<a-tag :color="record.trigger_type === 'scheduled' ? 'blue' : 'orange'">
{{ record.trigger_type === 'scheduled' ? '自动触发' : '手动触发' }}
</span>
</a-tag>
</div>
<div class="flex items-center text-sm text-gray-600">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<ClockCircleOutlined class="mr-1" />
{{ formatDateTime(record.check_in_time) }}
</div>
</div>
@@ -178,14 +177,16 @@
<!-- Pagination -->
<div v-if="!loading && records.length > 0" class="mt-6 flex justify-center">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
<a-pagination
v-model:current="currentPage"
v-model:pageSize="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
:pageSizeOptions="['10', '20', '50', '100']"
show-size-changer
show-quick-jumper
:show-total="total => `${total} 条记录`"
@change="handlePageChange"
@showSizeChange="handleSizeChange"
/>
</div>
</div>
@@ -196,7 +197,14 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
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'
@@ -257,7 +265,7 @@ const fetchTaskDetail = async () => {
try {
currentTask.value = await taskStore.fetchTask(taskId.value)
} catch (error) {
ElMessage.error(error.message || '获取任务详情失败')
message.error(error.message || '获取任务详情失败')
router.push('/tasks')
}
}
@@ -293,7 +301,7 @@ const fetchRecords = async () => {
total.value = 0
}
} catch (error) {
ElMessage.error(error.message || '获取打卡记录失败')
message.error(error.message || '获取打卡记录失败')
} finally {
loading.value = false
}
@@ -304,27 +312,22 @@ const handleManualCheckIn = async () => {
checkInLoading.value = true
// 显示持久化通知
const loadingMessage = ElMessage({
message: '正在打卡中,请稍候... 您可以继续浏览其他页面',
type: 'info',
duration: 0,
showClose: false
})
const hide = message.loading('正在打卡中,请稍候... 您可以继续浏览其他页面', 0)
try {
const result = await taskStore.checkInTask(taskId.value)
loadingMessage.close()
hide()
if (result.success) {
ElMessage.success('打卡成功')
message.success('打卡成功')
// 刷新记录列表
await fetchRecords()
} else {
ElMessage.warning(result.message || '打卡失败')
message.warning(result.message || '打卡失败')
}
} catch (error) {
loadingMessage.close()
ElMessage.error(error.message || '打卡失败')
hide()
message.error(error.message || '打卡失败')
} finally {
checkInLoading.value = false
}
+179 -161
View File
@@ -1,27 +1,30 @@
<template>
<Layout>
<div class="min-h-screen bg-gradient-to-br from-blue-50 via-white to-green-50 p-6">
<div class="tasks-view">
<div class="max-w-7xl mx-auto">
<!-- Header Section -->
<div class="mb-8 animate-fade-in">
<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-gray-600">管理您的自动打卡任务</p>
</div>
<button
<a-button
type="primary"
size="large"
@click="showCreateDialog = true"
class="md3-button-filled shadow-md3-3 hover:scale-105 transform transition-all"
class="shadow-md3-3"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<template #icon>
<PlusOutlined />
</template>
创建任务
</button>
</a-button>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="24" :sm="8" :md="8">
<div class="fluent-card p-6 animate-slide-up">
<div class="flex items-center justify-between">
<div>
@@ -29,13 +32,13 @@
<p class="text-3xl font-bold text-primary-600">{{ taskStore.taskStats.total }}</p>
</div>
<div class="w-12 h-12 bg-primary-100 rounded-md3 flex items-center justify-center">
<svg class="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<FileTextOutlined class="text-2xl text-primary-600" />
</div>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="8" :md="8">
<div class="fluent-card p-6 animate-slide-up" style="animation-delay: 0.1s">
<div class="flex items-center justify-between">
<div>
@@ -43,13 +46,13 @@
<p class="text-3xl font-bold text-green-600">{{ taskStore.taskStats.active }}</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-md3 flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<CheckCircleOutlined class="text-2xl text-green-600" />
</div>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="8" :md="8">
<div class="fluent-card p-6 animate-slide-up" style="animation-delay: 0.2s">
<div class="flex items-center justify-between">
<div>
@@ -57,39 +60,41 @@
<p class="text-3xl font-bold text-gray-600">{{ taskStore.taskStats.inactive }}</p>
</div>
<div class="w-12 h-12 bg-gray-100 rounded-md3 flex items-center justify-center">
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</div>
<StopOutlined class="text-2xl text-gray-600" />
</div>
</div>
</div>
</a-col>
</a-row>
</div>
<!-- Tasks List -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="i in 6" :key="i" class="fluent-card p-6">
<div class="skeleton h-6 w-3/4 mb-4"></div>
<div class="skeleton h-4 w-full mb-2"></div>
<div class="skeleton h-4 w-2/3"></div>
</div>
<div v-if="loading">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12" :lg="8" v-for="i in 6" :key="i">
<a-card>
<a-skeleton :active="true" :paragraph="{ rows: 4 }" />
</a-card>
</a-col>
</a-row>
</div>
<div v-else-if="taskStore.tasks.length === 0" class="fluent-card p-12 text-center">
<svg class="w-20 h-20 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<FileTextOutlined class="text-8xl text-gray-300 mb-4" />
<h3 class="text-xl font-semibold text-gray-700 mb-2">暂无任务</h3>
<p class="text-gray-500 mb-6">点击右上角的"创建任务"按钮开始添加您的第一个打卡任务</p>
<button @click="showCreateDialog = true" class="md3-button-outlined">
<a-button type="primary" @click="showCreateDialog = true">
创建第一个任务
</button>
</a-button>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
<a-row v-else :gutter="[16, 16]">
<a-col
:xs="24" :sm="12" :lg="8"
v-for="task in taskStore.tasks"
:key="task.id"
>
<div
class="fluent-card p-6 hover:scale-105 transform transition-all cursor-pointer animate-slide-up"
@click="viewTask(task)"
>
@@ -99,29 +104,23 @@
<h3 class="text-lg font-semibold text-gray-800 mb-1">{{ task.name || '未命名任务' }}</h3>
<p class="text-sm text-gray-500">任务 ID: {{ task.id }}</p>
</div>
<span :class="task.is_active ? 'status-success' : 'status-info'">
<a-tag :color="task.is_active ? 'success' : 'default'">
{{ task.is_active ? '启用' : '禁用' }}
</span>
</a-tag>
</div>
<!-- Task Details -->
<div class="space-y-2 mb-4">
<div class="flex items-center text-sm text-gray-600">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<TagOutlined class="mr-2" />
接龙ID: {{ getThreadId(task) }}
</div>
<div class="flex items-center text-sm text-gray-600">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<ClockCircleOutlined class="mr-2" />
最后打卡: {{ task.last_check_in_time ? formatDateTime(task.last_check_in_time) : '未打卡' }}
</div>
<div class="flex items-center text-sm">
<svg class="w-4 h-4 mr-2 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<CheckCircleOutlined class="mr-2 text-gray-600" />
<span v-if="task.last_check_in_status" :class="{
'text-green-600 font-medium': task.last_check_in_status === 'success',
'text-blue-600 font-medium': task.last_check_in_status === 'out_of_time',
@@ -141,52 +140,58 @@
<!-- Task Actions -->
<div class="flex gap-2 pt-4 border-t border-gray-100">
<button
<a-button
type="primary"
size="small"
:loading="checkInLoading[task.id]"
@click.stop="handleCheckIn(task.id)"
:disabled="checkInLoading[task.id]"
class="flex-1 py-2 px-4 bg-primary-50 text-primary-600 rounded-lg hover:bg-primary-100 transition-colors text-sm font-medium disabled:opacity-50"
class="flex-1"
>
{{ checkInLoading[task.id] ? '打卡中...' : '立即打卡' }}
</button>
<button
</a-button>
<a-button
size="small"
@click.stop="toggleTaskStatus(task)"
class="flex-1 py-2 px-4 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
class="flex-1"
>
{{ task.is_active ? '禁用' : '启用' }}
</button>
<button
</a-button>
<a-button
type="primary"
size="small"
ghost
@click.stop="editTask(task)"
class="p-2 bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 transition-colors"
class="icon-button"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
<template #icon><EditOutlined /></template>
</a-button>
<a-button
danger
size="small"
@click.stop="deleteTask(task)"
class="p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
class="icon-button"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<template #icon><DeleteOutlined /></template>
</a-button>
</div>
</div>
</a-col>
</a-row>
</div>
</div>
<!-- Create/Edit Task Dialog -->
<el-dialog
v-model="showCreateDialog"
<a-modal
v-model:open="showCreateDialog"
:title="editingTask ? '编辑任务' : '从模板创建任务'"
width="700px"
:close-on-click-modal="false"
:width="isMobile ? '100%' : 700"
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
:maskClosable="false"
>
<!-- 只显示从模板创建 -->
<div v-if="!editingTask">
<div v-if="loadingTemplates" class="text-center py-8">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
<a-spin size="large" />
<p class="text-gray-500 mt-2">加载模板中...</p>
</div>
@@ -197,7 +202,7 @@
<div v-else>
<!-- Template Selection -->
<el-form-item label="选择模板" label-width="100px" v-if="!selectedTemplate">
<a-form-item label="选择模板" v-if="!selectedTemplate">
<div class="grid grid-cols-1 gap-3">
<div
v-for="template in activeTemplates"
@@ -209,121 +214,117 @@
<p class="text-sm text-gray-600">{{ template.description || '无描述' }}</p>
</div>
</div>
</el-form-item>
</a-form-item>
<!-- Template Form -->
<el-form v-if="selectedTemplate" :model="templateTaskForm" ref="templateFormRef" label-width="120px">
<a-form v-if="selectedTemplate" :model="templateTaskForm" ref="templateFormRef" layout="vertical">
<div class="mb-4 p-3 bg-blue-50 rounded-lg flex items-center justify-between">
<div class="flex items-center">
<svg class="w-5 h-5 text-blue-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<FileTextOutlined class="text-blue-600 mr-2" />
<span class="text-sm font-medium text-blue-900">使用模板{{ selectedTemplate.name }}</span>
</div>
<el-button size="small" text @click="selectedTemplate = null">更换模板</el-button>
<a-button size="small" type="link" @click="selectedTemplate = null">更换模板</a-button>
</div>
<el-form-item label="任务名称" prop="task_name">
<el-input v-model="templateTaskForm.task_name" placeholder="可选,留空则自动生成" />
</el-form-item>
<a-form-item label="任务名称" name="task_name">
<a-input v-model:value="templateTaskForm.task_name" placeholder="可选,留空则自动生成" />
</a-form-item>
<el-form-item label="接龙 ID" prop="thread_id" required>
<el-input v-model="templateTaskForm.thread_id" placeholder="请输入接龙项目 ID" />
</el-form-item>
<a-form-item label="接龙 ID" name="thread_id" required>
<a-input v-model:value="templateTaskForm.thread_id" placeholder="请输入接龙项目 ID" />
</a-form-item>
<el-divider content-position="left">填写字段信息</el-divider>
<a-divider orientation="left">填写字段信息</a-divider>
<!-- Dynamic Fields -->
<div v-for="(fieldConfig, key) in visibleFields" :key="key">
<el-form-item
<a-form-item
:label="fieldConfig.display_name"
:required="fieldConfig.required"
>
<!-- Text Input -->
<el-input
<a-input
v-if="fieldConfig.field_type === 'text'"
v-model="templateTaskForm.field_values[key]"
v-model:value="templateTaskForm.field_values[key]"
:placeholder="fieldConfig.placeholder || `请输入${fieldConfig.display_name}`"
/>
<!-- Textarea -->
<el-input
<a-textarea
v-else-if="fieldConfig.field_type === 'textarea'"
v-model="templateTaskForm.field_values[key]"
type="textarea"
v-model:value="templateTaskForm.field_values[key]"
:rows="3"
:placeholder="fieldConfig.placeholder || `请输入${fieldConfig.display_name}`"
/>
<!-- Number Input -->
<el-input-number
<a-input-number
v-else-if="fieldConfig.field_type === 'number'"
v-model="templateTaskForm.field_values[key]"
v-model:value="templateTaskForm.field_values[key]"
:placeholder="fieldConfig.placeholder || `请输入${fieldConfig.display_name}`"
style="width: 100%"
/>
<!-- Select -->
<el-select
<a-select
v-else-if="fieldConfig.field_type === 'select'"
v-model="templateTaskForm.field_values[key]"
v-model:value="templateTaskForm.field_values[key]"
:placeholder="fieldConfig.placeholder || `请选择${fieldConfig.display_name}`"
style="width: 100%"
>
<el-option
<a-select-option
v-for="option in fieldConfig.options"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
>
{{ option.label }}
</a-select-option>
</a-select>
<span v-if="fieldConfig.default_value" class="text-xs text-gray-500 mt-1">
默认值: {{ fieldConfig.default_value }}
</span>
</el-form-item>
</a-form-item>
</div>
</el-form>
</a-form>
</div>
</div>
<!-- Edit Mode Form - 简化版只显示任务名称和启用状态 -->
<el-form v-if="editingTask" :model="taskForm" :rules="taskRules" ref="taskFormRef" label-width="100px">
<el-form-item label="任务名称" prop="name">
<el-input v-model="taskForm.name" placeholder="请输入任务名称(例如:公司打卡)" />
</el-form-item>
<a-form v-if="editingTask" :model="taskForm" :rules="taskRules" ref="taskFormRef" layout="vertical">
<a-form-item label="任务名称" name="name">
<a-input v-model:value="taskForm.name" placeholder="请输入任务名称(例如:公司打卡)" />
</a-form-item>
<el-form-item label="启用状态">
<el-switch v-model="taskForm.is_active" />
<a-form-item label="启用状态">
<a-switch v-model:checked="taskForm.is_active" />
<span class="ml-2 text-sm text-gray-500">
{{ taskForm.is_active ? '启用自动打卡' : '禁用自动打卡(仍可手动打卡)' }}
</span>
</el-form-item>
</a-form-item>
<!-- 新增Crontab 编辑器 -->
<el-form-item label="打卡时间表">
<a-form-item label="打卡时间表">
<CrontabEditor v-model="taskForm.cron_expression" />
</el-form-item>
</a-form-item>
<el-divider content-position="left">任务 Payload 配置只读</el-divider>
<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-gray-600">完整的打卡请求配置</span>
<button
<a-button
size="small"
type="primary"
ghost
@click="copyPayload"
type="button"
class="px-3 py-1 text-xs bg-blue-50 text-blue-600 rounded hover:bg-blue-100 transition-colors flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<template #icon><CopyOutlined /></template>
复制
</button>
</a-button>
</div>
<el-input
v-model="formattedPayload"
type="textarea"
<a-textarea
v-model:value="formattedPayload"
:rows="12"
readonly
class="font-mono text-xs"
@@ -333,26 +334,38 @@
💡 此配置由模板自动生成如需修改请删除任务后从模板重新创建
</p>
</div>
</el-form>
</a-form>
<template #footer>
<div class="flex gap-3 justify-end">
<button @click="showCreateDialog = false" class="md3-button-text">取消</button>
<button @click="handleSubmit" :disabled="submitting" class="md3-button-filled">
<a-button @click="showCreateDialog = false">取消</a-button>
<a-button type="primary" :loading="submitting" @click="handleSubmit">
{{ submitting ? '提交中...' : (editingTask ? '保存修改' : '创建任务') }}
</button>
</a-button>
</div>
</template>
</el-dialog>
</a-modal>
</Layout>
</template>
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
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'
@@ -360,6 +373,7 @@ import { copyToClipboard, formatDateTime } from '@/utils/helpers'
const router = useRouter()
const taskStore = useTaskStore()
const templateStore = useTemplateStore()
const { isMobile } = useBreakpoint()
const loading = ref(false)
const showCreateDialog = ref(false)
@@ -463,9 +477,9 @@ const formattedPayload = computed(() => {
const copyPayload = async () => {
const success = await copyToClipboard(formattedPayload.value)
if (success) {
ElMessage.success('Payload 已复制到剪贴板')
message.success('Payload 已复制到剪贴板')
} else {
ElMessage.error('复制失败')
message.error('复制失败')
}
}
@@ -480,7 +494,7 @@ watch(selectedTemplate, async (newTemplate) => {
try {
templatePreview.value = await templateStore.previewTemplate(newTemplate.id)
} catch (error) {
ElMessage.error('获取模板配置失败')
message.error('获取模板配置失败')
templatePreview.value = null
return
}
@@ -531,7 +545,7 @@ const loadTemplates = async () => {
try {
activeTemplates.value = await templateStore.fetchActiveTemplates()
} catch (error) {
ElMessage.error(error.message || '加载模板失败')
message.error(error.message || '加载模板失败')
} finally {
loadingTemplates.value = false
}
@@ -569,7 +583,7 @@ const fetchTasks = async () => {
try {
await taskStore.fetchMyTasks()
} catch (error) {
ElMessage.error(error.message || '加载任务列表失败')
message.error(error.message || '加载任务列表失败')
} finally {
loading.value = false
}
@@ -604,34 +618,32 @@ const editTask = (task) => {
}
// 删除任务
const deleteTask = async (task) => {
const deleteTask = (task) => {
Modal.confirm({
title: '删除确认',
content: `确定要删除任务"${task.name || task.id}"吗?此操作不可恢复。`,
okText: '确定删除',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
await ElMessageBox.confirm(
`确定要删除任务"${task.name || '未命名任务'}"吗?此操作不可恢复。`,
'删除确认',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
}
)
await taskStore.deleteTask(task.id)
ElMessage.success('任务删除成功')
message.success('任务删除成功')
await fetchTasks()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '删除任务失败')
}
message.error(error.message || '删除任务失败')
}
},
})
}
// 切换任务状态
const toggleTaskStatus = async (task) => {
try {
await taskStore.toggleTask(task.id)
ElMessage.success(task.is_active ? '任务已禁用' : '任务已启用')
message.success(task.is_active ? '任务已禁用' : '任务已启用')
} catch (error) {
ElMessage.error(error.message || '切换任务状态失败')
message.error(error.message || '切换任务状态失败')
}
}
@@ -646,21 +658,21 @@ const handleCheckIn = async (taskId) => {
// 获取 record_id
const recordId = result.record_id
if (!recordId) {
ElMessage.error('打卡请求失败:未获取到记录ID')
message.error('打卡请求失败:未获取到记录ID')
checkInLoading.value[taskId] = false
return
}
// 如果初始状态就是失败,显示错误并刷新任务列表
if (result.status === 'failure') {
ElMessage.error(result.message || '打卡失败')
message.error(result.message || '打卡失败')
checkInLoading.value[taskId] = false
await fetchTasks()
return
}
// 显示提示消息
ElMessage.info('打卡任务已启动,正在后台处理...')
message.info('打卡任务已启动,正在后台处理...')
// 用于存储 interval ID,以便在超时时清理
let pollIntervalId = null
@@ -677,12 +689,12 @@ const handleCheckIn = async (taskId) => {
if (status.status === 'success') {
// 打卡成功
ElMessage.success('打卡成功!')
message.success('打卡成功!')
await fetchTasks()
} else {
// 打卡失败或其他状态 (failure, out_of_time, unknown 等)
const errorMsg = status.error_message || status.response_text || '打卡失败'
ElMessage.error(errorMsg)
message.error(errorMsg)
await fetchTasks()
}
}
@@ -692,7 +704,7 @@ const handleCheckIn = async (taskId) => {
console.error('轮询状态失败:', error)
clearInterval(pollIntervalId)
checkInLoading.value[taskId] = false
ElMessage.error('查询打卡状态失败')
message.error('查询打卡状态失败')
}
}, 2000) // 每 2 秒查询一次
@@ -701,14 +713,14 @@ const handleCheckIn = async (taskId) => {
if (checkInLoading.value[taskId]) {
clearInterval(pollIntervalId)
checkInLoading.value[taskId] = false
ElMessage.warning('打卡处理时间较长,请稍后查看打卡记录')
message.warning('打卡处理时间较长,请稍后查看打卡记录')
}
}, 30000)
} catch (error) {
console.error('启动打卡失败:', error)
checkInLoading.value[taskId] = false
ElMessage.error(error.message || '启动打卡任务失败')
message.error(error.message || '启动打卡任务失败')
}
}
@@ -723,17 +735,17 @@ const handleSubmit = async () => {
await taskFormRef.value.validate()
await taskStore.updateTask(editingTask.value.id, taskForm)
ElMessage.success('任务更新成功')
message.success('任务更新成功')
}
// Create from template
else if (createMode.value === 'template') {
if (!selectedTemplate.value) {
ElMessage.warning('请选择一个模板')
message.warning('请选择一个模板')
return
}
if (!templateTaskForm.thread_id) {
ElMessage.warning('请输入接龙 ID')
message.warning('请输入接龙 ID')
return
}
@@ -744,7 +756,7 @@ const handleSubmit = async () => {
templateTaskForm.task_name || null
)
ElMessage.success('任务创建成功')
message.success('任务创建成功')
}
// Create manually
else {
@@ -752,14 +764,14 @@ const handleSubmit = async () => {
await taskFormRef.value.validate()
await taskStore.createTask(taskForm)
ElMessage.success('任务创建成功')
message.success('任务创建成功')
}
showCreateDialog.value = false
resetForm()
await fetchTasks()
} catch (error) {
ElMessage.error(error.message || '操作失败')
message.error(error.message || '操作失败')
} finally {
submitting.value = false
}
@@ -798,5 +810,11 @@ onMounted(() => {
</script>
<style scoped>
/* Additional component-specific styles if needed */
.icon-button {
display: flex;
align-items: center;
justify-content: center;
min-width: 32px;
padding: 4px 8px;
}
</style>
+21 -22
View File
@@ -1,40 +1,40 @@
<template>
<Layout>
<div class="admin-logs-container">
<el-card>
<template #header>
<a-card>
<template #title>
<div class="card-header">
<div>
<el-icon><Document /></el-icon>
<FileTextOutlined />
<span>系统日志</span>
</div>
<el-button type="primary" :icon="Refresh" @click="handleRefresh">
<a-button type="primary" @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
刷新
</el-button>
</a-button>
</div>
</template>
<el-alert
title="日志查看"
<a-alert
message="日志查看"
description="显示最新的系统日志信息(默认显示最近 200 行)"
type="info"
:closable="false"
show-icon
style="margin-bottom: 20px"
>
<p>显示最新的系统日志信息默认显示最近 200 </p>
</el-alert>
/>
<div v-if="adminStore.loading" class="loading-container">
<el-skeleton :rows="10" animated />
<a-skeleton :active="true" :paragraph="{ rows: 10 }" />
</div>
<div v-else class="logs-content">
<el-input
v-model="logContent"
type="textarea"
<a-textarea
v-model:value="logContent"
:rows="25"
readonly
:readonly="true"
placeholder="暂无日志内容"
class="log-textarea"
/>
<div class="log-info">
@@ -42,15 +42,15 @@
<span>最后更新: {{ lastUpdate }}</span>
</div>
</div>
</el-card>
</a-card>
</div>
</Layout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Document, Refresh } from '@element-plus/icons-vue'
import { 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'
@@ -73,12 +73,12 @@ const handleRefresh = async () => {
// 确保是字符串
logContent.value = typeof data.logs === 'string' ? data.logs : String(data.logs)
lastUpdate.value = formatDateTime(new Date())
ElMessage.success('刷新成功')
message.success('刷新成功')
} else {
logContent.value = '无日志内容'
}
} catch (error) {
ElMessage.error(error.message || '刷新失败')
message.error(error.message || '刷新失败')
}
}
@@ -103,7 +103,6 @@ onMounted(() => {
display: flex;
align-items: center;
gap: 8px;
font-weight: bold;
}
.loading-container {
@@ -122,7 +121,7 @@ onMounted(() => {
color: #909399;
}
:deep(.el-textarea__inner) {
.log-textarea :deep(textarea) {
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
line-height: 1.6;
+91 -54
View File
@@ -1,92 +1,130 @@
<template>
<Layout>
<div class="admin-records-container">
<el-card>
<template #header>
<a-card>
<template #title>
<div class="card-header">
<div>
<el-icon><List /></el-icon>
<UnorderedListOutlined />
<span>所有打卡记录</span>
</div>
<el-button type="primary" :icon="Refresh" @click="handleRefresh">
<a-button type="primary" @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
刷新
</el-button>
</a-button>
</div>
</template>
<!-- 记录表格 -->
<el-table
:data="checkInStore.allRecords"
v-loading="checkInStore.loading"
stripe
border
<!-- Desktop table -->
<a-table
v-if="!isMobile"
:dataSource="checkInStore.allRecords"
:columns="columns"
:loading="checkInStore.loading"
:pagination="false"
:row-key="record => record.id"
:scroll="{ x: 'max-content' }"
bordered
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="user_id" label="用户ID" width="100" />
<el-table-column prop="user_email" label="用户邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="task_name" label="任务名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="thread_id" label="接龙ID" width="150" show-overflow-tooltip />
<el-table-column prop="check_in_time" label="打卡时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.check_in_time) }}
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'check_in_time'">
{{ formatDateTime(record.check_in_time) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag v-if="row.status === 'success'" type="success"> 打卡成功</el-tag>
<el-tag v-else-if="row.status === 'out_of_time'" type="info">🕐 时间范围外</el-tag>
<el-tag v-else-if="row.status === 'unknown'" type="warning"> 打卡异常</el-tag>
<el-tag v-else type="danger"> 打卡失败</el-tag>
<template 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>
</el-table-column>
<el-table-column prop="trigger_type" label="触发方式" width="120">
<template #default="{ row }">
<el-tag v-if="row.trigger_type === 'manual'" type="primary">手动</el-tag>
<el-tag v-else-if="row.trigger_type === 'scheduled'" type="info">定时</el-tag>
<el-tag v-else-if="row.trigger_type === 'admin'" type="warning">管理员</el-tag>
<el-tag v-else>{{ row.trigger_type }}</el-tag>
<template 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>
</el-table-column>
</template>
</a-table>
<el-table-column prop="response_text" label="消息" min-width="200" show-overflow-tooltip />
</el-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>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="checkInStore.currentPage"
v-model:page-size="checkInStore.pageSize"
<!-- Empty state -->
<a-empty v-if="!checkInStore.loading && checkInStore.allRecords.length === 0" description="暂无打卡记录" />
<!-- Pagination -->
<div class="pagination-container" v-if="checkInStore.total > 0">
<a-pagination
v-model:current="checkInStore.currentPage"
v-model:pageSize="checkInStore.pageSize"
:total="checkInStore.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange"
@size-change="handleSizeChange"
:pageSizeOptions="['10', '20', '50', '100']"
show-size-changer
show-quick-jumper
:show-total="total => `${total} 条记录`"
@change="handlePageChange"
@showSizeChange="handleSizeChange"
/>
</div>
</el-card>
</a-card>
</div>
</Layout>
</template>
<script setup>
import { onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { List, Refresh } from '@element-plus/icons-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()
ElMessage.success('刷新成功')
message.success('刷新成功')
} catch (error) {
ElMessage.error(error.message || '刷新失败')
message.error(error.message || '刷新失败')
}
}
@@ -120,7 +158,6 @@ onMounted(() => {
display: flex;
align-items: center;
gap: 8px;
font-weight: bold;
}
.pagination-container {
+88 -68
View File
@@ -1,106 +1,126 @@
<template>
<Layout>
<div class="admin-stats-container">
<el-row :gutter="20">
<el-col :span="24">
<el-card>
<template #header>
<a-row :gutter="20">
<a-col :span="24">
<a-card>
<template #title>
<div class="card-header">
<el-icon><DataAnalysis /></el-icon>
<BarChartOutlined />
<span>系统统计信息</span>
<el-button type="primary" :icon="Refresh" @click="handleRefresh">
<a-button type="primary" @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
刷新
</el-button>
</a-button>
</div>
</template>
<div v-if="adminStore.loading" class="loading-container">
<el-skeleton :rows="5" animated />
<a-skeleton :active="true" :paragraph="{ rows: 5 }" />
</div>
<div v-else-if="adminStore.stats" class="stats-content">
<el-row :gutter="20">
<el-col :span="6">
<el-statistic
<a-row :gutter="[20, 20]">
<a-col :xs="24" :sm="12" :md="6">
<a-statistic
title="总用户数"
:value="adminStore.totalUsers"
prefix-icon="User"
/>
</el-col>
<el-col :span="6">
<el-statistic
>
<template #prefix>
<UserOutlined />
</template>
</a-statistic>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-statistic
title="已审批用户数"
:value="adminStore.activeUsers"
prefix-icon="Check"
value-style="color: #67c23a"
/>
</el-col>
<el-col :span="6">
<el-statistic
: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"
prefix-icon="List"
/>
</el-col>
<el-col :span="6">
<el-statistic
>
<template #prefix>
<UnorderedListOutlined />
</template>
</a-statistic>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-statistic
title="今日打卡"
:value="adminStore.todayRecords"
prefix-icon="Calendar"
value-style="color: #409eff"
/>
</el-col>
</el-row>
:value-style="{ color: '#1890ff' }"
>
<template #prefix>
<CalendarOutlined />
</template>
</a-statistic>
</a-col>
</a-row>
<el-divider />
<a-divider />
<el-descriptions title="详细信息" :column="2" border>
<el-descriptions-item label="管理员数量">
<a-descriptions title="详细信息" :column="{ xs: 1, sm: 1, md: 2 }" bordered>
<a-descriptions-item label="管理员数量">
{{ adminStore.stats?.users?.admin || 0 }}
</el-descriptions-item>
<el-descriptions-item label="普通用户数量">
</a-descriptions-item>
<a-descriptions-item label="普通用户数量">
{{ adminStore.stats?.users?.regular || 0 }}
</el-descriptions-item>
<el-descriptions-item label="今日成功打卡">
<el-tag type="success">{{ adminStore.stats?.check_in_records?.today_success || 0 }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="今日失败打卡">
<el-tag type="danger">{{ adminStore.stats?.check_in_records?.today_failure || 0 }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="今日时间范围外">
<el-tag type="info">{{ adminStore.stats?.check_in_records?.today_out_of_time || 0 }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="今日异常打卡">
<el-tag type="warning">{{ adminStore.stats?.check_in_records?.today_unknown || 0 }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="总成功率" :span="2">
<el-progress
:percentage="calculateSuccessRate()"
:color="getProgressColor"
</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())"
/>
</el-descriptions-item>
</el-descriptions>
</a-descriptions-item>
</a-descriptions>
</div>
</el-card>
</el-col>
</el-row>
</a-card>
</a-col>
</a-row>
</div>
</Layout>
</template>
<script setup>
import { onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { DataAnalysis, Refresh } from '@element-plus/icons-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 '#67c23a'
if (percentage >= 70) return '#e6a23c'
return '#f56c6c'
if (percentage >= 90) return '#52c41a'
if (percentage >= 70) return '#faad14'
return '#ff4d4f'
}
const calculateSuccessRate = () => {
@@ -121,9 +141,9 @@ const calculateSuccessRate = () => {
const handleRefresh = async () => {
try {
await adminStore.fetchStats()
ElMessage.success('刷新成功')
message.success('刷新成功')
} catch (error) {
ElMessage.error(error.message || '刷新失败')
message.error(error.message || '刷新失败')
}
}
@@ -142,10 +162,10 @@ onMounted(() => {
display: flex;
align-items: center;
gap: 8px;
font-weight: bold;
width: 100%;
}
.card-header .el-button {
.card-header :deep(.ant-btn) {
margin-left: auto;
}
+236 -154
View File
@@ -1,9 +1,9 @@
<template>
<Layout>
<div class="min-h-screen bg-gradient-to-br from-purple-50 via-white to-blue-50 p-6">
<div class="max-w-7xl mx-auto">
<div class="templates-view">
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="mb-8 animate-fade-in">
<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>
@@ -21,9 +21,7 @@
<!-- Templates List -->
<div v-if="loading && templates.length === 0" class="space-y-4">
<div v-for="i in 3" :key="i" class="fluent-card p-6">
<div class="skeleton h-6 w-1/3 mb-3"></div>
<div class="skeleton h-4 w-full mb-2"></div>
<div class="skeleton h-4 w-2/3"></div>
<a-skeleton :active="true" :paragraph="{ rows: 2 }" />
</div>
</div>
@@ -40,9 +38,9 @@
<div
v-for="template in templates"
:key="template.id"
class="fluent-card p-6 hover:shadow-xl transition-all animate-slide-up"
class="fluent-card p-7 hover:shadow-xl transition-all animate-slide-up"
>
<div class="flex items-start justify-between mb-4">
<div class="flex items-start justify-between mb-5">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-800 mb-2">{{ template.name }}</h3>
<p class="text-sm text-gray-600 mb-3">{{ template.description || '无描述' }}</p>
@@ -52,22 +50,35 @@
</div>
</div>
<div class="flex items-center gap-2 mt-4">
<button @click="previewTemplate(template)" class="md3-button-outlined text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="mt-5 pt-4 border-t border-gray-100 space-y-2">
<!-- 第一行预览在左半部分居中编辑在右半部分居中 -->
<div class="grid grid-cols-2 gap-2">
<div class="flex justify-center">
<button @click="previewTemplate(template)" class="md3-button-outlined text-sm flex-shrink-0">
<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>
<button @click="editTemplate(template)" class="md3-button-outlined text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div>
<div class="flex justify-center">
<button @click="editTemplate(template)" class="md3-button-outlined text-sm flex-shrink-0">
<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>
<button @click="deleteTemplate(template)" class="md3-button-text text-sm text-red-600">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div>
</div>
<!-- 第二行删除在右半部分居中与编辑对齐 -->
<div class="grid grid-cols-2 gap-2">
<div></div>
<div class="flex justify-center">
<button @click="deleteTemplate(template)" class="md3-button-text text-sm text-red-600 flex-shrink-0">
<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>
删除
@@ -75,50 +86,61 @@
</div>
</div>
</div>
</div>
</div>
<!-- Create/Edit Dialog -->
<el-dialog
v-model="dialogVisible"
<a-modal
v-model:open="dialogVisible"
:title="dialogMode === 'create' ? '新建模板' : '编辑模板'"
width="95%"
:close-on-click-modal="false"
class="template-editor-dialog"
:width="dialogWidth"
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
:maskClosable="false"
class="template-editor-modal"
>
<el-form :model="formData" label-width="120px" ref="formRef">
<el-form-item label="模板名称" required>
<el-input v-model="formData.name" placeholder="请输入模板名称" maxlength="100" show-word-limit />
</el-form-item>
<a-form :model="formData" layout="vertical" ref="formRef">
<a-form-item label="模板名称" required>
<a-input v-model:value="formData.name" placeholder="请输入模板名称" :maxlength="100" show-count />
</a-form-item>
<el-form-item label="模板描述">
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入模板描述" />
</el-form-item>
<a-form-item label="模板描述">
<a-textarea v-model:value="formData.description" :rows="2" placeholder="请输入模板描述" />
</a-form-item>
<el-form-item label="父模板">
<el-select v-model="formData.parent_id" placeholder="可选,继承父模板的字段配置" clearable class="w-full">
<el-option
<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"
:label="template.name"
:value="template.id"
:disabled="template.id === currentTemplateId"
/>
</el-select>
</el-form-item>
>
{{ template.name }}
</a-select-option>
</a-select>
</a-form-item>
<el-form-item label="是否启用">
<el-switch v-model="formData.is_active" />
</el-form-item>
<a-form-item label="是否启用">
<a-switch v-model:checked="formData.is_active" />
</a-form-item>
<el-divider content-position="left">
<a-divider orientation="left">
<span class="text-lg font-bold">Payload 配置 (JSON 映射)</span>
</el-divider>
</a-divider>
<el-alert
title="💡 JSON 映射架构"
<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>
@@ -128,40 +150,41 @@
<p class="text-sm">
<strong>ThreadId</strong> 由用户填写无需在模板中配置
</p>
</el-alert>
</template>
</a-alert>
<!-- 字段配置编辑器 -->
<div class="field-config-editor">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-gray-800">字段配置</h3>
<el-dropdown @command="handleAddField">
<el-button type="primary">
<a-dropdown>
<a-button type="primary">
添加字段
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="field">
<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>
普通字段
</el-dropdown-item>
<el-dropdown-item command="array">
</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>
数组字段
</el-dropdown-item>
<el-dropdown-item command="object">
</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>
对象字段
</el-dropdown-item>
</el-dropdown-menu>
</a-menu-item>
</a-menu>
</template>
</el-dropdown>
</a-dropdown>
</div>
<!-- 递归渲染字段树 -->
@@ -176,7 +199,7 @@
<div v-else class="space-y-3">
<FieldTreeNode
v-for="(config, key) in formData.field_config"
:key="key"
:key="`${fieldConfigVersion}-${key}`"
:field-key="key"
:field-config="config"
:path="[key]"
@@ -188,46 +211,56 @@
</div>
<!-- JSON 预览 -->
<el-divider content-position="left">
<a-divider orientation="left">
<span class="text-lg font-bold">JSON 预览</span>
</el-divider>
</a-divider>
<div class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm overflow-auto max-h-96">
<pre>{{ JSON.stringify(formData.field_config, null, 2) }}</pre>
</div>
</el-form>
</a-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
<a-button @click="dialogVisible = false">取消</a-button>
<a-button type="primary" @click="handleSubmit" :loading="submitting">
{{ dialogMode === 'create' ? '创建' : '更新' }}
</el-button>
</a-button>
</template>
</el-dialog>
</a-modal>
<!-- Add Field Dialog -->
<el-dialog v-model="addFieldDialogVisible" :title="`添加${fieldTypeLabel}`" width="500px">
<el-form @submit.prevent="confirmAddField">
<el-form-item label="字段名">
<el-input
v-model="newFieldName"
<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-gray-500 mt-1">
<span class="text-xs text-gray-500 mt-1 block">
💡 字段名将保持原样不会进行大小写转换
</span>
</el-form-item>
</el-form>
</a-form-item>
</a-form>
<template #footer>
<el-button @click="addFieldDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmAddField">确定</el-button>
<a-button @click="addFieldDialogVisible = false">取消</a-button>
<a-button type="primary" @click="confirmAddField">确定</a-button>
</template>
</el-dialog>
</a-modal>
<!-- Preview Dialog -->
<el-dialog v-model="previewDialogVisible" title="模板预览" width="90%">
<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-gray-50 rounded p-4">
<h4 class="font-semibold mb-2">生成的 Payload使用默认值</h4>
@@ -241,9 +274,9 @@
</div>
<template #footer>
<el-button @click="previewDialogVisible = false">关闭</el-button>
<a-button @click="previewDialogVisible = false">关闭</a-button>
</template>
</el-dialog>
</a-modal>
</div>
</div>
</Layout>
@@ -251,13 +284,28 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox, ElIcon } from 'element-plus'
import { ArrowDown } from '@element-plus/icons-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)
@@ -272,6 +320,7 @@ const previewData = ref(null)
const addFieldDialogVisible = ref(false)
const newFieldName = ref('')
const newFieldType = ref('field')
const fieldConfigVersion = ref(0) // 用于强制刷新字段列表
const formData = ref({
name: '',
@@ -315,7 +364,7 @@ const fetchTemplates = async () => {
try {
templates.value = await templateStore.fetchTemplates()
} catch (error) {
ElMessage.error(error.message || '获取模板列表失败')
message.error(error.message || '获取模板列表失败')
} finally {
loading.value = false
}
@@ -353,7 +402,7 @@ const editTemplate = (template) => {
const handleSubmit = async () => {
if (!formData.value.name) {
ElMessage.warning('请输入模板名称')
message.warning('请输入模板名称')
return
}
@@ -369,41 +418,38 @@ const handleSubmit = async () => {
if (dialogMode.value === 'create') {
await templateStore.createTemplate(templateData)
ElMessage.success('模板创建成功')
message.success('模板创建成功')
} else {
await templateStore.updateTemplate(currentTemplateId.value, templateData)
ElMessage.success('模板更新成功')
message.success('模板更新成功')
}
dialogVisible.value = false
await fetchTemplates()
} catch (error) {
ElMessage.error(error.message || '操作失败')
message.error(error.message || '操作失败')
} finally {
submitting.value = false
}
}
const deleteTemplate = async (template) => {
const deleteTemplate = (template) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除模板"${template.name}"吗?此操作不可撤销。`,
okText: '删除',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
await ElMessageBox.confirm(
`确定要删除模板"${template.name}"吗?此操作不可撤销。`,
'确认删除',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
}
)
await templateStore.deleteTemplate(template.id)
ElMessage.success('模板删除成功')
message.success('模板删除成功')
await fetchTemplates()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '删除失败')
}
message.error(error.message || '删除失败')
}
},
})
}
const previewTemplate = async (template) => {
@@ -411,38 +457,45 @@ const previewTemplate = async (template) => {
previewData.value = await templateStore.previewTemplate(template.id)
previewDialogVisible.value = true
} catch (error) {
ElMessage.error(error.message || '预览失败')
message.error(error.message || '预览失败')
}
}
const handleAddField = (type) => {
newFieldType.value = type
const handleAddField = ({ key }) => {
newFieldType.value = key
newFieldName.value = ''
addFieldDialogVisible.value = true
}
const confirmAddField = () => {
if (!newFieldName.value) {
ElMessage.warning('请输入字段名')
message.warning('请输入字段名')
return
}
if (formData.value.field_config[newFieldName.value]) {
ElMessage.warning('该字段已存在')
message.warning('该字段已存在')
return
}
// 创建一个新对象,确保新字段被添加到末尾
const newConfig = { ...formData.value.field_config }
// 创建对应类型的字段
if (newFieldType.value === 'field') {
formData.value.field_config[newFieldName.value] = createDefaultFieldConfig()
newConfig[newFieldName.value] = createDefaultFieldConfig()
} else if (newFieldType.value === 'array') {
formData.value.field_config[newFieldName.value] = []
newConfig[newFieldName.value] = []
} else if (newFieldType.value === 'object') {
formData.value.field_config[newFieldName.value] = {}
newConfig[newFieldName.value] = {}
}
// 替换整个 field_config 以确保顺序和响应性
formData.value.field_config = newConfig
fieldConfigVersion.value++ // 强制刷新
addFieldDialogVisible.value = false
ElMessage.success('字段添加成功')
message.success('字段添加成功')
}
const updateField = (path, newValue) => {
@@ -456,8 +509,6 @@ const updateField = (path, newValue) => {
const deleteField = (path) => {
// 通过路径删除嵌套字段
console.log('🗑️ 删除字段 - 路径:', path)
if (!path || path.length === 0) return
// 创建一个新的 field_config 副本以触发响应性
@@ -489,90 +540,121 @@ const deleteField = (path) => {
// 替换整个 field_config 以触发 Vue 响应性
formData.value.field_config = newConfig
console.log('✅ 字段已删除:', path)
fieldConfigVersion.value++ // 强制刷新
}
const moveField = (path, direction) => {
// 通过路径移动字段
if (!path || path.length === 0) return
// 创建一个新的 field_config 副本以触发响应性
// 如果是根级别字段,直接重建整个 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++) {
if (!parent || typeof parent !== 'object') {
console.error('移动失败:路径无效', path, 'at index', i)
return
}
parent = parent[path[i]]
}
if (!parent || typeof parent !== 'object') {
console.error('移动失败:父对象不存在', path)
if (!parent) {
console.error('❌ 路径无效:', path)
return
}
}
const fieldKey = path[path.length - 1]
if (Array.isArray(parent)) {
// 数组:使用索引移动
const index = fieldKey
// 数组情况:直接交换元素
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) return
let newIndex = currentIndex
if (direction === 'up' && currentIndex > 0) {
newIndex = currentIndex - 1
} else if (direction === 'down' && currentIndex < keys.length - 1) {
newIndex = currentIndex + 1
} else {
// 已经在边界,无需移动
if (currentIndex === -1) {
console.error('❌ 字段不存在:', fieldKey)
return
}
if (newIndex !== currentIndex) {
// 交换键的位置
const temp = keys[currentIndex]
keys[currentIndex] = keys[newIndex]
keys[newIndex] = temp
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 newParent = {}
// 交换键数组中的位置
const temp = keys[currentIndex]
keys[currentIndex] = keys[targetIndex]
keys[targetIndex] = temp
// 重建父对象
const reorderedParent = {}
keys.forEach(key => {
newParent[key] = parent[key]
reorderedParent[key] = parent[key]
})
// 更新父对象的所有
// 替换父容器的所有属性
Object.keys(parent).forEach(key => delete parent[key])
Object.assign(parent, newParent)
}
Object.assign(parent, reorderedParent)
}
// 替换整个 field_config 以触发 Vue 响应性
// 强制触发响应性更新
formData.value.field_config = newConfig
console.log('✅ 字段已移动:', path, direction)
fieldConfigVersion.value++
}
onMounted(() => {
@@ -585,7 +667,7 @@ onMounted(() => {
min-height: 200px;
}
.template-editor-dialog :deep(.el-dialog__body) {
.template-editor-modal :deep(.ant-modal-body) {
max-height: 70vh;
overflow-y: auto;
}
+257 -221
View File
@@ -1,220 +1,252 @@
<template>
<Layout>
<div class="admin-users-container">
<el-card>
<template #header>
<a-card>
<template #title>
<div class="card-header">
<div>
<el-icon><UserFilled /></el-icon>
<UserOutlined />
<span>用户管理</span>
</div>
<div class="actions">
<el-button type="success" :icon="Plus" @click="handleCreate">
<a-space class="actions">
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
创建用户
</el-button>
<el-button type="primary" :icon="Refresh" @click="handleRefresh">
</a-button>
<a-button @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
刷新
</el-button>
</div>
</a-button>
</a-space>
</div>
</template>
<!-- Tab 切换 -->
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
<!-- 待审批用户 Tab -->
<el-tab-pane label="待审批用户" name="pending">
<el-table
:data="pendingUsers"
v-loading="loading"
stripe
border
<a-tab-pane key="pending" tab="待审批用户">
<!-- 桌面端表格 -->
<a-table
v-if="!isMobile"
:dataSource="pendingUsers"
:columns="pendingColumns"
:loading="loading"
:row-key="record => record.id"
:scroll="{ x: 'max-content' }"
bordered
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="alias" label="用户名" min-width="150" />
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="registered_ip" label="注册IP" width="150" />
<el-table-column prop="created_at" label="注册时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="success" size="small" @click="handleApprove(row)">
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button type="primary" size="small" @click="handleApprove(record)">
通过
</el-button>
<el-button type="danger" size="small" @click="handleReject(row)">
</a-button>
<a-button danger size="small" @click="handleReject(record)">
拒绝
</el-button>
</a-button>
</a-space>
</template>
</el-table-column>
</el-table>
</template>
</a-table>
<el-empty v-if="!loading && pendingUsers.length === 0" description="暂无待审批用户" />
</el-tab-pane>
<!-- 移动端卡片视图 -->
<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 -->
<el-tab-pane label="所有用户" name="all">
<!-- 用户表格 -->
<el-table
:data="userStore.users"
v-loading="loading"
stripe
border
@selection-change="handleSelectionChange"
<a-tab-pane key="all" tab="所有用户">
<!-- 桌面端表格 -->
<a-table
v-if="!isMobile"
:dataSource="userStore.users"
:columns="allColumns"
:loading="loading"
:row-key="record => record.id"
:row-selection="rowSelection"
:scroll="{ x: 'max-content' }"
bordered
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="alias" label="用户名" min-width="150" show-overflow-tooltip />
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="role" label="角色" width="100">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'danger' : 'primary'">
{{ row.role === 'admin' ? '管理员' : '用户' }}
</el-tag>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'role'">
<a-tag :color="record.role === 'admin' ? 'error' : 'blue'">
{{ record.role === 'admin' ? '管理员' : '用户' }}
</a-tag>
</template>
</el-table-column>
<el-table-column prop="is_approved" label="审批状态" width="100">
<template #default="{ row }">
<el-tag :type="row.is_approved ? 'success' : 'warning'">
{{ row.is_approved ? '已审批' : '待审批' }}
</el-tag>
<template v-else-if="column.key === 'is_approved'">
<a-tag :color="record.is_approved ? 'success' : 'warning'">
{{ record.is_approved ? '已审批' : '待审批' }}
</a-tag>
</template>
</el-table-column>
<el-table-column prop="registered_ip" label="注册IP" width="150" />
<el-table-column prop="jwt_exp" label="Token 过期时间" width="180">
<template #default="{ row }">
{{ row.jwt_exp && row.jwt_exp !== '0' ? formatDateTime(parseInt(row.jwt_exp) * 1000) : '-' }}
<template v-else-if="column.key === 'jwt_exp'">
{{ record.jwt_exp && record.jwt_exp !== '0' ? formatDateTime(parseInt(record.jwt_exp) * 1000) : '-' }}
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button type="primary" size="small" @click="handleEdit(record)">
编辑
</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)">
</a-button>
<a-button danger size="small" @click="handleDelete(record)">
删除
</el-button>
</a-button>
</a-space>
</template>
</el-table-column>
</el-table>
</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 class="batch-actions" v-if="selectedUsers.length > 0">
<el-alert
:title="`已选择 ${selectedUsers.length} 个用户`"
<a-alert
:message="`已选择 ${selectedUsers.length} 个用户`"
type="info"
:closable="false"
>
<template #default>
<div style="margin-top: 10px;">
<el-button type="success" size="small" @click="handleBatchApprove">
<template #description>
<a-space style="margin-top: 10px;">
<a-button type="primary" size="small" @click="handleBatchApprove">
批量审批
</el-button>
<el-button type="danger" size="small" @click="handleBatchDelete">
</a-button>
<a-button danger size="small" @click="handleBatchDelete">
批量删除
</el-button>
</div>
</a-button>
</a-space>
</template>
</el-alert>
</a-alert>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</a-tab-pane>
</a-tabs>
</a-card>
<!-- 创建/编辑用户对话框 -->
<el-dialog
<a-modal
:title="dialogMode === 'create' ? '创建用户' : '编辑用户'"
v-model="dialogVisible"
width="600px"
v-model:open="dialogVisible"
:width="isMobile ? '100%' : 600"
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
>
<el-form
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
layout="vertical"
>
<el-form-item label="用户名" prop="alias">
<el-input v-model="formData.alias" placeholder="请输入用户名" />
</el-form-item>
<a-form-item label="用户名" name="alias">
<a-input v-model:value="formData.alias" placeholder="请输入用户名" />
</a-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="formData.email" placeholder="请输入邮箱" />
</el-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="formData.email" placeholder="请输入邮箱" />
</a-form-item>
<el-form-item label="角色" prop="role">
<el-select v-model="formData.role" placeholder="请选择角色">
<el-option label="用户" value="user" />
<el-option label="管理员" value="admin" />
</el-select>
</el-form-item>
<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>
<el-form-item label="审批状态" prop="is_approved">
<el-switch v-model="formData.is_approved" />
<a-form-item label="审批状态" name="is_approved">
<a-switch v-model:checked="formData.is_approved" />
<span class="form-hint">是否已审批通过</span>
</el-form-item>
</a-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="formData.password"
type="password"
<a-form-item label="密码" name="password">
<a-input-password
v-model:value="formData.password"
:placeholder="dialogMode === 'create' ? '请输入密码' : '留空则不修改密码'"
show-password
/>
<span class="form-hint" v-if="dialogMode === 'edit'">
留空则不修改密码
</span>
</el-form-item>
</a-form-item>
<el-form-item label="重置密码" v-if="dialogMode === 'edit'">
<el-switch v-model="formData.reset_password" />
<a-form-item label="重置密码" v-if="dialogMode === 'edit'">
<a-switch v-model:checked="formData.reset_password" />
<span class="form-hint-danger" v-if="formData.reset_password">
⚠️ 将重置为默认密码
</span>
</el-form-item>
</el-form>
</a-form-item>
</a-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
<a-button @click="dialogVisible = false">取消</a-button>
<a-button type="primary" @click="handleSubmit" :loading="submitting">
确定
</el-button>
</a-button>
</template>
</el-dialog>
</a-modal>
</div>
</Layout>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { UserFilled, Plus, Refresh } from '@element-plus/icons-vue'
import { 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 { useAdminStore } from '@/stores/admin'
import { adminAPI } from '@/api/index'
const userStore = useUserStore()
const adminStore = useAdminStore()
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)
@@ -256,13 +288,43 @@ const formatDateTime = (timestamp) => {
})
}
// 待审批用户表格列
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) {
ElMessage.error(error.message || '获取待审批用户失败')
message.error(error.message || '获取待审批用户失败')
} finally {
loading.value = false
}
@@ -279,48 +341,41 @@ const handleTabChange = (tab) => {
// 审批通过用户
const handleApprove = async (user) => {
Modal.confirm({
title: '审批确认',
content: `确认通过用户 "${user.alias}" 的审批吗?`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
try {
await ElMessageBox.confirm(
`确认通过用户 "${user.alias}" 的审批吗?`,
'审批确认',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'success',
}
)
await adminAPI.approveUser(user.id)
ElMessage.success('审批成功')
message.success('审批成功')
fetchPendingUsers()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '审批失败')
}
message.error(error.message || '审批失败')
}
},
})
}
// 拒绝用户
const handleReject = async (user) => {
Modal.confirm({
title: '拒绝确认',
content: `确认拒绝用户 "${user.alias}" 的申请吗?拒绝后将删除该用户。`,
okText: '确认',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
await ElMessageBox.confirm(
`确认拒绝用户 "${user.alias}" 的申请吗?拒绝后将删除该用户。`,
'拒绝确认',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
}
)
await adminAPI.rejectUser(user.id)
ElMessage.success('已拒绝并删除用户')
message.success('已拒绝并删除用户')
fetchPendingUsers()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '操作失败')
}
message.error(error.message || '操作失败')
}
},
})
}
// 刷新数据
@@ -331,9 +386,9 @@ const handleRefresh = async () => {
loading.value = true
try {
await userStore.fetchUsers()
ElMessage.success('刷新成功')
message.success('刷新成功')
} catch (error) {
ElMessage.error(error.message || '刷新失败')
message.error(error.message || '刷新失败')
} finally {
loading.value = false
}
@@ -379,23 +434,23 @@ const handleSubmit = async () => {
// 检查密码设置冲突
if (dialogMode.value === 'edit' && formData.value.password && formData.value.reset_password) {
ElMessage.warning('不能同时设置新密码和重置密码,请选择其一')
message.warning('不能同时设置新密码和重置密码,请选择其一')
submitting.value = false
return
}
if (dialogMode.value === 'create') {
await userStore.createUser(formData.value)
ElMessage.success('创建成功')
message.success('创建成功')
} else {
await userStore.updateUser(formData.value.id, formData.value)
ElMessage.success('更新成功')
message.success('更新成功')
}
dialogVisible.value = false
await handleRefresh()
} catch (error) {
ElMessage.error(error.message || '操作失败')
message.error(error.message || '操作失败')
} finally {
submitting.value = false
}
@@ -403,41 +458,32 @@ const handleSubmit = async () => {
// 删除用户
const handleDelete = (user) => {
ElMessageBox.confirm(`确定要删除用户 "${user.alias}" 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
Modal.confirm({
title: '警告',
content: `确定要删除用户 "${user.alias}" `,
okText: '确定',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
await userStore.deleteUser(user.id)
ElMessage.success('删除成功')
message.success('删除成功')
await handleRefresh()
} catch (error) {
ElMessage.error(error.message || '删除失败')
message.error(error.message || '删除失败')
}
},
})
.catch(() => {})
}
// 选择改变
const handleSelectionChange = (selection) => {
selectedUsers.value = selection
}
// 批量审批
const handleBatchApprove = async () => {
try {
await ElMessageBox.confirm(
`确认批量审批 ${selectedUsers.value.length} 个用户吗?`,
'批量审批确认',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'success',
}
)
const 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
@@ -451,28 +497,21 @@ const handleBatchApprove = async () => {
}
}
ElMessage.success(`批量审批完成:成功 ${successCount},失败 ${failureCount}`)
message.success(`批量审批完成成功 ${successCount}失败 ${failureCount}`)
await handleRefresh()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '批量审批失败')
}
}
},
})
}
// 批量删除
const handleBatchDelete = async () => {
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedUsers.value.length} 个用户吗?此操作不可恢复!`,
'批量删除警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
const 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
@@ -486,13 +525,10 @@ const handleBatchDelete = async () => {
}
}
ElMessage.success(`批量删除完成:成功 ${successCount},失败 ${failureCount}`)
message.success(`批量删除完成成功 ${successCount}失败 ${failureCount}`)
await handleRefresh()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '批量删除失败')
}
}
},
})
}
onMounted(() => {
@@ -519,10 +555,6 @@ onMounted(() => {
gap: 8px;
}
.actions {
gap: 10px;
}
.batch-actions {
margin-top: 15px;
}
@@ -540,4 +572,8 @@ onMounted(() => {
margin-left: 0;
margin-top: 4px;
}
.mt-3 {
margin-top: 12px;
}
</style>
+4 -4
View File
@@ -29,11 +29,11 @@ export default defineConfig({
rollupOptions: {
output: {
manualChunks(id) {
// Let Vite handle chunking automatically to avoid circular dependencies
// Element Plus will be bundled with its dependencies in the correct order
// Manual chunking for better dependency management
if (id.includes('node_modules')) {
if (id.includes('element-plus')) {
return 'element-plus'
// Ant Design Vue
if (id.includes('ant-design-vue')) {
return 'ant-design-vue'
}
// Group all other vendor code together
return 'vendor'
+1001
View File
File diff suppressed because it is too large Load Diff