feat: improve error handling and code quality

后端改进:
- 添加统一异常处理系统 (exceptions.py, response.py)
- 实现自定义异常类 (ValidationError, AuthorizationError, ResourceNotFoundError, BusinessLogicError)
- 配置全局异常处理器,统一 API 错误响应格式
- 迁移业务逻辑错误到自定义异常 (users.py, auth.py)
- 添加 SQL LIKE 通配符转义,防止通配符滥用
- 使用 EmailStr 进行邮箱格式验证
- 移除敏感字段暴露 (jwt_sub)

前端改进:
- 配置 ESLint 9 (flat config) 和 Prettier
- 修复所有 ESLint 错误和警告
- 移除未使用的变量和导入
- 为组件添加 PropTypes 默认值
- 统一代码格式和风格
This commit is contained in:
2026-01-03 19:01:15 +08:00
parent 523da50123
commit 5cdc8b2144
57 changed files with 4623 additions and 2754 deletions
+5 -3
View File
@@ -12,6 +12,7 @@ from backend.schemas.auth import (
AliasLoginResponse, AliasLoginResponse,
) )
from backend.services.auth_service import AuthService from backend.services.auth_service import AuthService
from backend.exceptions import BusinessLogicError
router = APIRouter() router = APIRouter()
@@ -38,9 +39,10 @@ async def request_qrcode(
if reg_cookie: if reg_cookie:
if not registration_manager.check_registration_cookie(reg_cookie): if not registration_manager.check_registration_cookie(reg_cookie):
raise HTTPException( raise BusinessLogicError(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, message="注册过于频繁,请 10 分钟后再试",
detail="注册过于频繁,请 10 分钟后再试" error_code="RATE_LIMIT_EXCEEDED",
status_code=429
) )
else: else:
# 生成新的 Cookie # 生成新的 Cookie
+12 -37
View File
@@ -8,6 +8,7 @@ from backend.schemas.task import TaskResponse
from backend.services.user_service import UserService from backend.services.user_service import UserService
from backend.services.task_service import TaskService from backend.services.task_service import TaskService
from backend.dependencies import get_current_user, get_current_admin_user from backend.dependencies import get_current_user, get_current_admin_user
from backend.exceptions import ValidationError, AuthorizationError, ResourceNotFoundError
router = APIRouter() router = APIRouter()
@@ -21,18 +22,15 @@ async def create_user(
""" """
创建用户(需要管理员权限) 创建用户(需要管理员权限)
- **jwt_sub**: QQ 扫码登录的唯一用户标识
- **alias**: 用户别名(用于登录) - **alias**: 用户别名(用于登录)
- **role**: 角色(可选,默认 "user" - **role**: 角色(可选,默认 "user"
- **email**: 邮箱地址(可选)
""" """
try: try:
user = UserService.create_user(user_data, db) user = UserService.create_user(user_data, db)
return user return user
except ValueError as e: except ValueError as e:
raise HTTPException( raise ValidationError(str(e))
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -51,7 +49,6 @@ async def get_current_user_info(
user_dict = { user_dict = {
"id": current_user.id, "id": current_user.id,
"alias": current_user.alias, "alias": current_user.alias,
"jwt_sub": current_user.jwt_sub,
"role": current_user.role, "role": current_user.role,
"is_approved": current_user.is_approved, "is_approved": current_user.is_approved,
"jwt_exp": current_user.jwt_exp, "jwt_exp": current_user.jwt_exp,
@@ -99,10 +96,7 @@ async def update_current_user_profile(
user = UserService.update_user_profile(current_user.id, profile_data, db) user = UserService.update_user_profile(current_user.id, profile_data, db)
return user return user
except ValueError as e: except ValueError as e:
raise HTTPException( raise ValidationError(str(e))
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -147,7 +141,6 @@ async def get_current_user_token_status(
return { return {
"is_valid": is_valid, "is_valid": is_valid,
"jwt_exp": current_user.jwt_exp, "jwt_exp": current_user.jwt_exp,
"jwt_sub": current_user.jwt_sub,
"expires_at": expires_at, "expires_at": expires_at,
"days_until_expiry": days_until_expiry, "days_until_expiry": days_until_expiry,
"expiring_soon": expiring_soon "expiring_soon": expiring_soon
@@ -179,7 +172,7 @@ async def get_current_user_tasks(
async def get_all_users( async def get_all_users(
skip: int = Query(0, ge=0, description="跳过记录数"), skip: int = Query(0, ge=0, description="跳过记录数"),
limit: int = Query(100, ge=1, le=500, description="限制记录数"), limit: int = Query(100, ge=1, le=500, description="限制记录数"),
search: Optional[str] = Query(None, description="搜索关键词(alias 或 jwt_sub"), search: Optional[str] = Query(None, description="搜索关键词(alias"),
role: Optional[str] = Query(None, description="过滤角色 (user/admin)"), role: Optional[str] = Query(None, description="过滤角色 (user/admin)"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user) current_user: User = Depends(get_current_admin_user)
@@ -189,7 +182,7 @@ async def get_all_users(
- **skip**: 跳过记录数 - **skip**: 跳过记录数
- **limit**: 限制记录数 - **limit**: 限制记录数
- **search**: 搜索关键词(模糊匹配 alias 或 jwt_sub - **search**: 搜索关键词(模糊匹配 alias)
- **role**: 过滤角色(user/admin - **role**: 过滤角色(user/admin
""" """
try: try:
@@ -216,17 +209,11 @@ async def get_user(
""" """
# 检查权限 # 检查权限
if current_user.role != "admin" and current_user.id != user_id: if current_user.role != "admin" and current_user.id != user_id:
raise HTTPException( raise AuthorizationError("权限不足,只能查看自己的信息")
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足,只能查看自己的信息"
)
user = UserService.get_user_by_id(user_id, db) user = UserService.get_user_by_id(user_id, db)
if not user: if not user:
raise HTTPException( raise ResourceNotFoundError(f"用户 ID {user_id} 不存在")
status_code=status.HTTP_404_NOT_FOUND,
detail=f"用户 ID {user_id} 不存在"
)
return user return user
@@ -247,25 +234,16 @@ async def update_user(
# 检查权限 # 检查权限
if current_user.role != "admin": if current_user.role != "admin":
if current_user.id != user_id: if current_user.id != user_id:
raise HTTPException( raise AuthorizationError("权限不足,只能更新自己的信息")
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足,只能更新自己的信息"
)
# 普通用户不能修改 role # 普通用户不能修改 role
if user_data.role is not None: if user_data.role is not None:
raise HTTPException( raise AuthorizationError("普通用户不能修改角色")
status_code=status.HTTP_403_FORBIDDEN,
detail="普通用户不能修改角色"
)
try: try:
# 获取更新前的用户状态 # 获取更新前的用户状态
old_user = UserService.get_user_by_id(user_id, db) old_user = UserService.get_user_by_id(user_id, db)
if not old_user: if not old_user:
raise HTTPException( raise ResourceNotFoundError(f"用户 ID {user_id} 不存在")
status_code=status.HTTP_404_NOT_FOUND,
detail=f"用户 ID {user_id} 不存在"
)
# 保存更新前的审批状态 (先读取后转换为 Python bool) # 保存更新前的审批状态 (先读取后转换为 Python bool)
old_approved_value = old_user.is_approved old_approved_value = old_user.is_approved
@@ -316,10 +294,7 @@ async def delete_user(
UserService.delete_user(user_id, db) UserService.delete_user(user_id, db)
return None return None
except ValueError as e: except ValueError as e:
raise HTTPException( raise ResourceNotFoundError(str(e))
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+64
View File
@@ -0,0 +1,64 @@
"""
自定义异常类
提供统一的异常处理机制,避免直接抛出通用Exception
"""
class BaseAPIException(Exception):
"""API 异常基类"""
def __init__(self, message: str, status_code: int = 500, error_code: str = None):
self.message = message
self.status_code = status_code
self.error_code = error_code or self.__class__.__name__
super().__init__(self.message)
class ValidationError(BaseAPIException):
"""验证错误 - 400"""
def __init__(self, message: str, error_code: str = "VALIDATION_ERROR"):
super().__init__(message, status_code=400, error_code=error_code)
class AuthenticationError(BaseAPIException):
"""认证错误 - 401"""
def __init__(self, message: str = "未授权", error_code: str = "AUTHENTICATION_ERROR"):
super().__init__(message, status_code=401, error_code=error_code)
class AuthorizationError(BaseAPIException):
"""授权错误 - 403"""
def __init__(self, message: str = "无权限访问", error_code: str = "AUTHORIZATION_ERROR"):
super().__init__(message, status_code=403, error_code=error_code)
class ResourceNotFoundError(BaseAPIException):
"""资源未找到 - 404"""
def __init__(self, message: str = "资源未找到", error_code: str = "NOT_FOUND"):
super().__init__(message, status_code=404, error_code=error_code)
class ResourceConflictError(BaseAPIException):
"""资源冲突 - 409"""
def __init__(self, message: str = "资源冲突", error_code: str = "CONFLICT"):
super().__init__(message, status_code=409, error_code=error_code)
class BusinessLogicError(BaseAPIException):
"""业务逻辑错误 - 默认422,但可自定义状态码(如429)"""
def __init__(self, message: str, error_code: str = "BUSINESS_ERROR", status_code: int = 422):
super().__init__(message, status_code=status_code, error_code=error_code)
class InternalServerError(BaseAPIException):
"""服务器内部错误 - 500"""
def __init__(self, message: str = "服务器内部错误", error_code: str = "INTERNAL_ERROR"):
super().__init__(message, status_code=500, error_code=error_code)
+59 -1
View File
@@ -1,11 +1,16 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError as PydanticValidationError
import logging import logging
from pathlib import Path from pathlib import Path
from backend.config import settings from backend.config import settings
from backend.models import init_db from backend.models import init_db
from backend.exceptions import BaseAPIException
from backend.schemas.response import ErrorResponse, ErrorDetail
# 配置日志 # 配置日志
settings.LOG_FILE.parent.mkdir(parents=True, exist_ok=True) settings.LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
@@ -69,6 +74,59 @@ app.add_middleware(
) )
# 全局异常处理器
@app.exception_handler(BaseAPIException)
async def api_exception_handler(request: Request, exc: BaseAPIException):
"""处理自定义 API 异常"""
return JSONResponse(
status_code=exc.status_code,
content=ErrorResponse(
error=ErrorDetail(
code=exc.error_code,
message=exc.message
)
).model_dump()
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""处理请求验证错误"""
errors = exc.errors()
# 取第一个错误作为主要错误消息
first_error = errors[0] if errors else {}
field = ".".join(str(loc) for loc in first_error.get("loc", []))
message = first_error.get("msg", "验证错误")
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=ErrorResponse(
error=ErrorDetail(
code="VALIDATION_ERROR",
message=message,
field=field or None
)
).model_dump()
)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""处理未捕获的异常"""
logger.error(f"未处理的异常: {type(exc).__name__}: {str(exc)}", exc_info=True)
# 不向客户端暴露内部错误详情
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content=ErrorResponse(
error=ErrorDetail(
code="INTERNAL_ERROR",
message="服务器内部错误,请稍后重试"
)
).model_dump()
)
# 健康检查端点 # 健康检查端点
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():
+28
View File
@@ -0,0 +1,28 @@
"""
统一的 API 响应 Schema
"""
from typing import Generic, TypeVar, Optional
from pydantic import BaseModel
T = TypeVar('T')
class ApiResponse(BaseModel, Generic[T]):
"""统一成功响应"""
success: bool = True
data: Optional[T] = None
message: Optional[str] = None
class ErrorDetail(BaseModel):
"""错误详情"""
code: str
message: str
field: Optional[str] = None # 字段验证错误时使用
class ErrorResponse(BaseModel):
"""统一错误响应"""
success: bool = False
error: ErrorDetail
+5 -7
View File
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, EmailStr
class UserBase(BaseModel): class UserBase(BaseModel):
@@ -11,7 +11,7 @@ class UserBase(BaseModel):
class UserCreate(UserBase): class UserCreate(UserBase):
"""创建用户 Schema(管理员手动创建,只需要别名)""" """创建用户 Schema(管理员手动创建,只需要别名)"""
role: Optional[str] = Field("user", description="角色: user/admin") role: Optional[str] = Field("user", description="角色: user/admin")
email: Optional[str] = Field(None, description="邮箱地址") email: Optional[EmailStr] = Field(None, description="邮箱地址")
is_approved: Optional[bool] = Field(True, description="是否已审批(默认已审批)") is_approved: Optional[bool] = Field(True, description="是否已审批(默认已审批)")
@@ -20,7 +20,7 @@ class UserUpdate(BaseModel):
alias: Optional[str] = Field(None, min_length=2, max_length=50, description="用户别名") alias: Optional[str] = Field(None, min_length=2, max_length=50, description="用户别名")
role: Optional[str] = None role: Optional[str] = None
is_approved: Optional[bool] = None is_approved: Optional[bool] = None
email: Optional[str] = None email: Optional[EmailStr] = None
password: Optional[str] = Field(None, min_length=6, description="新密码(可选,留空表示不修改)") password: Optional[str] = Field(None, min_length=6, description="新密码(可选,留空表示不修改)")
reset_password: Optional[bool] = Field(False, description="是否清空密码") reset_password: Optional[bool] = Field(False, description="是否清空密码")
@@ -28,7 +28,7 @@ class UserUpdate(BaseModel):
class UserUpdateProfile(BaseModel): class UserUpdateProfile(BaseModel):
"""用户更新个人信息 Schema""" """用户更新个人信息 Schema"""
alias: Optional[str] = Field(None, min_length=2, max_length=50, description="新别名") alias: Optional[str] = Field(None, min_length=2, max_length=50, description="新别名")
email: Optional[str] = Field(None, description="邮箱地址") email: Optional[EmailStr] = Field(None, description="邮箱地址")
current_password: Optional[str] = Field(None, min_length=6, description="当前密码(修改密码时必填)") current_password: Optional[str] = Field(None, min_length=6, description="当前密码(修改密码时必填)")
new_password: Optional[str] = Field(None, min_length=6, description="新密码") new_password: Optional[str] = Field(None, min_length=6, description="新密码")
@@ -37,11 +37,10 @@ class UserResponse(BaseModel):
"""用户响应 Schema""" """用户响应 Schema"""
id: int id: int
alias: str alias: str
jwt_sub: Optional[str] = None
role: str role: str
is_approved: bool is_approved: bool
jwt_exp: str jwt_exp: str
email: Optional[str] = None email: Optional[EmailStr] = None
has_password: bool = False # 是否已设置密码 has_password: bool = False # 是否已设置密码
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
@@ -59,7 +58,6 @@ class TokenStatus(BaseModel):
"""Token 状态 Schema""" """Token 状态 Schema"""
is_valid: bool is_valid: bool
jwt_exp: str jwt_exp: str
jwt_sub: Optional[str] = None
expires_at: Optional[int] = None # Unix 时间戳(秒) expires_at: Optional[int] = None # Unix 时间戳(秒)
days_until_expiry: Optional[int] = None days_until_expiry: Optional[int] = None
expiring_soon: bool = False # 是否即将过期(30分钟内) expiring_soon: bool = False # 是否即将过期(30分钟内)
+124
View File
@@ -0,0 +1,124 @@
"""
测试新的异常处理系统
"""
import sys
sys.path.insert(0, '..')
from backend.exceptions import (
ValidationError,
AuthenticationError,
AuthorizationError,
ResourceNotFoundError,
BusinessLogicError,
)
from backend.schemas.response import ErrorResponse, ErrorDetail
def test_exceptions():
"""测试自定义异常"""
print("=" * 60)
print("测试自定义异常类")
print("=" * 60)
# 测试 ValidationError
try:
raise ValidationError("用户名长度必须在2-50之间")
except ValidationError as e:
print(f"✅ ValidationError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
# 测试 AuthenticationError
try:
raise AuthenticationError("Token已过期")
except AuthenticationError as e:
print(f"✅ AuthenticationError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
# 测试 AuthorizationError
try:
raise AuthorizationError("需要管理员权限")
except AuthorizationError as e:
print(f"✅ AuthorizationError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
# 测试 ResourceNotFoundError
try:
raise ResourceNotFoundError("用户不存在")
except ResourceNotFoundError as e:
print(f"✅ ResourceNotFoundError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
# 测试 BusinessLogicError
try:
raise BusinessLogicError("打卡任务已禁用")
except BusinessLogicError as e:
print(f"✅ BusinessLogicError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
def test_response_schemas():
"""测试响应 Schema"""
print("\n" + "=" * 60)
print("测试响应 Schema")
print("=" * 60)
# 测试 ErrorResponse
error_response = ErrorResponse(
error=ErrorDetail(
code="VALIDATION_ERROR",
message="邮箱格式不正确",
field="email"
)
)
response_dict = error_response.model_dump()
print(f"✅ ErrorResponse 序列化成功:")
print(f" {response_dict}")
assert response_dict["success"] == False
assert response_dict["error"]["code"] == "VALIDATION_ERROR"
assert response_dict["error"]["message"] == "邮箱格式不正确"
assert response_dict["error"]["field"] == "email"
print("✅ 所有断言通过")
def check_old_exception_patterns():
"""检查旧的异常处理模式"""
print("\n" + "=" * 60)
print("检查需要更新的旧异常代码")
print("=" * 60)
import os
import re
patterns = {
"HTTPException with detail": r'raise HTTPException.*detail=f?".*{',
"except Exception": r'except Exception as',
}
results = {}
for pattern_name, pattern in patterns.items():
results[pattern_name] = []
for root, dirs, files in os.walk('../backend/api'):
for file in files:
if file.endswith('.py'):
filepath = os.path.join(root, file)
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
matches = re.findall(pattern, content, re.MULTILINE)
if matches:
results[pattern_name].append((filepath, len(matches)))
for pattern_name, files in results.items():
print(f"\n{pattern_name}:")
if files:
print(f" ⚠️ 发现 {sum(count for _, count in files)} 处使用")
for filepath, count in files:
print(f" - {filepath}: {count}")
else:
print(f" ✅ 未发现使用")
if __name__ == "__main__":
test_exceptions()
test_response_schemas()
check_old_exception_patterns()
print("\n" + "=" * 60)
print("✅ 所有测试通过!新的异常处理系统工作正常")
print("=" * 60)
+17 -2
View File
@@ -10,6 +10,19 @@ from backend.schemas.user import UserCreate, UserUpdate, UserUpdateProfile
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def escape_like_pattern(text: str) -> str:
"""
转义 LIKE 查询中的特殊字符
Args:
text: 原始搜索文本
Returns:
转义后的文本
"""
return text.replace('%', r'\%').replace('_', r'\_')
class UserService: class UserService:
"""用户服务""" """用户服务"""
@@ -114,10 +127,12 @@ class UserService:
# 搜索过滤 # 搜索过滤
if search: if search:
# 转义 LIKE 特殊字符,防止通配符滥用
escaped_search = escape_like_pattern(search)
# 注意:jwt_sub 可能为 NULL,需要处理 # 注意:jwt_sub 可能为 NULL,需要处理
search_conditions = [User.alias.ilike(f"%{search}%")] search_conditions = [User.alias.ilike(f"%{escaped_search}%")]
# 只有当 jwt_sub 不为空时才搜索 # 只有当 jwt_sub 不为空时才搜索
search_conditions.append(User.jwt_sub.ilike(f"%{search}%")) search_conditions.append(User.jwt_sub.ilike(f"%{escaped_search}%"))
query = query.filter(or_(*search_conditions)) query = query.filter(or_(*search_conditions))
# 角色过滤 # 角色过滤
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
*.local
+9
View File
@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"arrowParens": "avoid",
"endOfLine": "auto"
}
+38
View File
@@ -0,0 +1,38 @@
import js from '@eslint/js';
import pluginVue from 'eslint-plugin-vue';
import prettierConfig from '@vue/eslint-config-prettier';
export default [
{
ignores: ['node_modules', 'dist', '*.local'],
},
js.configs.recommended,
...pluginVue.configs['flat/recommended'],
prettierConfig,
{
languageOptions: {
globals: {
// 浏览器环境
window: 'readonly',
document: 'readonly',
localStorage: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
navigator: 'readonly',
// Node.js 环境(用于配置文件)
process: 'readonly',
__dirname: 'readonly',
},
},
rules: {
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'warn',
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-unused-vars': 'warn',
},
},
];
+1262
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -6,7 +6,9 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix",
"format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@ant-design/icons-vue": "^7.0.1", "@ant-design/icons-vue": "^7.0.1",
@@ -17,9 +19,14 @@
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.6.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.7.4",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"vite": "^7.2.4" "vite": "^7.2.4"
} }
+1 -1
View File
@@ -3,4 +3,4 @@ export default {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} };
+17 -16
View File
@@ -5,37 +5,37 @@
</template> </template>
<script setup> <script setup>
import { onMounted, computed } from 'vue' import { onMounted, computed } from 'vue';
import { ConfigProvider as AConfigProvider } from 'ant-design-vue' import { ConfigProvider as AConfigProvider } from 'ant-design-vue';
import zhCN from 'ant-design-vue/es/locale/zh_CN' import zhCN from 'ant-design-vue/es/locale/zh_CN';
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth';
import getAntdTheme from './antd-theme' import getAntdTheme from './antd-theme';
import { useTheme, initTheme, watchSystemTheme } from '@/composables/useTheme' import { useTheme, initTheme, watchSystemTheme } from '@/composables/useTheme';
const authStore = useAuthStore() const authStore = useAuthStore();
// 初始化主题(全局) // 初始化主题(全局)
initTheme() initTheme();
watchSystemTheme() watchSystemTheme();
// 使用主题 // 使用主题
const { isDark } = useTheme() const { isDark } = useTheme();
// 动态生成 Ant Design 主题 // 动态生成 Ant Design 主题
const antdTheme = computed(() => getAntdTheme(isDark.value)) const antdTheme = computed(() => getAntdTheme(isDark.value));
// 应用启动时验证 Token // 应用启动时验证 Token
onMounted(async () => { onMounted(async () => {
if (authStore.isAuthenticated) { if (authStore.isAuthenticated) {
try { try {
await authStore.fetchCurrentUser() await authStore.fetchCurrentUser();
} catch (error) { } catch (error) {
console.error('验证用户信息失败:', error) console.error('验证用户信息失败:', error);
// Token 可能已过期,清除认证状态 // Token 可能已过期,清除认证状态
authStore.clearAuth() authStore.clearAuth();
} }
} }
}) });
</script> </script>
<style> <style>
@@ -58,7 +58,8 @@ body {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 100vh; min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
+2 -2
View File
@@ -1,4 +1,4 @@
import { theme } from 'ant-design-vue' import { theme } from 'ant-design-vue';
/** /**
* Ant Design Vue 主题配置 * Ant Design Vue 主题配置
@@ -212,5 +212,5 @@ export default function getAntdTheme(isDark = false) {
// 算法配置 - 使用 Ant Design 内置的暗黑算法 // 算法配置 - 使用 Ant Design 内置的暗黑算法
algorithm: isDark ? [theme.darkAlgorithm] : [], algorithm: isDark ? [theme.darkAlgorithm] : [],
} };
} }
+33 -32
View File
@@ -1,4 +1,4 @@
import axios from 'axios' import axios from 'axios';
// 创建 axios 实例 // 创建 axios 实例
const client = axios.create({ const client = axios.create({
@@ -7,45 +7,45 @@ const client = axios.create({
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}) });
// 请求拦截器 - 添加 Token // 请求拦截器 - 添加 Token
client.interceptors.request.use( client.interceptors.request.use(
(config) => { config => {
const token = localStorage.getItem('token') const token = localStorage.getItem('token');
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`;
} }
return config return config;
}, },
(error) => { error => {
return Promise.reject(error) return Promise.reject(error);
} }
) );
// 响应拦截器 - 统一错误处理 // 响应拦截器 - 统一错误处理
client.interceptors.response.use( client.interceptors.response.use(
(response) => { response => {
return response.data return response.data;
}, },
(error) => { error => {
if (error.response) { if (error.response) {
// 服务器返回错误状态码 // 服务器返回错误状态码
const { status, data } = error.response const { status, data } = error.response;
if (status === 401) { if (status === 401) {
const errorDetail = data.detail || data.message || '' const errorDetail = data.detail || data.message || '';
// 检查用户是否设置了密码 // 检查用户是否设置了密码
const user = JSON.parse(localStorage.getItem('user') || '{}') const user = JSON.parse(localStorage.getItem('user') || '{}');
const hasPassword = user.has_password || false const hasPassword = user.has_password || false;
// Token 过期的情况 // Token 过期的情况
if (errorDetail.includes('过期')) { if (errorDetail.includes('过期')) {
if (hasPassword) { if (hasPassword) {
// 有密码的用户:不强制退出,只显示警告 // 有密码的用户:不强制退出,只显示警告
// 不清除 localStorage,让用户继续使用 // 不清除 localStorage,让用户继续使用
console.warn('Token 已过期,但用户设置了密码,允许继续使用') console.warn('Token 已过期,但用户设置了密码,允许继续使用');
// 返回错误但不跳转登录页 // 返回错误但不跳转登录页
return Promise.reject({ return Promise.reject({
@@ -53,29 +53,29 @@ client.interceptors.response.use(
message: '登录凭证已过期,部分功能可能受限,建议刷新凭证', message: '登录凭证已过期,部分功能可能受限,建议刷新凭证',
data, data,
tokenExpired: true, tokenExpired: true,
}) });
} else { } else {
// 没有密码的用户:必须重新登录 // 没有密码的用户:必须重新登录
localStorage.removeItem('token') localStorage.removeItem('token');
localStorage.removeItem('user') localStorage.removeItem('user');
// 延迟跳转,避免阻塞当前异步请求的错误处理 // 延迟跳转,避免阻塞当前异步请求的错误处理
setTimeout(() => { setTimeout(() => {
if (window.location.pathname !== '/login') { if (window.location.pathname !== '/login') {
window.location.href = '/login' window.location.href = '/login';
} }
}, 100) }, 100);
} }
} else { } else {
// 其他 401 错误(无效 Token 等):清除登录状态 // 其他 401 错误(无效 Token 等):清除登录状态
localStorage.removeItem('token') localStorage.removeItem('token');
localStorage.removeItem('user') localStorage.removeItem('user');
setTimeout(() => { setTimeout(() => {
if (window.location.pathname !== '/login') { if (window.location.pathname !== '/login') {
window.location.href = '/login' window.location.href = '/login';
} }
}, 100) }, 100);
} }
} }
@@ -84,23 +84,24 @@ client.interceptors.response.use(
status, status,
message: data.detail || data.message || '请求失败', message: data.detail || data.message || '请求失败',
data, data,
}) });
} else if (error.request) { } else if (error.request) {
// 请求已发出但没有收到响应(超时或网络错误) // 请求已发出但没有收到响应(超时或网络错误)
return Promise.reject({ return Promise.reject({
status: 0, status: 0,
message: error.code === 'ECONNABORTED' ? '请求超时,请稍后重试' : '网络错误,请检查您的网络连接', message:
error.code === 'ECONNABORTED' ? '请求超时,请稍后重试' : '网络错误,请检查您的网络连接',
data: null, data: null,
}) });
} else { } else {
// 发生了触发请求错误的问题 // 发生了触发请求错误的问题
return Promise.reject({ return Promise.reject({
status: 0, status: 0,
message: error.message || '请求配置错误', message: error.message || '请求配置错误',
data: null, data: null,
}) });
} }
} }
) );
export default client export default client;
+81 -77
View File
@@ -1,34 +1,34 @@
import client from './client' import client from './client';
/** /**
* 认证 API * 认证 API
*/ */
export const authAPI = { export const authAPI = {
// 请求 QR 码 // 请求 QR 码
requestQRCode: (alias) => { requestQRCode: alias => {
return client.post('/api/auth/request_qrcode', { alias }) return client.post('/api/auth/request_qrcode', { alias });
}, },
// 查询扫码状态 // 查询扫码状态
getQRCodeStatus: (sessionId) => { getQRCodeStatus: sessionId => {
return client.get(`/api/auth/qrcode_status/${sessionId}`) return client.get(`/api/auth/qrcode_status/${sessionId}`);
}, },
// 取消 QR 码登录会话 // 取消 QR 码登录会话
cancelQRCodeSession: (sessionId) => { cancelQRCodeSession: sessionId => {
return client.delete(`/api/auth/qrcode_session/${sessionId}`) return client.delete(`/api/auth/qrcode_session/${sessionId}`);
}, },
// 别名+密码登录 // 别名+密码登录
aliasLogin: (alias, password) => { aliasLogin: (alias, password) => {
return client.post('/api/auth/alias_login', { alias, password }) return client.post('/api/auth/alias_login', { alias, password });
}, },
// 验证 Token // 验证 Token
verifyToken: (token) => { verifyToken: token => {
return client.post('/api/auth/verify_token', { token }) return client.post('/api/auth/verify_token', { token });
}, },
} };
/** /**
* 用户 API * 用户 API
@@ -36,49 +36,49 @@ export const authAPI = {
export const userAPI = { export const userAPI = {
// 获取当前用户信息 // 获取当前用户信息
getCurrentUser: () => { getCurrentUser: () => {
return client.get('/api/users/me') return client.get('/api/users/me');
}, },
// 获取当前用户审批状态 // 获取当前用户审批状态
getUserStatus: () => { getUserStatus: () => {
return client.get('/api/users/me/status') return client.get('/api/users/me/status');
}, },
// 获取当前用户 Token 状态 // 获取当前用户 Token 状态
getTokenStatus: () => { getTokenStatus: () => {
return client.get('/api/users/me/token_status') return client.get('/api/users/me/token_status');
}, },
// 更新当前用户个人信息 // 更新当前用户个人信息
updateProfile: (profileData) => { updateProfile: profileData => {
return client.put('/api/users/me/profile', profileData) return client.put('/api/users/me/profile', profileData);
}, },
// 创建用户(管理员) // 创建用户(管理员)
createUser: (userData) => { createUser: userData => {
return client.post('/api/users', userData) return client.post('/api/users', userData);
}, },
// 获取所有用户(管理员) // 获取所有用户(管理员)
getUsers: (params = {}) => { getUsers: (params = {}) => {
return client.get('/api/users', { params }) return client.get('/api/users', { params });
}, },
// 获取指定用户 // 获取指定用户
getUser: (userId) => { getUser: userId => {
return client.get(`/api/users/${userId}`) return client.get(`/api/users/${userId}`);
}, },
// 更新用户 // 更新用户
updateUser: (userId, userData) => { updateUser: (userId, userData) => {
return client.put(`/api/users/${userId}`, userData) return client.put(`/api/users/${userId}`, userData);
}, },
// 删除用户 // 删除用户
deleteUser: (userId) => { deleteUser: userId => {
return client.delete(`/api/users/${userId}`) return client.delete(`/api/users/${userId}`);
}, },
} };
/** /**
* 任务 API (V2 新增) * 任务 API (V2 新增)
@@ -86,77 +86,81 @@ export const userAPI = {
export const taskAPI = { export const taskAPI = {
// 获取当前用户的任务列表 // 获取当前用户的任务列表
getMyTasks: (params = {}) => { getMyTasks: (params = {}) => {
return client.get('/api/tasks', { params }) return client.get('/api/tasks', { params });
}, },
// 创建任务 // 创建任务
createTask: (taskData) => { createTask: taskData => {
return client.post('/api/tasks', taskData) return client.post('/api/tasks', taskData);
}, },
// 获取任务详情 // 获取任务详情
getTask: (taskId) => { getTask: taskId => {
return client.get(`/api/tasks/${taskId}`) return client.get(`/api/tasks/${taskId}`);
}, },
// 更新任务 // 更新任务
updateTask: (taskId, taskData) => { updateTask: (taskId, taskData) => {
return client.put(`/api/tasks/${taskId}`, taskData) return client.put(`/api/tasks/${taskId}`, taskData);
}, },
// 删除任务 // 删除任务
deleteTask: (taskId) => { deleteTask: taskId => {
return client.delete(`/api/tasks/${taskId}`) return client.delete(`/api/tasks/${taskId}`);
}, },
// 切换任务启用状态 // 切换任务启用状态
toggleTask: (taskId) => { toggleTask: taskId => {
return client.post(`/api/tasks/${taskId}/toggle`) return client.post(`/api/tasks/${taskId}/toggle`);
}, },
// 手动触发任务打卡(异步,立即返回) // 手动触发任务打卡(异步,立即返回)
checkInTask: (taskId) => { checkInTask: taskId => {
return client.post(`/api/check_in/manual/${taskId}`) return client.post(`/api/check_in/manual/${taskId}`);
}, },
// 查询打卡记录状态 // 查询打卡记录状态
getCheckInRecordStatus: (recordId) => { getCheckInRecordStatus: recordId => {
return client.get(`/api/check_in/record/${recordId}/status`) return client.get(`/api/check_in/record/${recordId}/status`);
}, },
// 获取任务的打卡记录 // 获取任务的打卡记录
getTaskRecords: (taskId, params = {}) => { getTaskRecords: (taskId, params = {}) => {
return client.get(`/api/check_in/task/${taskId}/records`, { params }) return client.get(`/api/check_in/task/${taskId}/records`, { params });
}, },
} };
/** /**
* 打卡 API * 打卡 API
*/ */
export const checkInAPI = { export const checkInAPI = {
// 手动打卡(兼容旧版,推荐使用 taskAPI.checkInTask // 手动打卡(兼容旧版,推荐使用 taskAPI.checkInTask
manualCheckIn: (taskId) => { manualCheckIn: taskId => {
// 打卡操作耗时较长,设置 120 秒超时 // 打卡操作耗时较长,设置 120 秒超时
return client.post(`/api/check_in/manual/${taskId}`, {}, { return client.post(
timeout: 120000 // 120 秒 `/api/check_in/manual/${taskId}`,
}) {},
{
timeout: 120000, // 120 秒
}
);
}, },
// 获取任务打卡记录(兼容旧版,推荐使用 taskAPI.getTaskRecords // 获取任务打卡记录(兼容旧版,推荐使用 taskAPI.getTaskRecords
getMyRecords: (params = {}) => { getMyRecords: (params = {}) => {
return client.get('/api/check_in/my-records', { params }) return client.get('/api/check_in/my-records', { params });
}, },
// 获取所有打卡记录(管理员) // 获取所有打卡记录(管理员)
getAllRecords: (params = {}) => { getAllRecords: (params = {}) => {
return client.get('/api/check_in/records', { params }) return client.get('/api/check_in/records', { params });
}, },
// 统计打卡记录数 // 统计打卡记录数
getRecordsCount: (params = {}) => { getRecordsCount: (params = {}) => {
return client.get('/api/check_in/records/count', { params }) return client.get('/api/check_in/records/count', { params });
}, },
} };
/** /**
* 管理员 API * 管理员 API
@@ -164,44 +168,44 @@ export const checkInAPI = {
export const adminAPI = { export const adminAPI = {
// 获取待审批用户 // 获取待审批用户
getPendingUsers: () => { getPendingUsers: () => {
return client.get('/api/admin/users/pending') return client.get('/api/admin/users/pending');
}, },
// 审批通过用户 // 审批通过用户
approveUser: (userId) => { approveUser: userId => {
return client.post(`/api/admin/users/${userId}/approve`) return client.post(`/api/admin/users/${userId}/approve`);
}, },
// 拒绝用户 // 拒绝用户
rejectUser: (userId) => { rejectUser: userId => {
return client.delete(`/api/admin/users/${userId}/reject`) return client.delete(`/api/admin/users/${userId}/reject`);
}, },
// 批量启用/禁用任务(V2 更新) // 批量启用/禁用任务(V2 更新)
batchToggleTasks: (taskIds, isActive) => { batchToggleTasks: (taskIds, isActive) => {
return client.post('/api/admin/batch_toggle_tasks', { return client.post('/api/admin/batch_toggle_tasks', {
task_ids: taskIds, task_ids: taskIds,
is_active: isActive is_active: isActive,
}) });
}, },
// 批量触发打卡(V2 更新) // 批量触发打卡(V2 更新)
batchCheckIn: (taskIds) => { batchCheckIn: taskIds => {
return client.post('/api/admin/batch_check_in', { return client.post('/api/admin/batch_check_in', {
task_ids: taskIds task_ids: taskIds,
}) });
}, },
// 查看系统日志 // 查看系统日志
getLogs: (params = {}) => { getLogs: (params = {}) => {
return client.get('/api/admin/logs', { params }) return client.get('/api/admin/logs', { params });
}, },
// 系统统计信息 // 系统统计信息
getStats: () => { getStats: () => {
return client.get('/api/admin/stats') return client.get('/api/admin/stats');
}, },
} };
/** /**
* 模板 API * 模板 API
@@ -209,44 +213,44 @@ export const adminAPI = {
export const templateAPI = { export const templateAPI = {
// 获取所有模板列表 // 获取所有模板列表
getTemplates: (params = {}) => { getTemplates: (params = {}) => {
return client.get('/api/templates', { params }) return client.get('/api/templates', { params });
}, },
// 获取启用的模板列表 // 获取启用的模板列表
getActiveTemplates: (params = {}) => { getActiveTemplates: (params = {}) => {
return client.get('/api/templates/active', { params }) return client.get('/api/templates/active', { params });
}, },
// 获取单个模板详情 // 获取单个模板详情
getTemplate: (templateId) => { getTemplate: templateId => {
return client.get(`/api/templates/${templateId}`) return client.get(`/api/templates/${templateId}`);
}, },
// 预览模板生成的 payload // 预览模板生成的 payload
previewTemplate: (templateId) => { previewTemplate: templateId => {
return client.get(`/api/templates/${templateId}/preview`) return client.get(`/api/templates/${templateId}/preview`);
}, },
// 创建模板(管理员) // 创建模板(管理员)
createTemplate: (templateData) => { createTemplate: templateData => {
return client.post('/api/templates', templateData) return client.post('/api/templates', templateData);
}, },
// 更新模板(管理员) // 更新模板(管理员)
updateTemplate: (templateId, templateData) => { updateTemplate: (templateId, templateData) => {
return client.put(`/api/templates/${templateId}`, templateData) return client.put(`/api/templates/${templateId}`, templateData);
}, },
// 删除模板(管理员) // 删除模板(管理员)
deleteTemplate: (templateId) => { deleteTemplate: templateId => {
return client.delete(`/api/templates/${templateId}`) return client.delete(`/api/templates/${templateId}`);
}, },
// 从模板创建任务 // 从模板创建任务
createTaskFromTemplate: (requestData) => { createTaskFromTemplate: requestData => {
return client.post('/api/templates/create-task', requestData) return client.post('/api/templates/create-task', requestData);
}, },
} };
// 导出所有 API // 导出所有 API
export default { export default {
@@ -256,4 +260,4 @@ export default {
checkIn: checkInAPI, checkIn: checkInAPI,
admin: adminAPI, admin: adminAPI,
template: templateAPI, // V2.2 新增 template: templateAPI, // V2.2 新增
} };
+123 -112
View File
@@ -6,9 +6,9 @@
v-for="m in modes" v-for="m in modes"
:key="m" :key="m"
:class="{ active: mode === m }" :class="{ active: mode === m }"
@click.prevent="switchMode(m)"
class="mode-tab" class="mode-tab"
type="button" type="button"
@click.prevent="switchMode(m)"
> >
{{ modeLabels[m] }} {{ modeLabels[m] }}
</button> </button>
@@ -36,16 +36,12 @@
format="HH:mm" format="HH:mm"
placeholder="选择时间" placeholder="选择时间"
:minute-step="30" :minute-step="30"
@change="onCustomTimeChange"
style="width: 100%" style="width: 100%"
@change="onCustomTimeChange"
/> />
</a-form-item> </a-form-item>
<a-form-item label="频率" name="customFrequency"> <a-form-item label="频率" name="customFrequency">
<a-select <a-select id="cron-custom-frequency" v-model:value="customFrequency" style="width: 100%">
id="cron-custom-frequency"
v-model:value="customFrequency"
style="width: 100%"
>
<a-select-option value="daily">每天</a-select-option> <a-select-option value="daily">每天</a-select-option>
<a-select-option value="weekday">工作日周一-周五</a-select-option> <a-select-option value="weekday">工作日周一-周五</a-select-option>
<a-select-option value="weekend">周末周六-周日</a-select-option> <a-select-option value="weekend">周末周六-周日</a-select-option>
@@ -86,67 +82,70 @@
</template> </template>
<script setup> <script setup>
import { ref, watch, onBeforeUnmount } from 'vue' import { ref, watch, onBeforeUnmount } from 'vue';
import dayjs from 'dayjs' import dayjs from 'dayjs';
import client from '@/api/client' import client from '@/api/client';
const props = defineProps({ const props = defineProps({
modelValue: String, // 当前 cron 表达式 modelValue: {
}) type: String,
default: '0 0 * * *',
},
});
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue']);
const mode = ref('quick') const mode = ref('quick');
const modeLabels = { const modeLabels = {
quick: '快速', quick: '快速',
custom: '自定义', custom: '自定义',
advanced: '高级' advanced: '高级',
} };
const modes = ['quick', 'custom', 'advanced'] const modes = ['quick', 'custom', 'advanced'];
// 快速模式 // 快速模式
const selectedQuick = ref('20:00') const selectedQuick = ref('20:00');
// 自定义模式 // 自定义模式
const customTime = ref('20:00') const customTime = ref('20:00');
const customTimeValue = ref(dayjs('20:00', 'HH:mm')) const customTimeValue = ref(dayjs('20:00', 'HH:mm'));
const customFrequency = ref('daily') const customFrequency = ref('daily');
// 高级模式 // 高级模式
const advancedExpression = ref(props.modelValue || '0 20 * * *') const advancedExpression = ref(props.modelValue || '0 20 * * *');
const validationMessage = ref('') const validationMessage = ref('');
const validationStatus = ref('') const validationStatus = ref('');
// 通用 // 通用
const nextExecutions = ref([]) const nextExecutions = ref([]);
// 标志:是否正在手动编辑高级模式(防止自动解析导致模式切换) // 标志:是否正在手动编辑高级模式(防止自动解析导致模式切换)
let isManualEditing = false let isManualEditing = false;
// 切换模式 - 防止页面刷新 // 切换模式 - 防止页面刷新
function switchMode(newMode) { function switchMode(newMode) {
mode.value = newMode mode.value = newMode;
// 切换到快速模式时,自动选择默认值并触发保存 // 切换到快速模式时,自动选择默认值并触发保存
if (newMode === 'quick') { if (newMode === 'quick') {
selectedQuick.value = '20:00' selectedQuick.value = '20:00';
const cron = buildCrontabFromQuick() const cron = buildCrontabFromQuick();
advancedExpression.value = cron advancedExpression.value = cron;
emit('update:modelValue', cron) emit('update:modelValue', cron);
if (cron) validateAndPreview(cron) if (cron) validateAndPreview(cron);
} }
// 切换到自定义模式时,基于当前值构建 cron // 切换到自定义模式时,基于当前值构建 cron
else if (newMode === 'custom') { else if (newMode === 'custom') {
const cron = buildCrontabFromCustom() const cron = buildCrontabFromCustom();
advancedExpression.value = cron advancedExpression.value = cron;
emit('update:modelValue', cron) emit('update:modelValue', cron);
if (cron) validateAndPreview(cron) if (cron) validateAndPreview(cron);
} }
// 切换到高级模式时,使用当前的 advancedExpression // 切换到高级模式时,使用当前的 advancedExpression
else if (newMode === 'advanced') { else if (newMode === 'advanced') {
if (advancedExpression.value) { if (advancedExpression.value) {
emit('update:modelValue', advancedExpression.value) emit('update:modelValue', advancedExpression.value);
validateAndPreview(advancedExpression.value) validateAndPreview(advancedExpression.value);
} }
} }
} }
@@ -154,174 +153,185 @@ function switchMode(newMode) {
// 处理时间选择器变化 // 处理时间选择器变化
function onCustomTimeChange(time) { function onCustomTimeChange(time) {
if (time) { if (time) {
customTime.value = time.format('HH:mm') customTime.value = time.format('HH:mm');
} }
} }
// 监听 - 只在有效值时更新 // 监听 - 只在有效值时更新
watch(selectedQuick, () => { watch(selectedQuick, () => {
const cron = buildCrontabFromQuick() const cron = buildCrontabFromQuick();
advancedExpression.value = cron advancedExpression.value = cron;
emit('update:modelValue', cron) emit('update:modelValue', cron);
if (cron) validateAndPreview(cron) if (cron) validateAndPreview(cron);
}) });
watch(customFrequency, () => { watch(customFrequency, () => {
const cron = buildCrontabFromCustom() const cron = buildCrontabFromCustom();
advancedExpression.value = cron advancedExpression.value = cron;
emit('update:modelValue', cron) emit('update:modelValue', cron);
if (cron) validateAndPreview(cron) if (cron) validateAndPreview(cron);
}) });
watch(customTime, () => { watch(customTime, () => {
const cron = buildCrontabFromCustom() const cron = buildCrontabFromCustom();
advancedExpression.value = cron advancedExpression.value = cron;
emit('update:modelValue', cron) emit('update:modelValue', cron);
if (cron) validateAndPreview(cron) if (cron) validateAndPreview(cron);
}) });
// 工具函数 // 工具函数
function buildCrontabFromQuick() { function buildCrontabFromQuick() {
if (selectedQuick.value === '20:00') { if (selectedQuick.value === '20:00') {
return '0 20 * * *' // 每天 20:00 return '0 20 * * *'; // 每天 20:00
} }
return null return null;
} }
function buildCrontabFromCustom() { function buildCrontabFromCustom() {
const [hour, minute] = customTime.value.split(':') const [hour, minute] = customTime.value.split(':');
let dow = '*' // 星期 let dow = '*'; // 星期
if (customFrequency.value === 'weekday') { if (customFrequency.value === 'weekday') {
dow = '1-5' // 周一至周五 dow = '1-5'; // 周一至周五
} else if (customFrequency.value === 'weekend') { } else if (customFrequency.value === 'weekend') {
dow = '0,6' // 周六和周日 dow = '0,6'; // 周六和周日
} }
return `${minute} ${hour} * * ${dow}` return `${minute} ${hour} * * ${dow}`;
} }
// 处理高级模式输入 - 使用防抖以避免频繁调用API // 处理高级模式输入 - 使用防抖以避免频繁调用API
let debounceTimer = null let debounceTimer = null;
function handleAdvancedInput() { function handleAdvancedInput() {
// 设置手动编辑标志 // 设置手动编辑标志
isManualEditing = true isManualEditing = true;
// 立即触发 emit,保证值实时同步 // 立即触发 emit,保证值实时同步
emit('update:modelValue', advancedExpression.value) emit('update:modelValue', advancedExpression.value);
// 使用防抖延迟验证 // 使用防抖延迟验证
if (debounceTimer) { if (debounceTimer) {
clearTimeout(debounceTimer) clearTimeout(debounceTimer);
} }
debounceTimer = setTimeout(async () => { debounceTimer = setTimeout(async () => {
if (!advancedExpression.value.trim()) { if (!advancedExpression.value.trim()) {
validationMessage.value = '' validationMessage.value = '';
nextExecutions.value = [] nextExecutions.value = [];
return return;
} }
await validateAndPreview(advancedExpression.value) await validateAndPreview(advancedExpression.value);
}, 500) // 500ms 防抖延迟 }, 500); // 500ms 防抖延迟
} }
async function validateAndPreview(expr) { async function validateAndPreview(expr) {
if (!expr) { if (!expr) {
validationMessage.value = '' validationMessage.value = '';
nextExecutions.value = [] nextExecutions.value = [];
return return;
} }
try { try {
const response = await client.post('/api/tasks/validate-cron', { const response = await client.post('/api/tasks/validate-cron', {
cron_expression: expr cron_expression: expr,
}) });
if (response.valid) { if (response.valid) {
validationStatus.value = 'success' validationStatus.value = 'success';
validationMessage.value = `有效: ${response.description}` validationMessage.value = `有效: ${response.description}`;
nextExecutions.value = response.next_times nextExecutions.value = response.next_times;
} }
} catch (error) { } catch (error) {
validationStatus.value = 'error' validationStatus.value = 'error';
validationMessage.value = error.message || '无效的 crontab 表达式' validationMessage.value = error.message || '无效的 crontab 表达式';
nextExecutions.value = [] nextExecutions.value = [];
} }
} }
// 解析 cron 表达式并设置对应的模式 // 解析 cron 表达式并设置对应的模式
function parseCronExpression(cron) { function parseCronExpression(cron) {
if (!cron) return if (!cron) return;
advancedExpression.value = cron advancedExpression.value = cron;
// 尝试匹配快速模式: 0 20 * * * // 尝试匹配快速模式: 0 20 * * *
if (cron === '0 20 * * *') { if (cron === '0 20 * * *') {
mode.value = 'quick' mode.value = 'quick';
selectedQuick.value = '20:00' selectedQuick.value = '20:00';
validateAndPreview(cron) validateAndPreview(cron);
return return;
} }
// 尝试解析为自定义模式 // 尝试解析为自定义模式
const parts = cron.trim().split(/\s+/) const parts = cron.trim().split(/\s+/);
if (parts.length === 5) { if (parts.length === 5) {
const [minute, hour, day, month, dow] = parts const [minute, hour, day, month, dow] = parts;
// 检查是否是简单的每天或工作日/周末模式 // 检查是否是简单的每天或工作日/周末模式
if (day === '*' && month === '*') { if (day === '*' && month === '*') {
const hourNum = parseInt(hour) const hourNum = parseInt(hour);
const minuteNum = parseInt(minute) const minuteNum = parseInt(minute);
if (!isNaN(hourNum) && !isNaN(minuteNum) && hourNum >= 0 && hourNum < 24 && minuteNum >= 0 && minuteNum < 60) { if (
mode.value = 'custom' !isNaN(hourNum) &&
customTime.value = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}` !isNaN(minuteNum) &&
customTimeValue.value = dayjs(customTime.value, 'HH:mm') 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 === '*') { if (dow === '*') {
customFrequency.value = 'daily' customFrequency.value = 'daily';
} else if (dow === '1-5') { } else if (dow === '1-5') {
customFrequency.value = 'weekday' customFrequency.value = 'weekday';
} else if (dow === '0,6' || dow === '6,0') { } else if (dow === '0,6' || dow === '6,0') {
customFrequency.value = 'weekend' customFrequency.value = 'weekend';
} else { } else {
// 不支持的星期模式,使用高级模式 // 不支持的星期模式,使用高级模式
mode.value = 'advanced' mode.value = 'advanced';
} }
validateAndPreview(cron) validateAndPreview(cron);
return return;
} }
} }
} }
// 其他情况使用高级模式 // 其他情况使用高级模式
mode.value = 'advanced' mode.value = 'advanced';
validateAndPreview(cron) validateAndPreview(cron);
} }
// 初始化 - 解析传入的 cron 表达式 // 初始化 - 解析传入的 cron 表达式
watch(() => props.modelValue, (newVal) => { watch(
() => props.modelValue,
newVal => {
// 如果正在手动编辑高级模式,跳过自动解析 // 如果正在手动编辑高级模式,跳过自动解析
if (isManualEditing) { if (isManualEditing) {
isManualEditing = false // 重置标志 isManualEditing = false; // 重置标志
return return;
} }
if (newVal) { if (newVal) {
parseCronExpression(newVal) parseCronExpression(newVal);
} }
}, { immediate: true }) },
{ immediate: true }
);
// 组件卸载时清理防抖定时器,防止内存泄漏 // 组件卸载时清理防抖定时器,防止内存泄漏
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (debounceTimer) { if (debounceTimer) {
clearTimeout(debounceTimer) clearTimeout(debounceTimer);
debounceTimer = null debounceTimer = null;
} }
}) });
</script> </script>
<style scoped> <style scoped>
@@ -391,7 +401,8 @@ onBeforeUnmount(() => {
.quick-option:hover { .quick-option:hover {
border-color: var(--md-sys-color-outline); border-color: var(--md-sys-color-outline);
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.3), box-shadow:
0px 1px 2px 0px rgba(0, 0, 0, 0.3),
0px 1px 3px 1px rgba(0, 0, 0, 0.15); 0px 1px 3px 1px rgba(0, 0, 0, 0.15);
} }
+65 -73
View File
@@ -5,9 +5,9 @@
<a-form-item label="显示名称" class="mb-0"> <a-form-item label="显示名称" class="mb-0">
<a-input <a-input
:value="modelValue.display_name" :value="modelValue.display_name"
@change="e => updateField('display_name', e.target.value)"
placeholder="在表单中显示的名称" placeholder="在表单中显示的名称"
allow-clear allow-clear
@change="e => updateField('display_name', e.target.value)"
/> />
<span class="text-xs text-on-surface-variant mt-1">显示名称</span> <span class="text-xs text-on-surface-variant mt-1">显示名称</span>
</a-form-item> </a-form-item>
@@ -15,9 +15,9 @@
<a-form-item label="字段类型" class="mb-0"> <a-form-item label="字段类型" class="mb-0">
<a-select <a-select
:value="modelValue.field_type" :value="modelValue.field_type"
@change="handleFieldTypeChange"
placeholder="选择输入控件类型" placeholder="选择输入控件类型"
class="w-full" class="w-full"
@change="handleFieldTypeChange"
> >
<a-select-option label="📝 单行文本" value="text" /> <a-select-option label="📝 单行文本" value="text" />
<a-select-option label="📄 多行文本" value="textarea" /> <a-select-option label="📄 多行文本" value="textarea" />
@@ -33,9 +33,9 @@
<a-form-item label="值类型" class="mb-0"> <a-form-item label="值类型" class="mb-0">
<a-select <a-select
:value="modelValue.value_type" :value="modelValue.value_type"
@change="value => updateField('value_type', value)"
placeholder="选择数据类型" placeholder="选择数据类型"
class="w-full" class="w-full"
@change="value => updateField('value_type', value)"
> >
<a-select-option label="字符串 (string)" value="string"> <a-select-option label="字符串 (string)" value="string">
<span class="text-xs text-on-surface-variant">字符串 (string)</span> <span class="text-xs text-on-surface-variant">字符串 (string)</span>
@@ -60,26 +60,24 @@
<a-input <a-input
v-if="modelValue.value_type !== 'json'" v-if="modelValue.value_type !== 'json'"
:value="modelValue.default_value" :value="modelValue.default_value"
@change="e => updateField('default_value', e.target.value)"
placeholder="字段的默认值" placeholder="字段的默认值"
allow-clear allow-clear
@change="e => updateField('default_value', e.target.value)"
/> />
<a-textarea <a-textarea
v-else v-else
:value="modelValue.default_value" :value="modelValue.default_value"
@change="e => updateField('default_value', e.target.value)"
placeholder="字段的默认值" placeholder="字段的默认值"
:rows="3" :rows="3"
allow-clear allow-clear
@change="e => updateField('default_value', e.target.value)"
/> />
<span class="text-xs text-on-surface-variant mt-1"> <span class="text-xs text-on-surface-variant mt-1">
<template v-if="modelValue.value_type === 'json'"> <template v-if="modelValue.value_type === 'json'">
<p>输入JSON对象,会自动序列化为字符串</p> <p>输入JSON对象,会自动序列化为字符串</p>
<p>:{"key1":value1,"key2":value2}</p> <p>:{"key1":value1,"key2":value2}</p>
</template> </template>
<template v-else> <template v-else> 用户未填写时使用此值 </template>
用户未填写时使用此值
</template>
</span> </span>
</a-form-item> </a-form-item>
</div> </div>
@@ -88,15 +86,17 @@
<a-form-item label="占位符提示" class="mb-0"> <a-form-item label="占位符提示" class="mb-0">
<a-input <a-input
:value="modelValue.placeholder" :value="modelValue.placeholder"
@change="e => updateField('placeholder', e.target.value)"
placeholder="输入框的灰色提示文本" placeholder="输入框的灰色提示文本"
allow-clear allow-clear
@change="e => updateField('placeholder', e.target.value)"
/> />
<span class="text-xs text-on-surface-variant mt-1">占位符</span> <span class="text-xs text-on-surface-variant mt-1">占位符</span>
</a-form-item> </a-form-item>
<!-- Row 4: Switches --> <!-- Row 4: Switches -->
<div class="grid grid-cols-2 gap-4 p-3 bg-surface-container-low rounded-md3 border border-outline-variant"> <div
class="grid grid-cols-2 gap-4 p-3 bg-surface-container-low rounded-md3 border border-outline-variant"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<label class="text-sm font-medium text-on-surface">是否必填</label> <label class="text-sm font-medium text-on-surface">是否必填</label>
@@ -104,8 +104,8 @@
</div> </div>
<a-switch <a-switch
:checked="modelValue.required" :checked="modelValue.required"
@change="handleRequiredChange"
:disabled="modelValue.hidden" :disabled="modelValue.hidden"
@change="handleRequiredChange"
/> />
</div> </div>
@@ -114,20 +114,11 @@
<label class="text-sm font-medium text-on-surface">是否隐藏</label> <label class="text-sm font-medium text-on-surface">是否隐藏</label>
<p class="text-xs text-on-surface-variant">直接使用默认值不在表单中显示</p> <p class="text-xs text-on-surface-variant">直接使用默认值不在表单中显示</p>
</div> </div>
<a-switch <a-switch :checked="modelValue.hidden" @change="handleHiddenChange" />
:checked="modelValue.hidden"
@change="handleHiddenChange"
/>
</div> </div>
</div> </div>
<a-alert <a-alert v-if="modelValue.hidden" message="💡 提示" type="info" :closable="false" class="mt-3">
v-if="modelValue.hidden"
message="💡 提示"
type="info"
:closable="false"
class="mt-3"
>
<template #description> <template #description>
<p class="text-xs"> <p class="text-xs">
隐藏字段将自动使用默认值不会在创建任务表单中显示请确保设置了合适的默认值 隐藏字段将自动使用默认值不会在创建任务表单中显示请确保设置了合适的默认值
@@ -147,30 +138,31 @@
<span class="text-xs text-on-surface-variant w-8">{{ index + 1 }}.</span> <span class="text-xs text-on-surface-variant w-8">{{ index + 1 }}.</span>
<a-input <a-input
:value="option.label" :value="option.label"
@change="e => updateOption(index, 'label', e.target.value)"
placeholder="显示文本(如:健康)" placeholder="显示文本(如:健康)"
size="small" size="small"
class="flex-1" class="flex-1"
@change="e => updateOption(index, 'label', e.target.value)"
/> />
<a-input <a-input
:value="option.value" :value="option.value"
@change="e => updateOption(index, 'value', e.target.value)"
placeholder="选项值(如:healthy" placeholder="选项值(如:healthy"
size="small" size="small"
class="flex-1" class="flex-1"
@change="e => updateOption(index, 'value', e.target.value)"
/> />
<a-button <a-button size="small" danger @click="removeOption(index)">
size="small"
danger
@click="removeOption(index)"
>
<template #icon><DeleteOutlined /></template> <template #icon><DeleteOutlined /></template>
</a-button> </a-button>
</div> </div>
<a-button size="small" type="primary" @click="addOption" class="w-full"> <a-button size="small" type="primary" class="w-full" @click="addOption">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg> </svg>
添加选项 添加选项
</a-button> </a-button>
@@ -185,99 +177,99 @@
</template> </template>
<script setup> <script setup>
import { defineProps, defineEmits } from 'vue' import { defineProps, defineEmits } from 'vue';
import { DeleteOutlined } from '@ant-design/icons-vue' import { DeleteOutlined } from '@ant-design/icons-vue';
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Object, type: Object,
required: true required: true,
}, },
fieldKey: { fieldKey: {
type: String, type: String,
default: '' default: '',
} },
}) });
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue']);
// Update single field // Update single field
const updateField = (field, value) => { const updateField = (field, value) => {
emit('update:modelValue', { emit('update:modelValue', {
...props.modelValue, ...props.modelValue,
[field]: value [field]: value,
}) });
} };
// Handle required change // Handle required change
const handleRequiredChange = (value) => { const handleRequiredChange = value => {
updateField('required', value) updateField('required', value);
} };
// Handle hidden change - 当隐藏时,自动设置 required 为 false // Handle hidden change - 当隐藏时,自动设置 required 为 false
const handleHiddenChange = (value) => { const handleHiddenChange = value => {
const updated = { const updated = {
...props.modelValue, ...props.modelValue,
hidden: value hidden: value,
} };
// 如果设置为隐藏,则取消必填 // 如果设置为隐藏,则取消必填
if (value) { if (value) {
updated.required = false updated.required = false;
} }
emit('update:modelValue', updated) emit('update:modelValue', updated);
} };
// Handle field type change // Handle field type change
const handleFieldTypeChange = (newType) => { const handleFieldTypeChange = newType => {
const updated = { const updated = {
...props.modelValue, ...props.modelValue,
field_type: newType field_type: newType,
} };
if (newType === 'select' && !updated.options) { if (newType === 'select' && !updated.options) {
updated.options = [] updated.options = [];
} }
emit('update:modelValue', updated) emit('update:modelValue', updated);
} };
// Add option // Add option
const addOption = () => { const addOption = () => {
const options = [...(props.modelValue.options || [])] const options = [...(props.modelValue.options || [])];
options.push({ label: '', value: '' }) options.push({ label: '', value: '' });
emit('update:modelValue', { emit('update:modelValue', {
...props.modelValue, ...props.modelValue,
options options,
}) });
} };
// Update option // Update option
const updateOption = (index, field, value) => { const updateOption = (index, field, value) => {
const options = [...(props.modelValue.options || [])] const options = [...(props.modelValue.options || [])];
options[index] = { options[index] = {
...options[index], ...options[index],
[field]: value [field]: value,
} };
emit('update:modelValue', { emit('update:modelValue', {
...props.modelValue, ...props.modelValue,
options options,
}) });
} };
// Remove option // Remove option
const removeOption = (index) => { const removeOption = index => {
const options = [...(props.modelValue.options || [])] const options = [...(props.modelValue.options || [])];
options.splice(index, 1) options.splice(index, 1);
emit('update:modelValue', { emit('update:modelValue', {
...props.modelValue, ...props.modelValue,
options options,
}) });
} };
</script> </script>
<style scoped> <style scoped>
+264 -131
View File
@@ -1,13 +1,15 @@
<template> <template>
<div class="field-tree-node border border-outline-variant rounded-md3 p-4 bg-surface shadow-md3-1 hover:shadow-md3-2 transition-shadow"> <div
class="field-tree-node border border-outline-variant rounded-md3 p-4 bg-surface shadow-md3-1 hover:shadow-md3-2 transition-shadow"
>
<!-- 普通字段 --> <!-- 普通字段 -->
<div v-if="isFieldConfig" class="field-config"> <div v-if="isFieldConfig" class="field-config">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-outline-variant"> <div class="flex items-center justify-between mb-3 pb-2 border-b border-outline-variant">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <button
type="button" type="button"
@click="isCollapsed = !isCollapsed"
class="hover:bg-surface-container rounded-md3 p-1 transition-colors" class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
@click="isCollapsed = !isCollapsed"
> >
<svg <svg
class="w-4 h-4 text-on-surface-variant transition-transform" class="w-4 h-4 text-on-surface-variant transition-transform"
@@ -16,29 +18,54 @@
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</button> </button>
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" /> <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> </svg>
<span class="font-mono text-base font-bold text-primary">{{ fieldKey }}</span> <span class="font-mono text-base font-bold text-primary">{{ fieldKey }}</span>
<a-tag type="primary" size="small">普通字段</a-tag> <a-tag type="primary" size="small">普通字段</a-tag>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a-button size="small" @click="handleMove('up')" title="上移"> <a-button size="small" title="上移" @click="handleMove('up')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg> </svg>
</a-button> </a-button>
<a-button size="small" @click="handleMove('down')" title="下移"> <a-button size="small" title="下移" @click="handleMove('down')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</a-button> </a-button>
<a-button size="small" type="danger" plain @click="handleDelete"> <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"> <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" /> <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> </svg>
删除 删除
</a-button> </a-button>
@@ -56,8 +83,8 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <button
type="button" type="button"
@click="isCollapsed = !isCollapsed"
class="hover:bg-surface-container rounded-md3 p-1 transition-colors" class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
@click="isCollapsed = !isCollapsed"
> >
<svg <svg
class="w-4 h-4 text-on-surface-variant transition-transform" class="w-4 h-4 text-on-surface-variant transition-transform"
@@ -66,35 +93,65 @@
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</button> </button>
<svg class="w-5 h-5 text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg> </svg>
<span class="font-mono text-base font-bold text-secondary">{{ fieldKey }}</span> <span class="font-mono text-base font-bold text-secondary">{{ fieldKey }}</span>
<a-tag type="warning" size="small">数组字段</a-tag> <a-tag type="warning" size="small">数组字段</a-tag>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a-button size="small" @click="handleMove('up')" title="上移"> <a-button size="small" title="上移" @click="handleMove('up')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg> </svg>
</a-button> </a-button>
<a-button size="small" @click="handleMove('down')" title="下移"> <a-button size="small" title="下移" @click="handleMove('down')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</a-button> </a-button>
<a-button size="small" type="primary" @click="addArrayItem"> <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"> <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" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg> </svg>
添加元素 添加元素
</a-button> </a-button>
<a-button size="small" type="danger" plain @click="handleDelete"> <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"> <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" /> <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> </svg>
删除 删除
</a-button> </a-button>
@@ -102,7 +159,10 @@
</div> </div>
<div v-show="!isCollapsed"> <div v-show="!isCollapsed">
<div v-if="localFieldConfig.length === 0" class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline"> <div
v-if="localFieldConfig.length === 0"
class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline"
>
<p class="text-sm text-on-surface-variant mb-2">数组为空</p> <p class="text-sm text-on-surface-variant mb-2">数组为空</p>
<a-button size="small" type="primary" @click="addArrayItem">添加第一个元素</a-button> <a-button size="small" type="primary" @click="addArrayItem">添加第一个元素</a-button>
</div> </div>
@@ -121,8 +181,15 @@
</div> </div>
<!-- 如果数组元素是字段配置对象直接渲染为字段编辑器 --> <!-- 如果数组元素是字段配置对象直接渲染为字段编辑器 -->
<div v-if="typeof item === 'object' && !Array.isArray(item) && 'display_name' in item" class="bg-surface rounded-md3 p-3"> <div
<FieldConfigEditor :model-value="item" @update:model-value="updateArrayItemField(index, $event)" :field-key="`元素${index + 1}`" /> v-if="typeof item === 'object' && !Array.isArray(item) && 'display_name' in item"
class="bg-surface rounded-md3 p-3"
>
<FieldConfigEditor
:model-value="item"
:field-key="`元素${index + 1}`"
@update:model-value="updateArrayItemField(index, $event)"
/>
</div> </div>
<!-- 如果数组元素是对象但不是字段配置递归渲染其中的字段 --> <!-- 如果数组元素是对象但不是字段配置递归渲染其中的字段 -->
@@ -138,9 +205,20 @@
@move="$emit('move', $event)" @move="$emit('move', $event)"
/> />
<a-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"> <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" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg> </svg>
添加字段 添加字段
</a-button> </a-button>
@@ -168,8 +246,8 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <button
type="button" type="button"
@click="isCollapsed = !isCollapsed"
class="hover:bg-surface-container rounded-md3 p-1 transition-colors" class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
@click="isCollapsed = !isCollapsed"
> >
<svg <svg
class="w-4 h-4 text-on-surface-variant transition-transform" class="w-4 h-4 text-on-surface-variant transition-transform"
@@ -178,35 +256,65 @@
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</button> </button>
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> <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> </svg>
<span class="font-mono text-base font-bold text-accent">{{ fieldKey }}</span> <span class="font-mono text-base font-bold text-accent">{{ fieldKey }}</span>
<a-tag type="success" size="small">对象字段</a-tag> <a-tag type="success" size="small">对象字段</a-tag>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a-button size="small" @click="handleMove('up')" title="上移"> <a-button size="small" title="上移" @click="handleMove('up')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg> </svg>
</a-button> </a-button>
<a-button size="small" @click="handleMove('down')" title="下移"> <a-button size="small" title="下移" @click="handleMove('down')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</a-button> </a-button>
<a-button size="small" type="primary" @click="addFieldToObject"> <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"> <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" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg> </svg>
添加子字段 添加子字段
</a-button> </a-button>
<a-button size="small" type="danger" plain @click="handleDelete"> <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"> <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" /> <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> </svg>
删除 删除
</a-button> </a-button>
@@ -214,9 +322,14 @@
</div> </div>
<div v-show="!isCollapsed"> <div v-show="!isCollapsed">
<div v-if="Object.keys(localFieldConfig).length === 0" class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline"> <div
v-if="Object.keys(localFieldConfig).length === 0"
class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline"
>
<p class="text-sm text-on-surface-variant mb-2">对象为空</p> <p class="text-sm text-on-surface-variant mb-2">对象为空</p>
<a-button size="small" type="primary" @click="addFieldToObject">添加第一个子字段</a-button> <a-button size="small" type="primary" @click="addFieldToObject"
>添加第一个子字段</a-button
>
</div> </div>
<div v-else class="space-y-3 mt-3 pl-4 border-l-4 border-accent"> <div v-else class="space-y-3 mt-3 pl-4 border-l-4 border-accent">
@@ -236,12 +349,20 @@
</div> </div>
<!-- 添加字段对话框 --> <!-- 添加字段对话框 -->
<a-modal v-model:open="addFieldDialogVisible" :title="currentArrayIndex === -1 ? '添加数组元素' : '添加字段'" width="400px"> <a-modal
v-model:open="addFieldDialogVisible"
:title="currentArrayIndex === -1 ? '添加数组元素' : '添加字段'"
width="400px"
>
<a-form> <a-form>
<a-form-item :label="currentArrayIndex === -1 ? '字段名(可选)' : '字段名'"> <a-form-item :label="currentArrayIndex === -1 ? '字段名(可选)' : '字段名'">
<a-input <a-input
v-model:value="newFieldName" v-model:value="newFieldName"
:placeholder="currentArrayIndex === -1 ? '留空则作为数组元素,填写则作为对象字段' : '例如: FieldId, Values, Texts'" :placeholder="
currentArrayIndex === -1
? '留空则作为数组元素,填写则作为对象字段'
: '例如: FieldId, Values, Texts'
"
/> />
</a-form-item> </a-form-item>
<a-form-item label="元素类型"> <a-form-item label="元素类型">
@@ -262,119 +383,131 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, nextTick } from 'vue' import { ref, computed, watch, nextTick } from 'vue';
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue';
import FieldConfigEditor from './FieldConfigEditor.vue' import FieldConfigEditor from './FieldConfigEditor.vue';
const props = defineProps({ const props = defineProps({
fieldKey: { fieldKey: {
type: String, type: String,
required: true required: true,
}, },
fieldConfig: { fieldConfig: {
type: [Object, Array], type: [Object, Array],
required: true required: true,
}, },
path: { path: {
type: Array, type: Array,
required: true required: true,
} },
}) });
const emit = defineEmits(['update', 'delete', 'move']) const emit = defineEmits(['update', 'delete', 'move']);
const localFieldConfig = ref(JSON.parse(JSON.stringify(props.fieldConfig))) const localFieldConfig = ref(JSON.parse(JSON.stringify(props.fieldConfig)));
const addFieldDialogVisible = ref(false) const addFieldDialogVisible = ref(false);
const newFieldName = ref('') const newFieldName = ref('');
const newFieldType = ref('field') const newFieldType = ref('field');
const currentArrayIndex = ref(null) const currentArrayIndex = ref(null);
const isAddingToObject = ref(false) const isAddingToObject = ref(false);
const isCollapsed = ref(false) const isCollapsed = ref(false);
// 标志位,防止循环更新 // 标志位,防止循环更新
let isUpdatingFromProps = false let isUpdatingFromProps = false;
// 监听 props.fieldConfig 的变化,同步更新 localFieldConfig // 监听 props.fieldConfig 的变化,同步更新 localFieldConfig
watch(() => props.fieldConfig, (newVal) => { watch(
isUpdatingFromProps = true () => props.fieldConfig,
localFieldConfig.value = JSON.parse(JSON.stringify(newVal)) newVal => {
isUpdatingFromProps = true;
localFieldConfig.value = JSON.parse(JSON.stringify(newVal));
// 使用 nextTick 确保在下一个 tick 后重置标志 // 使用 nextTick 确保在下一个 tick 后重置标志
nextTick(() => { nextTick(() => {
isUpdatingFromProps = false isUpdatingFromProps = false;
}) });
}, { deep: true }) },
{ deep: true }
);
// 判断字段类型 // 判断字段类型
const isFieldConfig = computed(() => { const isFieldConfig = computed(() => {
return typeof props.fieldConfig === 'object' && return (
typeof props.fieldConfig === 'object' &&
!Array.isArray(props.fieldConfig) && !Array.isArray(props.fieldConfig) &&
'display_name' in props.fieldConfig 'display_name' in props.fieldConfig
}) );
});
const isArray = computed(() => { const isArray = computed(() => {
return Array.isArray(props.fieldConfig) return Array.isArray(props.fieldConfig);
}) });
const isObject = computed(() => { const isObject = computed(() => {
return typeof props.fieldConfig === 'object' && return (
typeof props.fieldConfig === 'object' &&
!Array.isArray(props.fieldConfig) && !Array.isArray(props.fieldConfig) &&
!('display_name' in props.fieldConfig) !('display_name' in props.fieldConfig)
}) );
});
// 监听本地配置变化 - 只在非 props 更新时触发 // 监听本地配置变化 - 只在非 props 更新时触发
watch(localFieldConfig, (newVal) => { watch(
localFieldConfig,
newVal => {
if (!isUpdatingFromProps) { if (!isUpdatingFromProps) {
emit('update', { path: props.path, value: newVal }) emit('update', { path: props.path, value: newVal });
} }
}, { deep: true }) },
{ deep: true }
);
// 删除字段 // 删除字段
const handleDelete = () => { const handleDelete = () => {
emit('delete', props.path) emit('delete', props.path);
} };
// 移动字段 // 移动字段
const handleMove = (direction) => { const handleMove = direction => {
emit('move', { path: props.path, direction }) emit('move', { path: props.path, direction });
} };
// 添加数组元素 // 添加数组元素
const addArrayItem = () => { const addArrayItem = () => {
// 弹出对话框让用户选择添加元素类型 // 弹出对话框让用户选择添加元素类型
currentArrayIndex.value = -1 // 标记为添加数组元素 currentArrayIndex.value = -1; // 标记为添加数组元素
isAddingToObject.value = false isAddingToObject.value = false;
newFieldName.value = '' // 数组元素不需要字段名,但复用对话框 newFieldName.value = ''; // 数组元素不需要字段名,但复用对话框
newFieldType.value = 'field' newFieldType.value = 'field';
addFieldDialogVisible.value = true addFieldDialogVisible.value = true;
} };
// 删除数组元素 // 删除数组元素
const removeArrayItem = (index) => { const removeArrayItem = index => {
localFieldConfig.value.splice(index, 1) localFieldConfig.value.splice(index, 1);
} };
// 更新数组元素的字段配置 // 更新数组元素的字段配置
const updateArrayItemField = (index, newValue) => { const updateArrayItemField = (index, newValue) => {
localFieldConfig.value[index] = newValue localFieldConfig.value[index] = newValue;
} };
// 为数组元素添加字段 // 为数组元素添加字段
const addFieldToArrayItem = (index) => { const addFieldToArrayItem = index => {
currentArrayIndex.value = index currentArrayIndex.value = index;
isAddingToObject.value = false isAddingToObject.value = false;
newFieldName.value = '' newFieldName.value = '';
newFieldType.value = 'field' newFieldType.value = 'field';
addFieldDialogVisible.value = true addFieldDialogVisible.value = true;
} };
// 为对象添加字段 // 为对象添加字段
const addFieldToObject = () => { const addFieldToObject = () => {
currentArrayIndex.value = null currentArrayIndex.value = null;
isAddingToObject.value = true isAddingToObject.value = true;
newFieldName.value = '' newFieldName.value = '';
newFieldType.value = 'field' newFieldType.value = 'field';
addFieldDialogVisible.value = true addFieldDialogVisible.value = true;
} };
// 确认添加字段 // 确认添加字段
const confirmAddField = () => { const confirmAddField = () => {
@@ -391,20 +524,20 @@ const confirmAddField = () => {
required: false, required: false,
hidden: false, hidden: false,
value_type: 'string', value_type: 'string',
options: [] options: [],
}) });
} else if (newFieldType.value === 'array') { } else if (newFieldType.value === 'array') {
localFieldConfig.value.push([]) localFieldConfig.value.push([]);
} else if (newFieldType.value === 'object') { } else if (newFieldType.value === 'object') {
localFieldConfig.value.push({}) localFieldConfig.value.push({});
} }
addFieldDialogVisible.value = false addFieldDialogVisible.value = false;
message.success('数组元素添加成功') message.success('数组元素添加成功');
return return;
} else { } else {
// 字段名不为空,添加为包含命名字段的对象 // 字段名不为空,添加为包含命名字段的对象
const newObject = {} const newObject = {};
if (newFieldType.value === 'field') { if (newFieldType.value === 'field') {
newObject[newFieldName.value] = { newObject[newFieldName.value] = {
display_name: '', display_name: '',
@@ -413,32 +546,32 @@ const confirmAddField = () => {
required: false, required: false,
hidden: false, hidden: false,
value_type: 'string', value_type: 'string',
options: [] options: [],
} };
} else if (newFieldType.value === 'array') { } else if (newFieldType.value === 'array') {
newObject[newFieldName.value] = [] newObject[newFieldName.value] = [];
} else if (newFieldType.value === 'object') { } else if (newFieldType.value === 'object') {
newObject[newFieldName.value] = {} newObject[newFieldName.value] = {};
} }
localFieldConfig.value.push(newObject) localFieldConfig.value.push(newObject);
addFieldDialogVisible.value = false addFieldDialogVisible.value = false;
message.success('带命名字段的对象添加成功') message.success('带命名字段的对象添加成功');
return return;
} }
} }
// 其他情况需要字段名 // 其他情况需要字段名
if (!newFieldName.value) { if (!newFieldName.value) {
message.warning('请输入字段名') message.warning('请输入字段名');
return return;
} }
if (isAddingToObject.value) { if (isAddingToObject.value) {
// 添加到对象字段 // 添加到对象字段
if (localFieldConfig.value[newFieldName.value]) { if (localFieldConfig.value[newFieldName.value]) {
message.warning('该字段已存在') message.warning('该字段已存在');
return return;
} }
if (newFieldType.value === 'field') { if (newFieldType.value === 'field') {
@@ -449,19 +582,19 @@ const confirmAddField = () => {
required: false, required: false,
hidden: false, hidden: false,
value_type: 'string', value_type: 'string',
options: [] options: [],
} };
} else if (newFieldType.value === 'array') { } else if (newFieldType.value === 'array') {
localFieldConfig.value[newFieldName.value] = [] localFieldConfig.value[newFieldName.value] = [];
} else if (newFieldType.value === 'object') { } else if (newFieldType.value === 'object') {
localFieldConfig.value[newFieldName.value] = {} localFieldConfig.value[newFieldName.value] = {};
} }
} else if (currentArrayIndex.value !== null) { } else if (currentArrayIndex.value !== null) {
// 添加到数组元素 // 添加到数组元素
const arrayItem = localFieldConfig.value[currentArrayIndex.value] const arrayItem = localFieldConfig.value[currentArrayIndex.value];
if (arrayItem[newFieldName.value]) { if (arrayItem[newFieldName.value]) {
message.warning('该字段已存在') message.warning('该字段已存在');
return return;
} }
if (newFieldType.value === 'field') { if (newFieldType.value === 'field') {
@@ -472,18 +605,18 @@ const confirmAddField = () => {
required: false, required: false,
hidden: false, hidden: false,
value_type: 'string', value_type: 'string',
options: [] options: [],
} };
} else if (newFieldType.value === 'array') { } else if (newFieldType.value === 'array') {
arrayItem[newFieldName.value] = [] arrayItem[newFieldName.value] = [];
} else if (newFieldType.value === 'object') { } else if (newFieldType.value === 'object') {
arrayItem[newFieldName.value] = {} arrayItem[newFieldName.value] = {};
} }
} }
addFieldDialogVisible.value = false addFieldDialogVisible.value = false;
message.success('字段添加成功') message.success('字段添加成功');
} };
</script> </script>
<style scoped> <style scoped>
+11 -7
View File
@@ -8,16 +8,16 @@
</template> </template>
<script setup> <script setup>
import { onMounted } from 'vue' import { onMounted } from 'vue';
import Navbar from './Navbar.vue' import Navbar from './Navbar.vue';
import { useTokenMonitor } from '@/composables/useTokenMonitor' import { useTokenMonitor } from '@/composables/useTokenMonitor';
// 启动全局 Token 监控 // 启动全局 Token 监控
const { startMonitoring } = useTokenMonitor() const { startMonitoring } = useTokenMonitor();
onMounted(() => { onMounted(() => {
startMonitoring() startMonitoring();
}) });
</script> </script>
<style scoped> <style scoped>
@@ -26,7 +26,11 @@ onMounted(() => {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: linear-gradient(135deg, var(--md-sys-color-surface-container-lowest) 0%, var(--md-sys-color-surface-container-low) 100%); background: linear-gradient(
135deg,
var(--md-sys-color-surface-container-lowest) 0%,
var(--md-sys-color-surface-container-low) 100%
);
} }
.main-content { .main-content {
+127 -136
View File
@@ -5,7 +5,9 @@
<!-- Logo and Brand --> <!-- Logo and Brand -->
<div class="flex items-center space-x-8"> <div class="flex items-center space-x-8">
<router-link to="/" class="flex items-center space-x-3 group"> <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"> <div
class="w-10 h-10 bg-gradient-to-br from-primary-500 to-secondary-500 rounded-md3 flex items-center justify-center transform group-hover:scale-110 transition-transform"
>
<CheckCircleOutlined class="text-white text-xl" /> <CheckCircleOutlined class="text-white text-xl" />
</div> </div>
<span class="text-xl font-bold text-gradient">接龙自动打卡</span> <span class="text-xl font-bold text-gradient">接龙自动打卡</span>
@@ -13,19 +15,15 @@
<!-- Desktop Navigation Links --> <!-- Desktop Navigation Links -->
<div v-if="!isMobile" class="hidden md:flex items-center space-x-2"> <div v-if="!isMobile" class="hidden md:flex items-center space-x-2">
<router-link <router-link v-slot="{ isActive }" to="/dashboard" custom>
to="/dashboard"
v-slot="{ isActive }"
custom
>
<a <a
@click="router.push('/dashboard')"
:class="[ :class="[
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer', 'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive isActive
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400' ? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
: 'text-on-surface hover:bg-surface-container' : 'text-on-surface hover:bg-surface-container',
]" ]"
@click="router.push('/dashboard')"
> >
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<HomeOutlined /> <HomeOutlined />
@@ -34,19 +32,15 @@
</a> </a>
</router-link> </router-link>
<router-link <router-link v-slot="{ isActive }" to="/tasks" custom>
to="/tasks"
v-slot="{ isActive }"
custom
>
<a <a
@click="router.push('/tasks')"
:class="[ :class="[
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer', 'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive isActive
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400' ? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
: 'text-on-surface hover:bg-surface-container' : 'text-on-surface hover:bg-surface-container',
]" ]"
@click="router.push('/tasks')"
> >
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<FileTextOutlined /> <FileTextOutlined />
@@ -55,19 +49,15 @@
</a> </a>
</router-link> </router-link>
<router-link <router-link v-slot="{ isActive }" to="/records" custom>
to="/records"
v-slot="{ isActive }"
custom
>
<a <a
@click="router.push('/records')"
:class="[ :class="[
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer', 'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive isActive
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400' ? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
: 'text-on-surface hover:bg-surface-container' : 'text-on-surface hover:bg-surface-container',
]" ]"
@click="router.push('/records')"
> >
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<UnorderedListOutlined /> <UnorderedListOutlined />
@@ -81,7 +71,9 @@
<a <a
:class="[ :class="[
'px-4 py-2 rounded-full font-medium transition-all flex items-center space-x-2 cursor-pointer', 'px-4 py-2 rounded-full font-medium transition-all flex items-center space-x-2 cursor-pointer',
isAdminPath ? 'bg-secondary-100 dark:bg-secondary-900/30 text-secondary-700 dark:text-secondary-400' : 'text-on-surface hover:bg-surface-container' isAdminPath
? 'bg-secondary-100 dark:bg-secondary-900/30 text-secondary-700 dark:text-secondary-400'
: 'text-on-surface hover:bg-surface-container',
]" ]"
> >
<SettingOutlined /> <SettingOutlined />
@@ -155,11 +147,15 @@
<!-- Desktop User Menu --> <!-- Desktop User Menu -->
<a-dropdown v-if="!isMobile" :trigger="['hover']"> <a-dropdown v-if="!isMobile" :trigger="['hover']">
<a class="flex items-center space-x-3 px-4 py-2 rounded-full hover:bg-surface-container transition-all cursor-pointer"> <a
class="flex items-center space-x-3 px-4 py-2 rounded-full hover:bg-surface-container transition-all cursor-pointer"
>
<a-avatar :style="{ backgroundColor: '#f56a00' }"> <a-avatar :style="{ backgroundColor: '#f56a00' }">
{{ userInitial }} {{ userInitial }}
</a-avatar> </a-avatar>
<span class="hidden md:block font-medium text-on-surface">{{ authStore.user?.alias || '用户' }}</span> <span class="hidden md:block font-medium text-on-surface">{{
authStore.user?.alias || '用户'
}}</span>
<DownOutlined class="text-xs text-on-surface-variant" /> <DownOutlined class="text-xs text-on-surface-variant" />
</a> </a>
<template #overlay> <template #overlay>
@@ -167,7 +163,9 @@
<a-menu-item key="info" disabled> <a-menu-item key="info" disabled>
<div class="px-2 py-1"> <div class="px-2 py-1">
<p class="text-sm font-medium text-on-surface">{{ authStore.user?.alias }}</p> <p class="text-sm font-medium text-on-surface">{{ authStore.user?.alias }}</p>
<p class="text-xs text-on-surface-variant mt-1">{{ authStore.isAdmin ? '管理员' : '普通用户' }}</p> <p class="text-xs text-on-surface-variant mt-1">
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
</p>
</div> </div>
</a-menu-item> </a-menu-item>
<a-menu-divider /> <a-menu-divider />
@@ -175,7 +173,7 @@
<SettingOutlined /> <SettingOutlined />
<span class="ml-2">个人设置</span> <span class="ml-2">个人设置</span>
</a-menu-item> </a-menu-item>
<a-menu-item key="logout" @click="handleLogout" danger> <a-menu-item key="logout" danger @click="handleLogout">
<LogoutOutlined /> <LogoutOutlined />
<span class="ml-2">退出登录</span> <span class="ml-2">退出登录</span>
</a-menu-item> </a-menu-item>
@@ -197,12 +195,7 @@
</nav> </nav>
<!-- Mobile Drawer --> <!-- Mobile Drawer -->
<a-drawer <a-drawer v-model:open="drawerVisible" placement="left" :width="280" title="菜单">
v-model:open="drawerVisible"
placement="left"
:width="280"
title="菜单"
>
<!-- User Info in Drawer --> <!-- User Info in Drawer -->
<div class="mb-6 pb-4 border-b border-outline-variant"> <div class="mb-6 pb-4 border-b border-outline-variant">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
@@ -211,17 +204,15 @@
</a-avatar> </a-avatar>
<div> <div>
<p class="font-medium text-on-surface">{{ authStore.user?.alias || '用户' }}</p> <p class="font-medium text-on-surface">{{ authStore.user?.alias || '用户' }}</p>
<p class="text-xs text-on-surface-variant">{{ authStore.isAdmin ? '管理员' : '普通用户' }}</p> <p class="text-xs text-on-surface-variant">
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Mobile Navigation Menu --> <!-- Mobile Navigation Menu -->
<a-menu <a-menu mode="inline" :selected-keys="[currentMenuKey]" @click="handleMenuClick">
mode="inline"
:selected-keys="[currentMenuKey]"
@click="handleMenuClick"
>
<a-menu-item key="dashboard"> <a-menu-item key="dashboard">
<template #icon><HomeOutlined /></template> <template #icon><HomeOutlined /></template>
仪表盘 仪表盘
@@ -285,15 +276,15 @@
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth';
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user';
import { useTokenMonitor } from '@/composables/useTokenMonitor' import { useTokenMonitor } from '@/composables/useTokenMonitor';
import { useBreakpoint } from '@/composables/useBreakpoint' import { useBreakpoint } from '@/composables/useBreakpoint';
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme';
import { Modal, message } from 'ant-design-vue' import { Modal, message } from 'ant-design-vue';
import QRCodeModal from './QRCodeModal.vue' import QRCodeModal from './QRCodeModal.vue';
import { import {
MenuOutlined, MenuOutlined,
HomeOutlined, HomeOutlined,
@@ -311,123 +302,123 @@ import {
BulbOutlined, BulbOutlined,
BulbFilled, BulbFilled,
ReloadOutlined, ReloadOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue';
const router = useRouter() const router = useRouter();
const route = useRoute() const route = useRoute();
const authStore = useAuthStore() const authStore = useAuthStore();
const userStore = useUserStore() const userStore = useUserStore();
const { isMobile } = useBreakpoint() const { isMobile } = useBreakpoint();
const { getRemainingMinutes, tokenStatus } = useTokenMonitor() const { getRemainingMinutes, tokenStatus } = useTokenMonitor();
const { isDark, toggleTheme } = useTheme() const { isDark, toggleTheme } = useTheme();
const drawerVisible = ref(false) const drawerVisible = ref(false);
const qrcodeModalVisible = ref(false) const qrcodeModalVisible = ref(false);
const isAdminPath = computed(() => route.path.startsWith('/admin')) const isAdminPath = computed(() => route.path.startsWith('/admin'));
const userInitial = computed(() => { const userInitial = computed(() => {
const name = authStore.user?.alias || 'U' const name = authStore.user?.alias || 'U';
return name.charAt(0).toUpperCase() return name.charAt(0).toUpperCase();
}) });
// Token 状态计算 // Token 状态计算
const remainingMinutes = computed(() => { const remainingMinutes = computed(() => {
return getRemainingMinutes() return getRemainingMinutes();
}) });
const showTokenStatus = computed(() => { const showTokenStatus = computed(() => {
if (!authStore.isAuthenticated || !tokenStatus.value) return false if (!authStore.isAuthenticated || !tokenStatus.value) return false;
const mins = remainingMinutes.value const mins = remainingMinutes.value;
// 显示条件:Token 即将过期(60分钟内)或已过期(5分钟内) // 显示条件:Token 即将过期(60分钟内)或已过期(5分钟内)
if (mins === null) return false if (mins === null) return false;
return mins <= 60 || (mins < 0 && Math.abs(mins) <= 5) return mins <= 60 || (mins < 0 && Math.abs(mins) <= 5);
}) });
const tokenBadgeStatus = computed(() => { const tokenBadgeStatus = computed(() => {
const mins = remainingMinutes.value const mins = remainingMinutes.value;
if (mins === null) return 'default' if (mins === null) return 'default';
if (mins < 0) return 'error' // 已过期 if (mins < 0) return 'error'; // 已过期
if (mins <= 10) return 'error' // 10分钟内过期 if (mins <= 10) return 'error'; // 10分钟内过期
if (mins <= 30) return 'warning' // 30分钟内过期 if (mins <= 30) return 'warning'; // 30分钟内过期
return 'processing' // 正常但快过期 return 'processing'; // 正常但快过期
}) });
const tokenBadgeText = computed(() => { const tokenBadgeText = computed(() => {
const mins = remainingMinutes.value const mins = remainingMinutes.value;
if (mins === null) return '' if (mins === null) return '';
if (mins < 0) return 'Token 已过期' if (mins < 0) return 'Token 已过期';
if (mins < 60) return `Token 剩余:${mins}分钟` if (mins < 60) return `Token 剩余:${mins}分钟`;
return '' return '';
}) });
const tokenIconClass = computed(() => { const tokenIconClass = computed(() => {
const mins = remainingMinutes.value const mins = remainingMinutes.value;
if (mins === null) return 'text-on-surface-variant' if (mins === null) return 'text-on-surface-variant';
if (mins < 0) return 'text-red-500 dark:text-red-400' // 已过期 if (mins < 0) return 'text-red-500 dark:text-red-400'; // 已过期
if (mins <= 10) return 'text-red-500 dark:text-red-400 animate-pulse' // 10分钟内,闪烁 if (mins <= 10) return 'text-red-500 dark:text-red-400 animate-pulse'; // 10分钟内,闪烁
if (mins <= 30) return 'text-orange-500 dark:text-orange-400' // 30分钟内 if (mins <= 30) return 'text-orange-500 dark:text-orange-400'; // 30分钟内
return 'text-blue-500 dark:text-blue-400' // 正常 return 'text-blue-500 dark:text-blue-400'; // 正常
}) });
const tokenStatusTooltip = computed(() => { const tokenStatusTooltip = computed(() => {
const mins = remainingMinutes.value const mins = remainingMinutes.value;
if (mins === null) return 'Token 状态未知' if (mins === null) return 'Token 状态未知';
if (mins < 0) { if (mins < 0) {
const expiredMins = Math.abs(mins) const expiredMins = Math.abs(mins);
return `登录凭证已过期 ${expiredMins} 分钟,点击右侧按钮刷新` return `登录凭证已过期 ${expiredMins} 分钟,点击右侧按钮刷新`;
} }
if (mins < 60) { if (mins < 60) {
return `Token 剩余时间:${mins} 分钟,过期后可刷新)` return `Token 剩余时间:${mins} 分钟,过期后可刷新)`;
} }
return 'Token 状态正常' return 'Token 状态正常';
}) });
const handleTokenStatusClick = () => { const handleTokenStatusClick = () => {
const mins = remainingMinutes.value const mins = remainingMinutes.value;
// Token 已过期时提醒刷新 // Token 已过期时提醒刷新
if (mins !== null && mins < 0) { if (mins !== null && mins < 0) {
message.info('Token 已过期,请进行刷新') message.info('Token 已过期,请进行刷新');
} }
// Token 未过期时,点击无效果 // Token 未过期时,点击无效果
} };
const currentMenuKey = computed(() => { const currentMenuKey = computed(() => {
const path = route.path const path = route.path;
if (path.startsWith('/admin/users')) return 'admin-users' if (path.startsWith('/admin/users')) return 'admin-users';
if (path.startsWith('/admin/templates')) return 'admin-templates' if (path.startsWith('/admin/templates')) return 'admin-templates';
if (path.startsWith('/admin/records')) return 'admin-records' if (path.startsWith('/admin/records')) return 'admin-records';
if (path.startsWith('/admin/stats')) return 'admin-stats' if (path.startsWith('/admin/stats')) return 'admin-stats';
if (path.startsWith('/admin/logs')) return 'admin-logs' if (path.startsWith('/admin/logs')) return 'admin-logs';
if (path.startsWith('/dashboard')) return 'dashboard' if (path.startsWith('/dashboard')) return 'dashboard';
if (path.startsWith('/tasks')) return 'tasks' if (path.startsWith('/tasks')) return 'tasks';
if (path.startsWith('/records')) return 'records' if (path.startsWith('/records')) return 'records';
if (path.startsWith('/settings')) return 'settings' if (path.startsWith('/settings')) return 'settings';
return '' return '';
}) });
const handleMenuClick = ({ key }) => { const handleMenuClick = ({ key }) => {
const routes = { const routes = {
'dashboard': '/dashboard', dashboard: '/dashboard',
'tasks': '/tasks', tasks: '/tasks',
'records': '/records', records: '/records',
'admin-users': '/admin/users', 'admin-users': '/admin/users',
'admin-templates': '/admin/templates', 'admin-templates': '/admin/templates',
'admin-records': '/admin/records', 'admin-records': '/admin/records',
'admin-stats': '/admin/stats', 'admin-stats': '/admin/stats',
'admin-logs': '/admin/logs', 'admin-logs': '/admin/logs',
'settings': '/settings', settings: '/settings',
} };
if (key === 'logout') { if (key === 'logout') {
handleLogout() handleLogout();
} else if (routes[key]) { } else if (routes[key]) {
router.push(routes[key]) router.push(routes[key]);
drawerVisible.value = false drawerVisible.value = false;
} }
} };
const handleLogout = () => { const handleLogout = () => {
Modal.confirm({ Modal.confirm({
@@ -436,36 +427,36 @@ const handleLogout = () => {
okText: '确定', okText: '确定',
cancelText: '取消', cancelText: '取消',
onOk() { onOk() {
authStore.logout() authStore.logout();
router.push('/login') router.push('/login');
drawerVisible.value = false drawerVisible.value = false;
}, },
}) });
} };
// 处理 Token 刷新 // 处理 Token 刷新
const handleRefreshToken = () => { const handleRefreshToken = () => {
qrcodeModalVisible.value = true qrcodeModalVisible.value = true;
} };
// 处理 QR 码扫码成功 // 处理 QR 码扫码成功
const handleQRCodeSuccess = async () => { const handleQRCodeSuccess = async () => {
message.success('Token 刷新成功') message.success('Token 刷新成功');
qrcodeModalVisible.value = false qrcodeModalVisible.value = false;
// 刷新用户信息和 Token 状态 // 刷新用户信息和 Token 状态
try { try {
await authStore.fetchCurrentUser() await authStore.fetchCurrentUser();
await userStore.fetchTokenStatus() await userStore.fetchTokenStatus();
} catch (error) { } catch (error) {
console.error('刷新用户信息失败:', error) console.error('刷新用户信息失败:', error);
} }
} };
// 处理 QR 码扫码失败 // 处理 QR 码扫码失败
const handleQRCodeError = (error) => { const handleQRCodeError = error => {
message.error(error?.message || 'Token 刷新失败') message.error(error?.message || 'Token 刷新失败');
} };
</script> </script>
<style scoped> <style scoped>
+84 -87
View File
@@ -4,9 +4,9 @@
title="QQ 扫码登录" title="QQ 扫码登录"
:width="isMobile ? '100%' : 400" :width="isMobile ? '100%' : 400"
:style="isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : {}" :style="isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : {}"
:maskClosable="false" :mask-closable="false"
@cancel="handleClose"
:footer="null" :footer="null"
@cancel="handleClose"
> >
<div class="qrcode-container"> <div class="qrcode-container">
<!-- 加载中 --> <!-- 加载中 -->
@@ -33,30 +33,26 @@
<div v-else-if="status === 'expired'" class="status-container"> <div v-else-if="status === 'expired'" class="status-container">
<WarningFilled class="status-icon warning-icon" /> <WarningFilled class="status-icon warning-icon" />
<p class="status-text">二维码已过期</p> <p class="status-text">二维码已过期</p>
<a-button type="primary" @click="refreshQRCode" class="mt-4">刷新二维码</a-button> <a-button type="primary" class="mt-4" @click="refreshQRCode">刷新二维码</a-button>
</div> </div>
<!-- 失败 --> <!-- 失败 -->
<div v-else-if="status === 'failed'" class="status-container"> <div v-else-if="status === 'failed'" class="status-container">
<CloseCircleFilled class="status-icon error-icon" /> <CloseCircleFilled class="status-icon error-icon" />
<p class="status-text error">{{ errorMessage }}</p> <p class="status-text error">{{ errorMessage }}</p>
<a-button type="primary" @click="refreshQRCode" class="mt-4">重试</a-button> <a-button type="primary" class="mt-4" @click="refreshQRCode">重试</a-button>
</div> </div>
</div> </div>
</a-modal> </a-modal>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue' import { ref, computed, watch, onBeforeUnmount } from 'vue';
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth';
import { useBreakpoint } from '@/composables/useBreakpoint' import { useBreakpoint } from '@/composables/useBreakpoint';
import { usePollStatus } from '@/composables/usePollStatus' import { usePollStatus } from '@/composables/usePollStatus';
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue';
import { import { CheckCircleFilled, WarningFilled, CloseCircleFilled } from '@ant-design/icons-vue';
CheckCircleFilled,
WarningFilled,
CloseCircleFilled,
} from '@ant-design/icons-vue'
const props = defineProps({ const props = defineProps({
visible: { visible: {
@@ -67,161 +63,162 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
}) });
const emit = defineEmits(['update:visible', 'success', 'error']) const emit = defineEmits(['update:visible', 'success', 'error']);
const authStore = useAuthStore() const authStore = useAuthStore();
const { isMobile } = useBreakpoint() const { isMobile } = useBreakpoint();
// 使用轮询 composable // 使用轮询 composable
const { startPolling: startQRPolling, stopPolling } = usePollStatus({ const { startPolling: startQRPolling, stopPolling } = usePollStatus({
interval: 2000, interval: 2000,
maxRetries: 90, // 3分钟 = 180秒 / 2秒间隔 = 90次 maxRetries: 90, // 3分钟 = 180秒 / 2秒间隔 = 90次
backoff: false backoff: false,
}) });
const dialogVisible = computed({ const dialogVisible = computed({
get: () => props.visible, get: () => props.visible,
set: (val) => emit('update:visible', val), set: val => emit('update:visible', val),
}) });
const status = ref('loading') // loading, pending, success, expired, failed const status = ref('loading'); // loading, pending, success, expired, failed
const qrcodeUrl = ref('') const qrcodeUrl = ref('');
const sessionId = ref('') const sessionId = ref('');
const errorMessage = ref('') const errorMessage = ref('');
const countdown = ref(180) // 倒计时 3 分钟 const countdown = ref(180); // 倒计时 3 分钟
const progress = ref(100) const progress = ref(100);
let countdownTimer = null let countdownTimer = null;
// 获取二维码 // 获取二维码
const fetchQRCode = async () => { const fetchQRCode = async () => {
status.value = 'loading' status.value = 'loading';
try { try {
const result = await authStore.loginWithQRCode(props.alias) const result = await authStore.loginWithQRCode(props.alias);
sessionId.value = result.session_id sessionId.value = result.session_id;
qrcodeUrl.value = `data:image/png;base64,${result.qrcode_base64}` qrcodeUrl.value = `data:image/png;base64,${result.qrcode_base64}`;
status.value = 'pending' status.value = 'pending';
// 开始轮询扫码状态(使用 composable // 开始轮询扫码状态(使用 composable
startQRPolling( startQRPolling(
async () => { async () => {
const result = await authStore.checkQRCodeStatus(sessionId.value) const result = await authStore.checkQRCodeStatus(sessionId.value);
// 检查是否完成(成功、过期或失败) // 检查是否完成(成功、过期或失败)
const completed = result.status === 'expired' || result.status === 'failed' || result.success const completed =
result.status === 'expired' || result.status === 'failed' || result.success;
return { return {
completed, completed,
success: result.success === true, success: result.success === true,
data: result data: result,
} };
}, },
{ {
onSuccess: (result) => { onSuccess: result => {
status.value = 'success' status.value = 'success';
stopCountdown() stopCountdown();
message.success('登录成功!') message.success('登录成功!');
// 延迟关闭对话框 // 延迟关闭对话框
setTimeout(() => { setTimeout(() => {
emit('success', result.user) emit('success', result.user);
handleClose() handleClose();
}, 1500) }, 1500);
}, },
onFailure: (result) => { onFailure: result => {
if (result.status === 'expired') { if (result.status === 'expired') {
status.value = 'expired' status.value = 'expired';
} else { } else {
status.value = 'failed' status.value = 'failed';
errorMessage.value = result.message || '扫码失败' errorMessage.value = result.message || '扫码失败';
} }
stopCountdown() stopCountdown();
}, },
onTimeout: () => { onTimeout: () => {
status.value = 'expired' status.value = 'expired';
stopCountdown() stopCountdown();
},
} }
} );
)
startCountdown() startCountdown();
} catch (error) { } catch (error) {
status.value = 'failed' status.value = 'failed';
errorMessage.value = error.message || '获取二维码失败' errorMessage.value = error.message || '获取二维码失败';
emit('error', error) emit('error', error);
} }
} };
// 开始倒计时 // 开始倒计时
const startCountdown = () => { const startCountdown = () => {
countdown.value = 180 countdown.value = 180;
if (countdownTimer) { if (countdownTimer) {
clearInterval(countdownTimer) clearInterval(countdownTimer);
} }
countdownTimer = setInterval(() => { countdownTimer = setInterval(() => {
countdown.value-- countdown.value--;
progress.value = (countdown.value / 180) * 100 progress.value = (countdown.value / 180) * 100;
if (countdown.value <= 0) { if (countdown.value <= 0) {
status.value = 'expired' status.value = 'expired';
stopPolling() // 停止轮询 stopPolling(); // 停止轮询
stopCountdown() stopCountdown();
} }
}, 1000) }, 1000);
} };
// 停止倒计时 // 停止倒计时
const stopCountdown = () => { const stopCountdown = () => {
if (countdownTimer) { if (countdownTimer) {
clearInterval(countdownTimer) clearInterval(countdownTimer);
countdownTimer = null countdownTimer = null;
} }
} };
// 刷新二维码 // 刷新二维码
const refreshQRCode = () => { const refreshQRCode = () => {
fetchQRCode() fetchQRCode();
} };
// 关闭对话框 // 关闭对话框
const handleClose = () => { const handleClose = () => {
stopPolling() // 停止轮询 stopPolling(); // 停止轮询
stopCountdown() stopCountdown();
// 如果有未完成的会话,取消它 // 如果有未完成的会话,取消它
if (sessionId.value && status.value !== 'success') { if (sessionId.value && status.value !== 'success') {
try { try {
authStore.cancelQRCodeSession(sessionId.value) authStore.cancelQRCodeSession(sessionId.value);
} catch (error) { } catch (error) {
console.error('取消会话失败:', error) console.error('取消会话失败:', error);
} }
} }
dialogVisible.value = false dialogVisible.value = false;
} };
// 监听对话框显示状态 // 监听对话框显示状态
watch( watch(
() => props.visible, () => props.visible,
(visible) => { visible => {
if (visible) { if (visible) {
fetchQRCode() fetchQRCode();
} else { } else {
stopPolling() stopPolling();
stopCountdown() stopCountdown();
} }
} }
) );
// 组件卸载时清理定时器,防止内存泄漏 // 组件卸载时清理定时器,防止内存泄漏
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopPolling() stopPolling();
stopCountdown() stopCountdown();
}) });
</script> </script>
<style scoped> <style scoped>
+20 -29
View File
@@ -1,12 +1,8 @@
<template> <template>
<a-card class="md3-card text-center" style="padding: 48px 20px;"> <a-card class="md3-card text-center" style="padding: 48px 20px">
<!-- 图标 --> <!-- 图标 -->
<div v-if="icon" class="mb-6"> <div v-if="icon" class="mb-6">
<component <component :is="icon" class="text-8xl mx-auto" :class="iconColorClass" />
:is="icon"
class="text-8xl mx-auto"
:class="iconColorClass"
/>
</div> </div>
<!-- 标题 --> <!-- 标题 -->
@@ -22,12 +18,7 @@
<!-- 操作按钮可选 --> <!-- 操作按钮可选 -->
<div v-if="$slots.action || actionText"> <div v-if="$slots.action || actionText">
<slot name="action"> <slot name="action">
<a-button <a-button v-if="actionText" type="primary" :loading="loading" @click="handleAction">
v-if="actionText"
type="primary"
@click="handleAction"
:loading="loading"
>
<template v-if="actionIcon" #icon> <template v-if="actionIcon" #icon>
<component :is="actionIcon" /> <component :is="actionIcon" />
</template> </template>
@@ -39,7 +30,7 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue';
const props = defineProps({ const props = defineProps({
/** /**
@@ -47,7 +38,7 @@ const props = defineProps({
*/ */
icon: { icon: {
type: Object, type: Object,
default: null default: null,
}, },
/** /**
@@ -55,7 +46,7 @@ const props = defineProps({
*/ */
title: { title: {
type: String, type: String,
default: '' default: '',
}, },
/** /**
@@ -63,7 +54,7 @@ const props = defineProps({
*/ */
description: { description: {
type: String, type: String,
default: '' default: '',
}, },
/** /**
@@ -71,7 +62,7 @@ const props = defineProps({
*/ */
actionText: { actionText: {
type: String, type: String,
default: '' default: '',
}, },
/** /**
@@ -79,7 +70,7 @@ const props = defineProps({
*/ */
actionIcon: { actionIcon: {
type: Object, type: Object,
default: null default: null,
}, },
/** /**
@@ -87,7 +78,7 @@ const props = defineProps({
*/ */
loading: { loading: {
type: Boolean, type: Boolean,
default: false default: false,
}, },
/** /**
@@ -96,15 +87,15 @@ const props = defineProps({
iconColor: { iconColor: {
type: String, type: String,
default: 'neutral', default: 'neutral',
validator: (v) => ['primary', 'neutral', 'success', 'warning', 'error'].includes(v) validator: v => ['primary', 'neutral', 'success', 'warning', 'error'].includes(v),
} },
}) });
const emit = defineEmits(['action']) const emit = defineEmits(['action']);
const handleAction = () => { const handleAction = () => {
emit('action') emit('action');
} };
const iconColorClass = computed(() => { const iconColorClass = computed(() => {
const colors = { const colors = {
@@ -112,8 +103,8 @@ const iconColorClass = computed(() => {
neutral: 'text-on-surface-variant', neutral: 'text-on-surface-variant',
success: 'text-green-500', success: 'text-green-500',
warning: 'text-orange-500', warning: 'text-orange-500',
error: 'text-error' error: 'text-error',
} };
return colors[props.iconColor] return colors[props.iconColor];
}) });
</script> </script>
+20 -39
View File
@@ -2,61 +2,38 @@
<div v-if="loading" class="loading-state"> <div v-if="loading" class="loading-state">
<!-- 卡片骨架屏 --> <!-- 卡片骨架屏 -->
<div v-if="type === 'card'" class="grid grid-cols-1 gap-4"> <div v-if="type === 'card'" class="grid grid-cols-1 gap-4">
<a-card <a-card v-for="i in count" :key="i" class="md3-card">
v-for="i in count" <a-skeleton :active="true" :paragraph="{ rows: paragraphRows }" :avatar="showAvatar" />
:key="i"
class="md3-card"
>
<a-skeleton
:active="true"
:paragraph="{ rows: paragraphRows }"
:avatar="showAvatar"
/>
</a-card> </a-card>
</div> </div>
<!-- 列表骨架屏 --> <!-- 列表骨架屏 -->
<div v-else-if="type === 'list'" class="space-y-4"> <div v-else-if="type === 'list'" class="space-y-4">
<a-card <a-card v-for="i in count" :key="i" class="md3-card">
v-for="i in count" <a-skeleton :active="true" :paragraph="{ rows: 1 }" :avatar="showAvatar" />
:key="i"
class="md3-card"
>
<a-skeleton
:active="true"
:paragraph="{ rows: 1 }"
:avatar="showAvatar"
/>
</a-card> </a-card>
</div> </div>
<!-- 表格骨架屏 --> <!-- 表格骨架屏 -->
<a-card v-else-if="type === 'table'" class="md3-card"> <a-card v-else-if="type === 'table'" class="md3-card">
<a-skeleton <a-skeleton :active="true" :paragraph="{ rows: count * 2 }" />
:active="true"
:paragraph="{ rows: count * 2 }"
/>
</a-card> </a-card>
<!-- 默认骨架屏 --> <!-- 默认骨架屏 -->
<a-card v-else class="md3-card"> <a-card v-else class="md3-card">
<a-skeleton <a-skeleton :active="true" :paragraph="{ rows: paragraphRows }" :avatar="showAvatar" />
:active="true"
:paragraph="{ rows: paragraphRows }"
:avatar="showAvatar"
/>
</a-card> </a-card>
</div> </div>
</template> </template>
<script setup> <script setup>
const props = defineProps({ defineProps({
/** /**
* 是否显示加载状态 * 是否显示加载状态
*/ */
loading: { loading: {
type: Boolean, type: Boolean,
default: true default: true,
}, },
/** /**
@@ -65,7 +42,7 @@ const props = defineProps({
type: { type: {
type: String, type: String,
default: 'card', default: 'card',
validator: (v) => ['card', 'list', 'table', 'default'].includes(v) validator: v => ['card', 'list', 'table', 'default'].includes(v),
}, },
/** /**
@@ -73,7 +50,7 @@ const props = defineProps({
*/ */
count: { count: {
type: Number, type: Number,
default: 3 default: 3,
}, },
/** /**
@@ -81,7 +58,7 @@ const props = defineProps({
*/ */
paragraphRows: { paragraphRows: {
type: Number, type: Number,
default: 4 default: 4,
}, },
/** /**
@@ -89,9 +66,9 @@ const props = defineProps({
*/ */
showAvatar: { showAvatar: {
type: Boolean, type: Boolean,
default: false default: false,
} },
}) });
</script> </script>
<style scoped> <style scoped>
@@ -100,7 +77,11 @@ const props = defineProps({
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from {
to { opacity: 1; } opacity: 0;
}
to {
opacity: 1;
}
} }
</style> </style>
+39 -47
View File
@@ -28,11 +28,7 @@
<!-- 趋势指示器可选 --> <!-- 趋势指示器可选 -->
<div v-if="trend !== undefined" class="mt-3 pt-3 border-t border-outline-variant"> <div v-if="trend !== undefined" class="mt-3 pt-3 border-t border-outline-variant">
<div class="flex items-center text-sm"> <div class="flex items-center text-sm">
<component <component :is="trendIcon" :class="trendColorClass" class="mr-1" />
:is="trendIcon"
:class="trendColorClass"
class="mr-1"
/>
<span :class="trendColorClass" class="md3-label-small"> <span :class="trendColorClass" class="md3-label-small">
{{ trendText }} {{ trendText }}
</span> </span>
@@ -42,12 +38,8 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue';
import { import { ArrowUpOutlined, ArrowDownOutlined, MinusOutlined } from '@ant-design/icons-vue';
ArrowUpOutlined,
ArrowDownOutlined,
MinusOutlined
} from '@ant-design/icons-vue'
const props = defineProps({ const props = defineProps({
/** /**
@@ -55,7 +47,7 @@ const props = defineProps({
*/ */
label: { label: {
type: String, type: String,
required: true required: true,
}, },
/** /**
@@ -63,7 +55,7 @@ const props = defineProps({
*/ */
value: { value: {
type: [String, Number], type: [String, Number],
required: true required: true,
}, },
/** /**
@@ -71,7 +63,7 @@ const props = defineProps({
*/ */
subtitle: { subtitle: {
type: String, type: String,
default: '' default: '',
}, },
/** /**
@@ -79,7 +71,7 @@ const props = defineProps({
*/ */
icon: { icon: {
type: Object, type: Object,
default: null default: null,
}, },
/** /**
@@ -88,7 +80,7 @@ const props = defineProps({
color: { color: {
type: String, type: String,
default: 'primary', default: 'primary',
validator: (v) => ['primary', 'success', 'warning', 'error', 'info', 'neutral'].includes(v) validator: v => ['primary', 'success', 'warning', 'error', 'info', 'neutral'].includes(v),
}, },
/** /**
@@ -96,7 +88,7 @@ const props = defineProps({
*/ */
delay: { delay: {
type: Number, type: Number,
default: 0 default: 0,
}, },
/** /**
@@ -104,7 +96,7 @@ const props = defineProps({
*/ */
formatter: { formatter: {
type: Function, type: Function,
default: null default: null,
}, },
/** /**
@@ -112,7 +104,7 @@ const props = defineProps({
*/ */
trend: { trend: {
type: Number, type: Number,
default: undefined default: undefined,
}, },
/** /**
@@ -120,73 +112,73 @@ const props = defineProps({
*/ */
trendText: { trendText: {
type: String, type: String,
default: '' default: '',
} },
}) });
// 动画延迟 // 动画延迟
const animationDelay = computed(() => `${props.delay}s`) const animationDelay = computed(() => `${props.delay}s`);
// 格式化数值 // 格式化数值
const formattedValue = computed(() => { const formattedValue = computed(() => {
if (props.formatter) { if (props.formatter) {
return props.formatter(props.value) return props.formatter(props.value);
} }
return props.value return props.value;
}) });
// 颜色映射 // 颜色映射
const colorClasses = { const colorClasses = {
primary: { primary: {
value: 'text-primary', value: 'text-primary',
iconBg: 'bg-primary-100 dark:bg-primary-900/30', iconBg: 'bg-primary-100 dark:bg-primary-900/30',
icon: 'text-primary' icon: 'text-primary',
}, },
success: { success: {
value: 'text-green-600 dark:text-green-400', value: 'text-green-600 dark:text-green-400',
iconBg: 'bg-green-100 dark:bg-green-900/30', iconBg: 'bg-green-100 dark:bg-green-900/30',
icon: 'text-green-600 dark:text-green-400' icon: 'text-green-600 dark:text-green-400',
}, },
warning: { warning: {
value: 'text-orange-600 dark:text-orange-400', value: 'text-orange-600 dark:text-orange-400',
iconBg: 'bg-orange-100 dark:bg-orange-900/30', iconBg: 'bg-orange-100 dark:bg-orange-900/30',
icon: 'text-orange-600 dark:text-orange-400' icon: 'text-orange-600 dark:text-orange-400',
}, },
error: { error: {
value: 'text-error', value: 'text-error',
iconBg: 'bg-red-100 dark:bg-red-900/30', iconBg: 'bg-red-100 dark:bg-red-900/30',
icon: 'text-error' icon: 'text-error',
}, },
info: { info: {
value: 'text-secondary', value: 'text-secondary',
iconBg: 'bg-blue-100 dark:bg-blue-900/30', iconBg: 'bg-blue-100 dark:bg-blue-900/30',
icon: 'text-secondary' icon: 'text-secondary',
}, },
neutral: { neutral: {
value: 'text-on-surface', value: 'text-on-surface',
iconBg: 'bg-surface-container', iconBg: 'bg-surface-container',
icon: 'text-on-surface-variant' icon: 'text-on-surface-variant',
} },
} };
const valueColorClass = computed(() => colorClasses[props.color].value) const valueColorClass = computed(() => colorClasses[props.color].value);
const iconBgClass = computed(() => colorClasses[props.color].iconBg) const iconBgClass = computed(() => colorClasses[props.color].iconBg);
const iconColorClass = computed(() => colorClasses[props.color].icon) const iconColorClass = computed(() => colorClasses[props.color].icon);
// 趋势图标和颜色 // 趋势图标和颜色
const trendIcon = computed(() => { const trendIcon = computed(() => {
if (props.trend === undefined) return null if (props.trend === undefined) return null;
if (props.trend > 0) return ArrowUpOutlined if (props.trend > 0) return ArrowUpOutlined;
if (props.trend < 0) return ArrowDownOutlined if (props.trend < 0) return ArrowDownOutlined;
return MinusOutlined return MinusOutlined;
}) });
const trendColorClass = computed(() => { const trendColorClass = computed(() => {
if (props.trend === undefined) return '' if (props.trend === undefined) return '';
if (props.trend > 0) return 'text-green-600 dark:text-green-400' if (props.trend > 0) return 'text-green-600 dark:text-green-400';
if (props.trend < 0) return 'text-red-600 dark:text-red-400' if (props.trend < 0) return 'text-red-600 dark:text-red-400';
return 'text-on-surface-variant' return 'text-on-surface-variant';
}) });
</script> </script>
<style scoped> <style scoped>
+23 -23
View File
@@ -13,12 +13,12 @@
* } * }
*/ */
import { ref } from 'vue' import { ref } from 'vue';
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue';
export function useAsyncAction(options = {}) { export function useAsyncAction(options = {}) {
const loading = ref(false) const loading = ref(false);
const error = ref(null) const error = ref(null);
/** /**
* 执行异步操作 * 执行异步操作
@@ -35,50 +35,50 @@ export function useAsyncAction(options = {}) {
successMsg = options.successMsg, successMsg = options.successMsg,
errorMsg = options.errorMsg, errorMsg = options.errorMsg,
throwOnError = false, throwOnError = false,
silent = false silent = false,
} = config } = config;
loading.value = true loading.value = true;
error.value = null error.value = null;
try { try {
const result = await asyncFn() const result = await asyncFn();
if (!silent && successMsg) { if (!silent && successMsg) {
message.success(successMsg) message.success(successMsg);
} }
return result return result;
} catch (err) { } catch (err) {
error.value = err error.value = err;
if (!silent) { if (!silent) {
const msg = err.message || err.detail || errorMsg || '操作失败' const msg = err.message || err.detail || errorMsg || '操作失败';
message.error(msg) message.error(msg);
} }
if (throwOnError) { if (throwOnError) {
throw err throw err;
} }
return null return null;
} finally { } finally {
loading.value = false loading.value = false;
}
} }
};
/** /**
* 重置状态 * 重置状态
*/ */
const reset = () => { const reset = () => {
loading.value = false loading.value = false;
error.value = null error.value = null;
} };
return { return {
loading, loading,
error, error,
execute, execute,
reset reset,
} };
} }
+26 -26
View File
@@ -1,4 +1,4 @@
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue';
/** /**
* 响应式断点检测 Composable * 响应式断点检测 Composable
@@ -11,42 +11,42 @@ import { ref, onMounted, onUnmounted } from 'vue'
* - xxl: ≥1600px (超大屏) * - xxl: ≥1600px (超大屏)
*/ */
export function useBreakpoint() { export function useBreakpoint() {
const isMobile = ref(window.innerWidth < 768) const isMobile = ref(window.innerWidth < 768);
const isTablet = ref(window.innerWidth >= 768 && window.innerWidth < 992) const isTablet = ref(window.innerWidth >= 768 && window.innerWidth < 992);
const isDesktop = ref(window.innerWidth >= 992) const isDesktop = ref(window.innerWidth >= 992);
// Ant Design 断点 // Ant Design 断点
const isXs = ref(window.innerWidth < 576) const isXs = ref(window.innerWidth < 576);
const isSm = ref(window.innerWidth >= 576 && window.innerWidth < 768) const isSm = ref(window.innerWidth >= 576 && window.innerWidth < 768);
const isMd = ref(window.innerWidth >= 768 && window.innerWidth < 992) const isMd = ref(window.innerWidth >= 768 && window.innerWidth < 992);
const isLg = ref(window.innerWidth >= 992 && window.innerWidth < 1200) const isLg = ref(window.innerWidth >= 992 && window.innerWidth < 1200);
const isXl = ref(window.innerWidth >= 1200 && window.innerWidth < 1600) const isXl = ref(window.innerWidth >= 1200 && window.innerWidth < 1600);
const isXxl = ref(window.innerWidth >= 1600) const isXxl = ref(window.innerWidth >= 1600);
const updateBreakpoints = () => { const updateBreakpoints = () => {
const width = window.innerWidth const width = window.innerWidth;
// 简化断点 // 简化断点
isMobile.value = width < 768 isMobile.value = width < 768;
isTablet.value = width >= 768 && width < 992 isTablet.value = width >= 768 && width < 992;
isDesktop.value = width >= 992 isDesktop.value = width >= 992;
// Ant Design 断点 // Ant Design 断点
isXs.value = width < 576 isXs.value = width < 576;
isSm.value = width >= 576 && width < 768 isSm.value = width >= 576 && width < 768;
isMd.value = width >= 768 && width < 992 isMd.value = width >= 768 && width < 992;
isLg.value = width >= 992 && width < 1200 isLg.value = width >= 992 && width < 1200;
isXl.value = width >= 1200 && width < 1600 isXl.value = width >= 1200 && width < 1600;
isXxl.value = width >= 1600 isXxl.value = width >= 1600;
} };
onMounted(() => { onMounted(() => {
window.addEventListener('resize', updateBreakpoints) window.addEventListener('resize', updateBreakpoints);
}) });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', updateBreakpoints) window.removeEventListener('resize', updateBreakpoints);
}) });
return { return {
// 简化断点(常用) // 简化断点(常用)
@@ -61,5 +61,5 @@ export function useBreakpoint() {
isLg, isLg,
isXl, isXl,
isXxl, isXxl,
} };
} }
+36 -40
View File
@@ -26,19 +26,19 @@
* ) * )
*/ */
import { ref, onUnmounted } from 'vue' import { ref, onUnmounted } from 'vue';
export function usePollStatus(options = {}) { export function usePollStatus(options = {}) {
const { const {
interval = 2000, // 初始轮询间隔(毫秒) interval = 2000, // 初始轮询间隔(毫秒)
maxRetries = 15, // 最大重试次数 maxRetries = 15, // 最大重试次数
backoff = false, // 是否使用指数退避 backoff = false, // 是否使用指数退避
maxBackoffInterval = 10000 // 最大退避间隔(毫秒) maxBackoffInterval = 10000, // 最大退避间隔(毫秒)
} = options } = options;
const polling = ref(false) const polling = ref(false);
let pollTimer = null let pollTimer = null;
let retryCount = 0 let retryCount = 0;
/** /**
* 开始轮询 * 开始轮询
@@ -49,80 +49,76 @@ export function usePollStatus(options = {}) {
* @param {Function} callbacks.onTimeout - 超时回调 * @param {Function} callbacks.onTimeout - 超时回调
*/ */
const startPolling = async (checkFn, callbacks = {}) => { const startPolling = async (checkFn, callbacks = {}) => {
const { onSuccess, onFailure, onTimeout } = callbacks const { onSuccess, onFailure, onTimeout } = callbacks;
// 重置状态 // 重置状态
stopPolling() stopPolling();
polling.value = true polling.value = true;
retryCount = 0 retryCount = 0;
const poll = async () => { const poll = async () => {
try { try {
const result = await checkFn() const result = await checkFn();
// 检查是否完成 // 检查是否完成
if (result.completed) { if (result.completed) {
stopPolling() stopPolling();
if (result.success) { if (result.success) {
onSuccess?.(result.data || result) onSuccess?.(result.data || result);
} else { } else {
onFailure?.(result.data || result) onFailure?.(result.data || result);
} }
return return;
} }
// 检查是否超时 // 检查是否超时
retryCount++ retryCount++;
if (retryCount >= maxRetries) { if (retryCount >= maxRetries) {
stopPolling() stopPolling();
onTimeout?.() onTimeout?.();
return return;
} }
// 计算下次轮询间隔(支持指数退避) // 计算下次轮询间隔(支持指数退避)
let nextInterval = interval let nextInterval = interval;
if (backoff) { if (backoff) {
// 指数退避:2s -> 4s -> 8s -> 最大10s // 指数退避:2s -> 4s -> 8s -> 最大10s
nextInterval = Math.min( nextInterval = Math.min(interval * Math.pow(2, retryCount - 1), maxBackoffInterval);
interval * Math.pow(2, retryCount - 1),
maxBackoffInterval
)
} }
// 继续轮询 // 继续轮询
pollTimer = setTimeout(poll, nextInterval) pollTimer = setTimeout(poll, nextInterval);
} catch (error) { } catch (error) {
stopPolling() stopPolling();
onFailure?.(error) onFailure?.(error);
}
} }
};
// 立即执行第一次检查 // 立即执行第一次检查
poll() poll();
} };
/** /**
* 停止轮询 * 停止轮询
*/ */
const stopPolling = () => { const stopPolling = () => {
if (pollTimer) { if (pollTimer) {
clearTimeout(pollTimer) clearTimeout(pollTimer);
pollTimer = null pollTimer = null;
}
polling.value = false
retryCount = 0
} }
polling.value = false;
retryCount = 0;
};
// 组件卸载时自动清理 // 组件卸载时自动清理
onUnmounted(() => { onUnmounted(() => {
stopPolling() stopPolling();
}) });
return { return {
polling, polling,
startPolling, startPolling,
stopPolling stopPolling,
} };
} }
+43 -43
View File
@@ -1,22 +1,22 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue';
const THEME_STORAGE_KEY = 'checkin-app-theme' const THEME_STORAGE_KEY = 'checkin-app-theme';
// 全局主题状态(单例模式) // 全局主题状态(单例模式)
const theme = ref('light') const theme = ref('light');
/** /**
* 应用主题到 DOM * 应用主题到 DOM
*/ */
const applyTheme = (newTheme) => { const applyTheme = newTheme => {
const html = document.documentElement const html = document.documentElement;
if (newTheme === 'dark') { if (newTheme === 'dark') {
html.classList.add('dark') html.classList.add('dark');
} else { } else {
html.classList.remove('dark') html.classList.remove('dark');
} }
} };
/** /**
* 初始化主题 * 初始化主题
@@ -24,48 +24,48 @@ const applyTheme = (newTheme) => {
*/ */
export const initTheme = () => { export const initTheme = () => {
// 1. 尝试从 localStorage 读取 // 1. 尝试从 localStorage 读取
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY) const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (savedTheme === 'light' || savedTheme === 'dark') { if (savedTheme === 'light' || savedTheme === 'dark') {
theme.value = savedTheme theme.value = savedTheme;
applyTheme(savedTheme) applyTheme(savedTheme);
return return;
} }
// 2. 检测系统偏好 // 2. 检测系统偏好
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
theme.value = 'dark' theme.value = 'dark';
applyTheme('dark') applyTheme('dark');
return return;
} }
// 3. 默认亮色 // 3. 默认亮色
theme.value = 'light' theme.value = 'light';
applyTheme('light') applyTheme('light');
} };
/** /**
* 监听系统主题变化 * 监听系统主题变化
*/ */
export const watchSystemTheme = () => { export const watchSystemTheme = () => {
if (!window.matchMedia) return if (!window.matchMedia) return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e) => { const handleChange = e => {
// 仅在用户未手动设置主题时才跟随系统 // 仅在用户未手动设置主题时才跟随系统
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY) const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (!savedTheme) { if (!savedTheme) {
const systemTheme = e.matches ? 'dark' : 'light' const systemTheme = e.matches ? 'dark' : 'light';
theme.value = systemTheme theme.value = systemTheme;
applyTheme(systemTheme) applyTheme(systemTheme);
}
} }
};
mediaQuery.addEventListener('change', handleChange) mediaQuery.addEventListener('change', handleChange);
// 返回清理函数 // 返回清理函数
return () => mediaQuery.removeEventListener('change', handleChange) return () => mediaQuery.removeEventListener('change', handleChange);
} };
/** /**
* 主题管理 Composable * 主题管理 Composable
@@ -76,31 +76,31 @@ export function useTheme() {
* 切换主题 * 切换主题
*/ */
const toggleTheme = () => { const toggleTheme = () => {
const newTheme = theme.value === 'light' ? 'dark' : 'light' const newTheme = theme.value === 'light' ? 'dark' : 'light';
theme.value = newTheme theme.value = newTheme;
applyTheme(newTheme) applyTheme(newTheme);
localStorage.setItem(THEME_STORAGE_KEY, newTheme) localStorage.setItem(THEME_STORAGE_KEY, newTheme);
} };
/** /**
* 设置指定主题 * 设置指定主题
*/ */
const setTheme = (newTheme) => { const setTheme = newTheme => {
if (newTheme !== 'light' && newTheme !== 'dark') { if (newTheme !== 'light' && newTheme !== 'dark') {
console.warn(`Invalid theme: ${newTheme}. Using 'light' instead.`) console.warn(`Invalid theme: ${newTheme}. Using 'light' instead.`);
newTheme = 'light' newTheme = 'light';
} }
theme.value = newTheme theme.value = newTheme;
applyTheme(newTheme) applyTheme(newTheme);
localStorage.setItem(THEME_STORAGE_KEY, newTheme) localStorage.setItem(THEME_STORAGE_KEY, newTheme);
} };
return { return {
theme, theme,
toggleTheme, toggleTheme,
setTheme, setTheme,
isDark: computed(() => theme.value === 'dark'), isDark: computed(() => theme.value === 'dark'),
isLight: computed(() => theme.value === 'light') isLight: computed(() => theme.value === 'light'),
} };
} }
+62 -63
View File
@@ -1,8 +1,8 @@
import { ref, computed, onMounted, onUnmounted } from 'vue' import { computed, onMounted } from 'vue';
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue';
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth';
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user';
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router';
/** /**
* Token 过期监控 Composable * Token 过期监控 Composable
@@ -16,49 +16,49 @@ import { useRouter } from 'vue-router'
*/ */
// 全局单例:确保整个应用只有一个监控实例 // 全局单例:确保整个应用只有一个监控实例
let monitorTimer = null let monitorTimer = null;
let warningShown = false let warningShown = false;
let isMonitoring = false // 新增:防止重复启动 let isMonitoring = false; // 新增:防止重复启动
// 检查间隔(毫秒) // 检查间隔(毫秒)
const NORMAL_CHECK_INTERVAL = 15 * 60 * 1000 // 正常情况:15 分钟 const NORMAL_CHECK_INTERVAL = 15 * 60 * 1000; // 正常情况:15 分钟
const URGENT_CHECK_INTERVAL = 5 * 60 * 1000 // Token 即将过期:5 分钟 const URGENT_CHECK_INTERVAL = 5 * 60 * 1000; // Token 即将过期:5 分钟
export function useTokenMonitor() { export function useTokenMonitor() {
const authStore = useAuthStore() const authStore = useAuthStore();
const userStore = useUserStore() const userStore = useUserStore();
const router = useRouter() const router = useRouter();
const tokenStatus = computed(() => userStore.tokenStatus) const tokenStatus = computed(() => userStore.tokenStatus);
const hasPassword = computed(() => authStore.user?.has_password || false) const hasPassword = computed(() => authStore.user?.has_password || false);
// 计算 Token 剩余分钟数 // 计算 Token 剩余分钟数
const getRemainingMinutes = () => { const getRemainingMinutes = () => {
if (!tokenStatus.value?.expires_at) return null if (!tokenStatus.value?.expires_at) return null;
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000);
const expiresAt = tokenStatus.value.expires_at const expiresAt = tokenStatus.value.expires_at;
const diffSeconds = expiresAt - now const diffSeconds = expiresAt - now;
return Math.floor(diffSeconds / 60) return Math.floor(diffSeconds / 60);
} };
// 检查 Token 状态并显示提醒 // 检查 Token 状态并显示提醒
const checkTokenStatus = async () => { const checkTokenStatus = async () => {
// 如果未登录,不检查 // 如果未登录,不检查
if (!authStore.isAuthenticated) { if (!authStore.isAuthenticated) {
return return;
} }
try { try {
// 获取最新的 Token 状态 // 获取最新的 Token 状态
await userStore.fetchTokenStatus() await userStore.fetchTokenStatus();
const remainingMinutes = getRemainingMinutes() const remainingMinutes = getRemainingMinutes();
// Token 已过期(负数分钟) // Token 已过期(负数分钟)
if (remainingMinutes !== null && remainingMinutes < 0) { if (remainingMinutes !== null && remainingMinutes < 0) {
const expiredMinutes = Math.abs(remainingMinutes) const expiredMinutes = Math.abs(remainingMinutes);
// Token 过期后 5 分钟内提醒 // Token 过期后 5 分钟内提醒
if (expiredMinutes <= 5) { if (expiredMinutes <= 5) {
@@ -69,8 +69,8 @@ export function useTokenMonitor() {
content: `您的登录凭证已过期 ${expiredMinutes} 分钟,部分功能可能受限。建议您扫码刷新凭证。`, content: `您的登录凭证已过期 ${expiredMinutes} 分钟,部分功能可能受限。建议您扫码刷新凭证。`,
duration: 8, duration: 8,
key: 'token-expired-warning', key: 'token-expired-warning',
}) });
warningShown = true warningShown = true;
} }
} else { } else {
// 没有密码的用户:必须重新登录 // 没有密码的用户:必须重新登录
@@ -78,18 +78,18 @@ export function useTokenMonitor() {
content: '您的登录凭证已过期,请重新扫码登录', content: '您的登录凭证已过期,请重新扫码登录',
duration: 5, duration: 5,
key: 'token-expired-error', key: 'token-expired-error',
}) });
// 清除登录状态并跳转 // 清除登录状态并跳转
authStore.logout() authStore.logout();
router.push('/login') router.push('/login');
} }
} else if (expiredMinutes > 5) { } else if (expiredMinutes > 5) {
// 过期超过 5 分钟 // 过期超过 5 分钟
if (!hasPassword.value) { if (!hasPassword.value) {
// 没有密码的用户:强制退出 // 没有密码的用户:强制退出
authStore.logout() authStore.logout();
router.push('/login') router.push('/login');
} }
} }
} }
@@ -100,82 +100,81 @@ export function useTokenMonitor() {
content: `您的登录凭证将在 ${remainingMinutes} 分钟后过期,建议您提前刷新`, content: `您的登录凭证将在 ${remainingMinutes} 分钟后过期,建议您提前刷新`,
duration: 6, duration: 6,
key: 'token-expiring-warning', key: 'token-expiring-warning',
}) });
warningShown = true warningShown = true;
} }
// Token 即将过期时,切换到更频繁的检查(5 分钟) // Token 即将过期时,切换到更频繁的检查(5 分钟)
adjustCheckInterval(URGENT_CHECK_INTERVAL) adjustCheckInterval(URGENT_CHECK_INTERVAL);
} }
// Token 状态正常 // Token 状态正常
else if (remainingMinutes !== null && remainingMinutes > 60) { else if (remainingMinutes !== null && remainingMinutes > 60) {
// 重置警告标志 // 重置警告标志
warningShown = false warningShown = false;
// 恢复正常检查频率(15 分钟) // 恢复正常检查频率(15 分钟)
adjustCheckInterval(NORMAL_CHECK_INTERVAL) adjustCheckInterval(NORMAL_CHECK_INTERVAL);
} }
} catch (error) { } catch (error) {
console.error('检查 Token 状态失败:', error) console.error('检查 Token 状态失败:', error);
}
} }
};
// 调整检查间隔 // 调整检查间隔
const adjustCheckInterval = (newInterval) => { const adjustCheckInterval = newInterval => {
if (monitorTimer) { if (monitorTimer) {
const currentInterval = monitorTimer._idleTimeout || 0 const currentInterval = monitorTimer._idleTimeout || 0;
// 只有当新间隔与当前间隔不同时才重启定时器 // 只有当新间隔与当前间隔不同时才重启定时器
if (currentInterval !== newInterval) { if (currentInterval !== newInterval) {
clearInterval(monitorTimer) clearInterval(monitorTimer);
monitorTimer = setInterval(() => { monitorTimer = setInterval(() => {
checkTokenStatus() checkTokenStatus();
}, newInterval) }, newInterval);
}
} }
} }
};
// 启动监控 // 启动监控
const startMonitoring = () => { const startMonitoring = () => {
// 避免重复启动(单例模式) // 避免重复启动(单例模式)
if (isMonitoring || monitorTimer) { if (isMonitoring || monitorTimer) {
return return;
} }
isMonitoring = true isMonitoring = true;
// 立即检查一次 // 立即检查一次
checkTokenStatus() checkTokenStatus();
// 默认使用正常检查频率(15 分钟) // 默认使用正常检查频率(15 分钟)
monitorTimer = setInterval(() => { monitorTimer = setInterval(() => {
checkTokenStatus() checkTokenStatus();
}, NORMAL_CHECK_INTERVAL) }, NORMAL_CHECK_INTERVAL);
} };
// 停止监控 // 停止监控
const stopMonitoring = () => { const stopMonitoring = () => {
if (monitorTimer) { if (monitorTimer) {
clearInterval(monitorTimer) clearInterval(monitorTimer);
monitorTimer = null monitorTimer = null;
}
isMonitoring = false
warningShown = false
} }
isMonitoring = false;
warningShown = false;
};
// 手动触发检查 // 手动触发检查
const checkNow = () => { const checkNow = () => {
warningShown = false // 重置警告标志,允许再次显示 warningShown = false; // 重置警告标志,允许再次显示
checkTokenStatus() checkTokenStatus();
} };
// 组件挂载时启动监控 // 组件挂载时启动监控
onMounted(() => { onMounted(() => {
if (authStore.isAuthenticated) { if (authStore.isAuthenticated) {
startMonitoring() startMonitoring();
} }
}) });
// 组件卸载时不停止监控(因为是全局单例) // 组件卸载时不停止监控(因为是全局单例)
// onUnmounted 中不调用 stopMonitoring(),让监控持续运行 // onUnmounted 中不调用 stopMonitoring(),让监控持续运行
@@ -187,5 +186,5 @@ export function useTokenMonitor() {
stopMonitoring, stopMonitoring,
checkNow, checkNow,
getRemainingMinutes, getRemainingMinutes,
} };
} }
+13 -13
View File
@@ -1,21 +1,21 @@
import { createApp } from 'vue' import { createApp } from 'vue';
import { createPinia } from 'pinia' import { createPinia } from 'pinia';
// Ant Design Vue // Ant Design Vue
import Antd from 'ant-design-vue' import Antd from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css' import 'ant-design-vue/dist/reset.css';
import App from './App.vue' import App from './App.vue';
import router from './router' import router from './router';
import './style.css' import './style.css';
const app = createApp(App) const app = createApp(App);
const pinia = createPinia() const pinia = createPinia();
app.use(pinia) app.use(pinia);
app.use(router) app.use(router);
// Ant Design Vue // Ant Design Vue
app.use(Antd) app.use(Antd);
app.mount('#app') app.mount('#app');
+24 -24
View File
@@ -1,6 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth';
import { userAPI } from '@/api' import { userAPI } from '@/api';
const routes = [ const routes = [
{ {
@@ -91,72 +91,72 @@ const routes = [
component: () => import('@/views/NotFoundView.vue'), component: () => import('@/views/NotFoundView.vue'),
meta: { requiresAuth: false, title: '页面未找到' }, meta: { requiresAuth: false, title: '页面未找到' },
}, },
] ];
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes, routes,
}) });
// 全局前置守卫 // 全局前置守卫
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore() const authStore = useAuthStore();
// 设置页面标题 // 设置页面标题
document.title = to.meta.title ? `${to.meta.title} - 接龙自动打卡系统` : '接龙自动打卡系统' document.title = to.meta.title ? `${to.meta.title} - 接龙自动打卡系统` : '接龙自动打卡系统';
// 检查是否需要认证 // 检查是否需要认证
if (to.meta.requiresAuth) { if (to.meta.requiresAuth) {
if (!authStore.isAuthenticated) { if (!authStore.isAuthenticated) {
// 未登录,重定向到登录页 // 未登录,重定向到登录页
next({ name: 'Login', query: { redirect: to.fullPath } }) next({ name: 'Login', query: { redirect: to.fullPath } });
return return;
} }
// 检查用户审批状态(除了待审批页面本身) // 检查用户审批状态(除了待审批页面本身)
if (to.name !== 'PendingApproval') { if (to.name !== 'PendingApproval') {
try { try {
const status = await userAPI.getUserStatus() const status = await userAPI.getUserStatus();
if (!status.is_approved) { if (!status.is_approved) {
// 未审批用户只能访问待审批页面 // 未审批用户只能访问待审批页面
next({ name: 'PendingApproval' }) next({ name: 'PendingApproval' });
return return;
} }
} catch (error) { } catch (error) {
console.error('检查审批状态失败:', error) console.error('检查审批状态失败:', error);
// 如果检查失败,允许继续访问(避免阻塞正常用户) // 如果检查失败,允许继续访问(避免阻塞正常用户)
} }
} else { } else {
// 访问待审批页面时,检查是否已审批 // 访问待审批页面时,检查是否已审批
try { try {
const status = await userAPI.getUserStatus() const status = await userAPI.getUserStatus();
if (status.is_approved) { if (status.is_approved) {
// 已审批用户不能访问待审批页面 // 已审批用户不能访问待审批页面
next({ name: 'Dashboard' }) next({ name: 'Dashboard' });
return return;
} }
} catch (error) { } catch (error) {
console.error('检查审批状态失败:', error) console.error('检查审批状态失败:', error);
} }
} }
// 检查是否需要管理员权限 // 检查是否需要管理员权限
if (to.meta.requiresAdmin && !authStore.isAdmin) { if (to.meta.requiresAdmin && !authStore.isAdmin) {
// 非管理员,重定向到仪表盘 // 非管理员,重定向到仪表盘
next({ name: 'Dashboard' }) next({ name: 'Dashboard' });
return return;
} }
} else { } else {
// 不需要认证的页面,如果已登录则重定向到仪表盘 // 不需要认证的页面,如果已登录则重定向到仪表盘
if (to.name === 'Login' && authStore.isAuthenticated) { if (to.name === 'Login' && authStore.isAuthenticated) {
next({ name: 'Dashboard' }) next({ name: 'Dashboard' });
return return;
} }
} }
next() next();
}) });
export default router export default router;
+24 -24
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
import { adminAPI } from '@/api' import { adminAPI } from '@/api';
export const useAdminStore = defineStore('admin', { export const useAdminStore = defineStore('admin', {
state: () => ({ state: () => ({
@@ -10,53 +10,53 @@ export const useAdminStore = defineStore('admin', {
}), }),
getters: { getters: {
totalUsers: (state) => state.stats?.users?.total || 0, totalUsers: state => state.stats?.users?.total || 0,
activeUsers: (state) => { activeUsers: state => {
// Active users = 已审批的用户(is_approved=true // Active users = 已审批的用户(is_approved=true
return state.stats?.users?.active || 0 return state.stats?.users?.active || 0;
}, },
totalRecords: (state) => state.stats?.check_in_records?.total || 0, totalRecords: state => state.stats?.check_in_records?.total || 0,
todayRecords: (state) => state.stats?.check_in_records?.today || 0, todayRecords: state => state.stats?.check_in_records?.today || 0,
}, },
actions: { actions: {
// 获取系统统计信息 // 获取系统统计信息
async fetchStats() { async fetchStats() {
this.loading = true this.loading = true;
try { try {
const stats = await adminAPI.getStats() const stats = await adminAPI.getStats();
this.stats = stats this.stats = stats;
return stats return stats;
} catch (error) { } catch (error) {
throw new Error(error.message || '获取统计信息失败') throw new Error(error.message || '获取统计信息失败');
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
// 批量触发打卡 // 批量触发打卡
async batchCheckIn(userIds) { async batchCheckIn(userIds) {
try { try {
const result = await adminAPI.batchCheckIn(userIds) const result = await adminAPI.batchCheckIn(userIds);
return result return result;
} catch (error) { } catch (error) {
throw new Error(error.message || '批量打卡失败') throw new Error(error.message || '批量打卡失败');
} }
}, },
// 获取系统日志 // 获取系统日志
async fetchLogs(params = {}) { async fetchLogs(params = {}) {
this.loading = true this.loading = true;
try { try {
const data = await adminAPI.getLogs(params) const data = await adminAPI.getLogs(params);
this.logs = data.logs || data this.logs = data.logs || data;
this.logsTotal = data.total || this.logs.length this.logsTotal = data.total || this.logs.length;
return data return data;
} catch (error) { } catch (error) {
throw new Error(error.message || '获取日志失败') throw new Error(error.message || '获取日志失败');
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
}, },
}) });
+45 -45
View File
@@ -1,133 +1,133 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
import { authAPI, userAPI } from '@/api' import { authAPI, userAPI } from '@/api';
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
state: () => { state: () => {
// 安全地解析 localStorage 中的用户数据 // 安全地解析 localStorage 中的用户数据
let user = null let user = null;
try { try {
const userStr = localStorage.getItem('user') const userStr = localStorage.getItem('user');
if (userStr && userStr !== 'undefined' && userStr !== 'null') { if (userStr && userStr !== 'undefined' && userStr !== 'null') {
user = JSON.parse(userStr) user = JSON.parse(userStr);
} }
} catch (e) { } catch (e) {
console.warn('Failed to parse user from localStorage:', e) console.warn('Failed to parse user from localStorage:', e);
localStorage.removeItem('user') localStorage.removeItem('user');
} }
return { return {
token: localStorage.getItem('token') || null, token: localStorage.getItem('token') || null,
user, user,
} };
}, },
getters: { getters: {
// 将 isAuthenticated 改为 getter,这样它会实时反应 state.token 的变化 // 将 isAuthenticated 改为 getter,这样它会实时反应 state.token 的变化
isAuthenticated: (state) => !!state.token, isAuthenticated: state => !!state.token,
isAdmin: (state) => state.user?.role === 'admin', isAdmin: state => state.user?.role === 'admin',
}, },
actions: { actions: {
// 设置认证信息 // 设置认证信息
setAuth(token, user) { setAuth(token, user) {
// 清理 token:移除 URL 编码的 Bearer 前缀 // 清理 token:移除 URL 编码的 Bearer 前缀
let cleanToken = token let cleanToken = token;
if (cleanToken) { if (cleanToken) {
// URL 解码 // URL 解码
cleanToken = decodeURIComponent(cleanToken) cleanToken = decodeURIComponent(cleanToken);
// 移除 Bearer 前缀(如果存在) // 移除 Bearer 前缀(如果存在)
if (cleanToken.toLowerCase().startsWith('bearer ')) { if (cleanToken.toLowerCase().startsWith('bearer ')) {
cleanToken = cleanToken.substring(7) cleanToken = cleanToken.substring(7);
} }
} }
this.token = cleanToken this.token = cleanToken;
this.user = user this.user = user;
localStorage.setItem('token', cleanToken) localStorage.setItem('token', cleanToken);
localStorage.setItem('user', JSON.stringify(user)) localStorage.setItem('user', JSON.stringify(user));
}, },
// 清除认证信息 // 清除认证信息
clearAuth() { clearAuth() {
this.token = null this.token = null;
this.user = null this.user = null;
localStorage.removeItem('token') localStorage.removeItem('token');
localStorage.removeItem('user') localStorage.removeItem('user');
}, },
// QR 码登录流程 // QR 码登录流程
async loginWithQRCode(alias) { async loginWithQRCode(alias) {
try { try {
// 1. 请求 QR 码 // 1. 请求 QR 码
const qrData = await authAPI.requestQRCode(alias) const qrData = await authAPI.requestQRCode(alias);
const { session_id, qrcode_base64 } = qrData const { session_id, qrcode_base64 } = qrData;
// 2. 返回 session_id 和 qrcode,由组件处理轮询 // 2. 返回 session_id 和 qrcode,由组件处理轮询
return { session_id, qrcode_base64 } return { session_id, qrcode_base64 };
} catch (error) { } catch (error) {
throw new Error(error.message || '请求二维码失败') throw new Error(error.message || '请求二维码失败');
} }
}, },
// 检查扫码状态 // 检查扫码状态
async checkQRCodeStatus(sessionId) { async checkQRCodeStatus(sessionId) {
try { try {
const result = await authAPI.getQRCodeStatus(sessionId) const result = await authAPI.getQRCodeStatus(sessionId);
if (result.status === 'success') { if (result.status === 'success') {
// 扫码成功,保存 Token 和用户信息 // 扫码成功,保存 Token 和用户信息
this.setAuth(result.token, result.user) this.setAuth(result.token, result.user);
return { success: true, user: result.user } return { success: true, user: result.user };
} else if (result.status === 'failed') { } else if (result.status === 'failed') {
return { success: false, message: result.message } return { success: false, message: result.message };
} else { } else {
// pending 或 expired // pending 或 expired
return { success: false, status: result.status } return { success: false, status: result.status };
} }
} catch (error) { } catch (error) {
throw new Error(error.message || '检查扫码状态失败') throw new Error(error.message || '检查扫码状态失败');
} }
}, },
// 取消扫码会话 // 取消扫码会话
async cancelQRCodeSession(sessionId) { async cancelQRCodeSession(sessionId) {
try { try {
await authAPI.cancelQRCodeSession(sessionId) await authAPI.cancelQRCodeSession(sessionId);
} catch (error) { } catch (error) {
console.error('取消会话失败:', error) console.error('取消会话失败:', error);
} }
}, },
// 验证 Token // 验证 Token
async verifyToken(token) { async verifyToken(token) {
try { try {
const userData = await authAPI.verifyToken(token) const userData = await authAPI.verifyToken(token);
this.setAuth(token, userData) this.setAuth(token, userData);
return userData return userData;
} catch (error) { } catch (error) {
this.clearAuth() this.clearAuth();
throw new Error(error.message || 'Token 验证失败') throw new Error(error.message || 'Token 验证失败');
} }
}, },
// 获取当前用户信息 // 获取当前用户信息
async fetchCurrentUser() { async fetchCurrentUser() {
try { try {
const userData = await userAPI.getCurrentUser() const userData = await userAPI.getCurrentUser();
// 更新本地用户信息 // 更新本地用户信息
this.user = userData this.user = userData;
localStorage.setItem('user', JSON.stringify(userData)) localStorage.setItem('user', JSON.stringify(userData));
return userData return userData;
} catch (error) { } catch (error) {
throw new Error(error.message || '获取用户信息失败') throw new Error(error.message || '获取用户信息失败');
} }
}, },
// 登出 // 登出
logout() { logout() {
this.clearAuth() this.clearAuth();
}, },
}, },
}) });
+36 -36
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
import { checkInAPI } from '@/api' import { checkInAPI } from '@/api';
export const useCheckInStore = defineStore('checkIn', { export const useCheckInStore = defineStore('checkIn', {
state: () => ({ state: () => ({
@@ -12,83 +12,83 @@ export const useCheckInStore = defineStore('checkIn', {
}), }),
getters: { getters: {
todayRecords: (state) => { todayRecords: state => {
const today = new Date().toISOString().split('T')[0] const today = new Date().toISOString().split('T')[0];
return state.myRecords.filter((record) => { return state.myRecords.filter(record => {
const recordDate = new Date(record.check_in_time).toISOString().split('T')[0] const recordDate = new Date(record.check_in_time).toISOString().split('T')[0];
return recordDate === today return recordDate === today;
}) });
}, },
successRate: (state) => { successRate: state => {
if (state.myRecords.length === 0) return 0 if (state.myRecords.length === 0) return 0;
const successCount = state.myRecords.filter((r) => r.status === 'success').length const successCount = state.myRecords.filter(r => r.status === 'success').length;
return ((successCount / state.myRecords.length) * 100).toFixed(2) return ((successCount / state.myRecords.length) * 100).toFixed(2);
}, },
}, },
actions: { actions: {
// 手动打卡 // 手动打卡
async manualCheckIn() { async manualCheckIn() {
this.loading = true this.loading = true;
try { try {
const result = await checkInAPI.manualCheckIn() const result = await checkInAPI.manualCheckIn();
// 刷新打卡记录 // 刷新打卡记录
await this.fetchMyRecords() await this.fetchMyRecords();
return result return result;
} catch (error) { } catch (error) {
throw new Error(error.message || '打卡失败') throw new Error(error.message || '打卡失败');
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
// 获取我的打卡记录 // 获取我的打卡记录
async fetchMyRecords(params = {}) { async fetchMyRecords(params = {}) {
this.loading = true this.loading = true;
try { try {
const data = await checkInAPI.getMyRecords({ const data = await checkInAPI.getMyRecords({
skip: (this.currentPage - 1) * this.pageSize, skip: (this.currentPage - 1) * this.pageSize,
limit: this.pageSize, limit: this.pageSize,
...params, ...params,
}) });
this.myRecords = data.records || data this.myRecords = data.records || data;
this.total = data.total || this.myRecords.length this.total = data.total || this.myRecords.length;
return data return data;
} catch (error) { } catch (error) {
throw new Error(error.message || '获取打卡记录失败') throw new Error(error.message || '获取打卡记录失败');
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
// 获取所有打卡记录(管理员) // 获取所有打卡记录(管理员)
async fetchAllRecords(params = {}) { async fetchAllRecords(params = {}) {
this.loading = true this.loading = true;
try { try {
const data = await checkInAPI.getAllRecords({ const data = await checkInAPI.getAllRecords({
skip: (this.currentPage - 1) * this.pageSize, skip: (this.currentPage - 1) * this.pageSize,
limit: this.pageSize, limit: this.pageSize,
...params, ...params,
}) });
this.allRecords = data.records || data this.allRecords = data.records || data;
this.total = data.total || this.allRecords.length this.total = data.total || this.allRecords.length;
return data return data;
} catch (error) { } catch (error) {
throw new Error(error.message || '获取打卡记录失败') throw new Error(error.message || '获取打卡记录失败');
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
// 统计打卡记录 // 统计打卡记录
async getRecordsCount(params = {}) { async getRecordsCount(params = {}) {
try { try {
const count = await checkInAPI.getRecordsCount(params) const count = await checkInAPI.getRecordsCount(params);
return count return count;
} catch (error) { } catch (error) {
throw new Error(error.message || '获取统计信息失败') throw new Error(error.message || '获取统计信息失败');
} }
}, },
}, },
}) });
+76 -79
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
import api from '@/api' import api from '@/api';
export const useTaskStore = defineStore('task', { export const useTaskStore = defineStore('task', {
state: () => ({ state: () => ({
@@ -11,176 +11,173 @@ export const useTaskStore = defineStore('task', {
getters: { getters: {
// 启用的任务 // 启用的任务
activeTasks: (state) => state.tasks.filter(t => t.is_active), activeTasks: state => state.tasks.filter(t => t.is_active),
// 禁用的任务 // 禁用的任务
inactiveTasks: (state) => state.tasks.filter(t => !t.is_active), inactiveTasks: state => state.tasks.filter(t => !t.is_active),
// 任务数量统计 // 任务数量统计
taskStats: (state) => ({ taskStats: state => ({
total: state.tasks.length, total: state.tasks.length,
active: state.tasks.filter(t => t.is_active).length, active: state.tasks.filter(t => t.is_active).length,
inactive: state.tasks.filter(t => !t.is_active).length, inactive: state.tasks.filter(t => !t.is_active).length,
}), }),
// 根据 ID 获取任务 // 根据 ID 获取任务
getTaskById: (state) => (taskId) => { getTaskById: state => taskId => {
return state.tasks.find(t => t.id === taskId) return state.tasks.find(t => t.id === taskId);
}, },
}, },
actions: { actions: {
// 获取当前用户的所有任务 // 获取当前用户的所有任务
async fetchMyTasks(includeInactive = true) { async fetchMyTasks(includeInactive = true) {
this.loading = true this.loading = true;
this.error = null this.error = null;
try { try {
const tasks = await api.task.getMyTasks({ include_inactive: includeInactive }) const tasks = await api.task.getMyTasks({ include_inactive: includeInactive });
this.tasks = tasks this.tasks = tasks;
return tasks return tasks;
} catch (error) { } catch (error) {
this.error = error.message || '获取任务列表失败' this.error = error.message || '获取任务列表失败';
throw error throw error;
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
// 创建新任务 // 创建新任务
async createTask(taskData) { async createTask(taskData) {
this.loading = true this.loading = true;
this.error = null this.error = null;
try { try {
const newTask = await api.task.createTask(taskData) const newTask = await api.task.createTask(taskData);
this.tasks.unshift(newTask) // 添加到列表开头 this.tasks.unshift(newTask); // 添加到列表开头
return newTask return newTask;
} catch (error) { } catch (error) {
// 解析后端错误信息 // 解析后端错误信息
let errorMsg = error.message || '创建任务失败' let errorMsg = error.message || '创建任务失败';
this.error = errorMsg this.error = errorMsg;
throw new Error(errorMsg) throw new Error(errorMsg);
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
// 更新任务 // 更新任务
async updateTask(taskId, taskData) { async updateTask(taskId, taskData) {
this.loading = true this.loading = true;
this.error = null this.error = null;
try { try {
const updatedTask = await api.task.updateTask(taskId, taskData) const updatedTask = await api.task.updateTask(taskId, taskData);
const index = this.tasks.findIndex(t => t.id === taskId) const index = this.tasks.findIndex(t => t.id === taskId);
if (index !== -1) { if (index !== -1) {
this.tasks[index] = updatedTask this.tasks[index] = updatedTask;
} }
return updatedTask return updatedTask;
} catch (error) { } catch (error) {
this.error = error.message || '更新任务失败' this.error = error.message || '更新任务失败';
throw error throw error;
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
// 删除任务 // 删除任务
async deleteTask(taskId) { async deleteTask(taskId) {
this.loading = true this.loading = true;
this.error = null this.error = null;
try { try {
await api.task.deleteTask(taskId) await api.task.deleteTask(taskId);
this.tasks = this.tasks.filter(t => t.id !== taskId) this.tasks = this.tasks.filter(t => t.id !== taskId);
} catch (error) { } catch (error) {
this.error = error.message || '删除任务失败' this.error = error.message || '删除任务失败';
throw error throw error;
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
// 切换任务启用状态 // 切换任务启用状态
async toggleTask(taskId) { async toggleTask(taskId) {
this.loading = true this.loading = true;
this.error = null this.error = null;
try { try {
const updatedTask = await api.task.toggleTask(taskId) const updatedTask = await api.task.toggleTask(taskId);
const index = this.tasks.findIndex(t => t.id === taskId) const index = this.tasks.findIndex(t => t.id === taskId);
if (index !== -1) { if (index !== -1) {
// 保留原任务的 last_check_in_time 和 last_check_in_status // 保留原任务的 last_check_in_time 和 last_check_in_status
const originalTask = this.tasks[index] const originalTask = this.tasks[index];
this.tasks[index] = { this.tasks[index] = {
...updatedTask, ...updatedTask,
last_check_in_time: updatedTask.last_check_in_time || originalTask.last_check_in_time, 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, last_check_in_status:
updatedTask.last_check_in_status || originalTask.last_check_in_status,
};
} }
} return updatedTask;
return updatedTask
} catch (error) { } catch (error) {
this.error = error.message || '切换任务状态失败' this.error = error.message || '切换任务状态失败';
throw error throw error;
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
// 获取任务详情 // 获取任务详情
async fetchTask(taskId) { async fetchTask(taskId) {
this.loading = true this.loading = true;
this.error = null this.error = null;
try { try {
const task = await api.task.getTask(taskId) const task = await api.task.getTask(taskId);
this.currentTask = task this.currentTask = task;
return task return task;
} catch (error) { } catch (error) {
this.error = error.message || '获取任务详情失败' this.error = error.message || '获取任务详情失败';
throw error throw error;
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
// 手动触发任务打卡(异步方式,立即返回 record_id) // 手动触发任务打卡(异步方式,立即返回 record_id)
async checkInTask(taskId) { async checkInTask(taskId) {
// Don't set global loading state to avoid blocking UI during long check-in operations // Don't set global loading state to avoid blocking UI during long check-in operations
this.error = null this.error = null;
try { try {
const result = await api.task.checkInTask(taskId) const result = await api.task.checkInTask(taskId);
return result return result;
} catch (error) { } catch (error) {
this.error = error.message || '打卡失败' this.error = error.message || '打卡失败';
throw error throw error;
} }
}, },
// 查询打卡记录状态 // 查询打卡记录状态
async getCheckInRecordStatus(recordId) { async getCheckInRecordStatus(recordId) {
try { const result = await api.task.getCheckInRecordStatus(recordId);
const result = await api.task.getCheckInRecordStatus(recordId) return result;
return result
} catch (error) {
throw error
}
}, },
// 获取任务的打卡记录 // 获取任务的打卡记录
async fetchTaskRecords(taskId, params = {}) { async fetchTaskRecords(taskId, params = {}) {
this.loading = true this.loading = true;
this.error = null this.error = null;
try { try {
const records = await api.task.getTaskRecords(taskId, params) const records = await api.task.getTaskRecords(taskId, params);
return records return records;
} catch (error) { } catch (error) {
this.error = error.message || '获取打卡记录失败' this.error = error.message || '获取打卡记录失败';
throw error throw error;
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
// 清空当前任务 // 清空当前任务
clearCurrentTask() { clearCurrentTask() {
this.currentTask = null this.currentTask = null;
}, },
}, },
}) });
+72 -72
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
import { templateAPI } from '@/api' import { templateAPI } from '@/api';
export const useTemplateStore = defineStore('template', { export const useTemplateStore = defineStore('template', {
state: () => ({ state: () => ({
@@ -10,153 +10,153 @@ export const useTemplateStore = defineStore('template', {
}), }),
getters: { getters: {
activeTemplates: (state) => state.templates.filter((t) => t.is_active), activeTemplates: state => state.templates.filter(t => t.is_active),
getTemplateById: (state) => (id) => { getTemplateById: state => id => {
return state.templates.find((t) => t.id === id) return state.templates.find(t => t.id === id);
}, },
}, },
actions: { actions: {
async fetchTemplates(isActive = null) { async fetchTemplates(isActive = null) {
this.loading = true this.loading = true;
this.error = null this.error = null;
try { try {
const params = {} const params = {};
if (isActive !== null) { if (isActive !== null) {
params.is_active = isActive params.is_active = isActive;
} }
this.templates = await templateAPI.getTemplates(params) this.templates = await templateAPI.getTemplates(params);
return this.templates return this.templates;
} catch (error) { } catch (error) {
this.error = error.message || '获取模板列表失败' this.error = error.message || '获取模板列表失败';
throw error throw error;
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
async fetchActiveTemplates() { async fetchActiveTemplates() {
this.loading = true this.loading = true;
this.error = null this.error = null;
try { try {
this.templates = await templateAPI.getActiveTemplates() this.templates = await templateAPI.getActiveTemplates();
return this.templates return this.templates;
} catch (error) { } catch (error) {
this.error = error.message || '获取启用模板失败' this.error = error.message || '获取启用模板失败';
throw error throw error;
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
async fetchTemplate(id) { async fetchTemplate(id) {
this.loading = true this.loading = true;
this.error = null this.error = null;
try { try {
this.currentTemplate = await templateAPI.getTemplate(id) this.currentTemplate = await templateAPI.getTemplate(id);
return this.currentTemplate return this.currentTemplate;
} catch (error) { } catch (error) {
this.error = error.message || '获取模板详情失败' this.error = error.message || '获取模板详情失败';
throw error throw error;
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
async previewTemplate(id) { async previewTemplate(id) {
this.loading = true this.loading = true;
this.error = null this.error = null;
try { try {
const preview = await templateAPI.previewTemplate(id) const preview = await templateAPI.previewTemplate(id);
return preview return preview;
} catch (error) { } catch (error) {
this.error = error.message || '预览模板失败' this.error = error.message || '预览模板失败';
throw error throw error;
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
async createTemplate(templateData) { async createTemplate(templateData) {
this.loading = true this.loading = true;
this.error = null this.error = null;
try { try {
const newTemplate = await templateAPI.createTemplate(templateData) const newTemplate = await templateAPI.createTemplate(templateData);
this.templates.unshift(newTemplate) this.templates.unshift(newTemplate);
return newTemplate return newTemplate;
} catch (error) { } catch (error) {
this.error = error.message || '创建模板失败' this.error = error.message || '创建模板失败';
throw error throw error;
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
async updateTemplate(id, templateData) { async updateTemplate(id, templateData) {
this.loading = true this.loading = true;
this.error = null this.error = null;
try { try {
const updatedTemplate = await templateAPI.updateTemplate(id, templateData) const updatedTemplate = await templateAPI.updateTemplate(id, templateData);
const index = this.templates.findIndex((t) => t.id === id) const index = this.templates.findIndex(t => t.id === id);
if (index !== -1) { if (index !== -1) {
this.templates[index] = updatedTemplate this.templates[index] = updatedTemplate;
} }
if (this.currentTemplate && this.currentTemplate.id === id) { if (this.currentTemplate && this.currentTemplate.id === id) {
this.currentTemplate = updatedTemplate this.currentTemplate = updatedTemplate;
} }
return updatedTemplate return updatedTemplate;
} catch (error) { } catch (error) {
this.error = error.message || '更新模板失败' this.error = error.message || '更新模板失败';
throw error throw error;
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
async deleteTemplate(id) { async deleteTemplate(id) {
this.loading = true this.loading = true;
this.error = null this.error = null;
try { try {
await templateAPI.deleteTemplate(id) await templateAPI.deleteTemplate(id);
this.templates = this.templates.filter((t) => t.id !== id) this.templates = this.templates.filter(t => t.id !== id);
if (this.currentTemplate && this.currentTemplate.id === id) { if (this.currentTemplate && this.currentTemplate.id === id) {
this.currentTemplate = null this.currentTemplate = null;
} }
return true return true;
} catch (error) { } catch (error) {
this.error = error.message || '删除模板失败' this.error = error.message || '删除模板失败';
throw error throw error;
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
async createTaskFromTemplate(templateId, threadId, fieldValues, taskName = null) { async createTaskFromTemplate(templateId, threadId, fieldValues, taskName = null) {
this.loading = true this.loading = true;
this.error = null this.error = null;
try { try {
const task = await templateAPI.createTaskFromTemplate({ const task = await templateAPI.createTaskFromTemplate({
template_id: templateId, template_id: templateId,
thread_id: threadId, thread_id: threadId,
field_values: fieldValues, field_values: fieldValues,
task_name: taskName, task_name: taskName,
}) });
return task return task;
} catch (error) { } catch (error) {
this.error = error.message || '从模板创建任务失败' this.error = error.message || '从模板创建任务失败';
throw error throw error;
} finally { } finally {
this.loading = false this.loading = false;
} }
}, },
clearCurrentTemplate() { clearCurrentTemplate() {
this.currentTemplate = null this.currentTemplate = null;
}, },
clearError() { clearError() {
this.error = null this.error = null;
}, },
}, },
}) });
+36 -32
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
import { userAPI } from '@/api' import { userAPI } from '@/api';
export const useUserStore = defineStore('user', { export const useUserStore = defineStore('user', {
state: () => ({ state: () => ({
@@ -11,14 +11,14 @@ export const useUserStore = defineStore('user', {
}), }),
getters: { getters: {
isTokenExpiring: (state) => { isTokenExpiring: state => {
if (!state.tokenStatus) return false if (!state.tokenStatus) return false;
return state.tokenStatus.expiring_soon || false return state.tokenStatus.expiring_soon || false;
}, },
tokenExpireTime: (state) => { tokenExpireTime: state => {
if (!state.tokenStatus || !state.tokenStatus.expires_at) return null if (!state.tokenStatus || !state.tokenStatus.expires_at) return null;
return new Date(state.tokenStatus.expires_at * 1000) return new Date(state.tokenStatus.expires_at * 1000);
}, },
}, },
@@ -26,35 +26,35 @@ export const useUserStore = defineStore('user', {
// 获取 Token 状态 // 获取 Token 状态
async fetchTokenStatus() { async fetchTokenStatus() {
try { try {
const status = await userAPI.getTokenStatus() const status = await userAPI.getTokenStatus();
this.tokenStatus = status this.tokenStatus = status;
return status return status;
} catch (error) { } catch (error) {
throw new Error(error.message || '获取 Token 状态失败') throw new Error(error.message || '获取 Token 状态失败');
} }
}, },
// 获取用户列表(管理员) // 获取用户列表(管理员)
async fetchUsers(params = {}) { async fetchUsers(params = {}) {
try { try {
const data = await userAPI.getUsers(params) const data = await userAPI.getUsers(params);
this.users = data.users || data this.users = data.users || data;
this.total = data.total || this.users.length this.total = data.total || this.users.length;
return data return data;
} catch (error) { } catch (error) {
throw new Error(error.message || '获取用户列表失败') throw new Error(error.message || '获取用户列表失败');
} }
}, },
// 创建用户(管理员) // 创建用户(管理员)
async createUser(userData) { async createUser(userData) {
try { try {
const newUser = await userAPI.createUser(userData) const newUser = await userAPI.createUser(userData);
// 刷新用户列表 // 刷新用户列表
await this.fetchUsers() await this.fetchUsers();
return newUser return newUser;
} catch (error) { } catch (error) {
throw new Error(error.message || '创建用户失败') throw new Error(error.message || '创建用户失败');
} }
}, },
@@ -62,29 +62,33 @@ export const useUserStore = defineStore('user', {
async updateUser(userId, userData) { async updateUser(userId, userData) {
try { try {
// 过滤空密码字段 // 过滤空密码字段
const cleanedData = { ...userData } const cleanedData = { ...userData };
if (cleanedData.password === '' || cleanedData.password === null || cleanedData.password === undefined) { if (
delete cleanedData.password cleanedData.password === '' ||
cleanedData.password === null ||
cleanedData.password === undefined
) {
delete cleanedData.password;
} }
const updatedUser = await userAPI.updateUser(userId, cleanedData) const updatedUser = await userAPI.updateUser(userId, cleanedData);
// 刷新用户列表 // 刷新用户列表
await this.fetchUsers() await this.fetchUsers();
return updatedUser return updatedUser;
} catch (error) { } catch (error) {
throw new Error(error.message || '更新用户失败') throw new Error(error.message || '更新用户失败');
} }
}, },
// 删除用户 // 删除用户
async deleteUser(userId) { async deleteUser(userId) {
try { try {
await userAPI.deleteUser(userId) await userAPI.deleteUser(userId);
// 刷新用户列表 // 刷新用户列表
await this.fetchUsers() await this.fetchUsers();
} catch (error) { } catch (error) {
throw new Error(error.message || '删除用户失败') throw new Error(error.message || '删除用户失败');
} }
}, },
}, },
}) });
+82 -26
View File
@@ -15,7 +15,12 @@
@layer base { @layer base {
:root { :root {
/* === MD3 Typography === */ /* === MD3 Typography === */
font-family: 'Roboto', 'Inter', system-ui, -apple-system, sans-serif; font-family:
'Roboto',
'Inter',
system-ui,
-apple-system,
sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color-scheme: light; color-scheme: light;
@@ -130,7 +135,8 @@
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), transition:
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
color 0.3s cubic-bezier(0.4, 0, 0.2, 1); color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
@@ -140,7 +146,12 @@
} }
/* === Typography Styles === */ /* === Typography Styles === */
h1, h2, h3, h4, h5, h6 { h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 400; font-weight: 400;
line-height: 1.2; line-height: 1.2;
color: var(--md-sys-color-on-surface); color: var(--md-sys-color-on-surface);
@@ -270,14 +281,16 @@
background-color: var(--md-sys-color-surface-container-low); background-color: var(--md-sys-color-surface-container-low);
color: var(--md-sys-color-on-surface); color: var(--md-sys-color-on-surface);
border-radius: 12px; border-radius: 12px;
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.1), box-shadow:
0px 1px 2px 0px rgba(0, 0, 0, 0.1),
0px 1px 3px 1px rgba(0, 0, 0, 0.05); 0px 1px 3px 1px rgba(0, 0, 0, 0.05);
overflow: hidden; overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
.md3-card:hover { .md3-card:hover {
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.15), box-shadow:
0px 1px 3px 0px rgba(0, 0, 0, 0.15),
0px 4px 8px 3px rgba(0, 0, 0, 0.08); 0px 4px 8px 3px rgba(0, 0, 0, 0.08);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -307,18 +320,21 @@
@apply md3-button; @apply md3-button;
background-color: var(--md-sys-color-primary); background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary); color: var(--md-sys-color-on-primary);
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.1), box-shadow:
0px 1px 2px 0px rgba(0, 0, 0, 0.1),
0px 1px 3px 1px rgba(0, 0, 0, 0.05); 0px 1px 3px 1px rgba(0, 0, 0, 0.05);
} }
.md3-button-filled:hover { .md3-button-filled:hover {
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.15), box-shadow:
0px 1px 3px 0px rgba(0, 0, 0, 0.15),
0px 4px 8px 3px rgba(0, 0, 0, 0.08); 0px 4px 8px 3px rgba(0, 0, 0, 0.08);
transform: translateY(-1px); transform: translateY(-1px);
} }
.md3-button-filled:active { .md3-button-filled:active {
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.1), box-shadow:
0px 1px 2px 0px rgba(0, 0, 0, 0.1),
0px 1px 3px 1px rgba(0, 0, 0, 0.05); 0px 1px 3px 1px rgba(0, 0, 0, 0.05);
transform: translateY(0); transform: translateY(0);
} }
@@ -495,13 +511,15 @@
/* === Card === */ /* === Card === */
.ant-card { .ant-card {
border-radius: 12px; border-radius: 12px;
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.1), box-shadow:
0px 1px 2px 0px rgba(0, 0, 0, 0.1),
0px 1px 3px 1px rgba(0, 0, 0, 0.05); 0px 1px 3px 1px rgba(0, 0, 0, 0.05);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
.ant-card:hover { .ant-card:hover {
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.15), box-shadow:
0px 1px 3px 0px rgba(0, 0, 0, 0.15),
0px 4px 8px 3px rgba(0, 0, 0, 0.08); 0px 4px 8px 3px rgba(0, 0, 0, 0.08);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -537,12 +555,14 @@
} }
.ant-btn-primary { .ant-btn-primary {
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.1), box-shadow:
0px 1px 2px 0px rgba(0, 0, 0, 0.1),
0px 1px 3px 1px rgba(0, 0, 0, 0.05); 0px 1px 3px 1px rgba(0, 0, 0, 0.05);
} }
.ant-btn-primary:hover { .ant-btn-primary:hover {
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.15), box-shadow:
0px 1px 3px 0px rgba(0, 0, 0, 0.15),
0px 4px 8px 3px rgba(0, 0, 0, 0.08); 0px 4px 8px 3px rgba(0, 0, 0, 0.08);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -672,8 +692,9 @@
/* === Dropdown === */ /* === Dropdown === */
.ant-dropdown-menu { .ant-dropdown-menu {
border-radius: 12px; border-radius: 12px;
box-shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.15), box-shadow:
0px 6px 10px 4px rgba(0, 0, 0, 0.10); 0px 2px 3px 0px rgba(0, 0, 0, 0.15),
0px 6px 10px 4px rgba(0, 0, 0, 0.1);
} }
.ant-dropdown-menu-item { .ant-dropdown-menu-item {
@@ -715,12 +736,22 @@
} }
/* 只对包含 ant-descriptions-item-content 的 td 应用白色背景 */ /* 只对包含 ant-descriptions-item-content 的 td 应用白色背景 */
.ant-descriptions.ant-descriptions-bordered .ant-descriptions-view table tbody tr td:has(.ant-descriptions-item-content) { .ant-descriptions.ant-descriptions-bordered
.ant-descriptions-view
table
tbody
tr
td:has(.ant-descriptions-item-content) {
background-color: var(--md-sys-color-surface) !important; background-color: var(--md-sys-color-surface) !important;
} }
/* 对包含 ant-descriptions-item-label 的 td 应用灰色背景(跨列的 label) */ /* 对包含 ant-descriptions-item-label 的 td 应用灰色背景(跨列的 label) */
.ant-descriptions.ant-descriptions-bordered .ant-descriptions-view table tbody tr td:has(.ant-descriptions-item-label) { .ant-descriptions.ant-descriptions-bordered
.ant-descriptions-view
table
tbody
tr
td:has(.ant-descriptions-item-label) {
background-color: var(--md-sys-color-surface-container) !important; background-color: var(--md-sys-color-surface-container) !important;
} }
@@ -736,22 +767,42 @@
} }
/* 圆角 - 第一行第一个 th(左上角)*/ /* 圆角 - 第一行第一个 th(左上角)*/
.ant-descriptions.ant-descriptions-bordered .ant-descriptions-view table tbody tr:first-child th:first-child { .ant-descriptions.ant-descriptions-bordered
.ant-descriptions-view
table
tbody
tr:first-child
th:first-child {
border-top-left-radius: 7px !important; border-top-left-radius: 7px !important;
} }
/* 圆角 - 第一行最后一个 td(右上角)*/ /* 圆角 - 第一行最后一个 td(右上角)*/
.ant-descriptions.ant-descriptions-bordered .ant-descriptions-view table tbody tr:first-child td:last-child { .ant-descriptions.ant-descriptions-bordered
.ant-descriptions-view
table
tbody
tr:first-child
td:last-child {
border-top-right-radius: 7px !important; border-top-right-radius: 7px !important;
} }
/* 圆角 - 最后一行第一个 th(左下角)*/ /* 圆角 - 最后一行第一个 th(左下角)*/
.ant-descriptions.ant-descriptions-bordered .ant-descriptions-view table tbody tr:last-child th:first-child { .ant-descriptions.ant-descriptions-bordered
.ant-descriptions-view
table
tbody
tr:last-child
th:first-child {
border-bottom-left-radius: 7px !important; border-bottom-left-radius: 7px !important;
} }
/* 圆角 - 最后一行最后一个 td(右下角)*/ /* 圆角 - 最后一行最后一个 td(右下角)*/
.ant-descriptions.ant-descriptions-bordered .ant-descriptions-view table tbody tr:last-child td:last-child { .ant-descriptions.ant-descriptions-bordered
.ant-descriptions-view
table
tbody
tr:last-child
td:last-child {
border-bottom-right-radius: 7px !important; border-bottom-right-radius: 7px !important;
} }
} }
@@ -944,27 +995,32 @@
} }
.elevation-1 { .elevation-1 {
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.1), box-shadow:
0px 1px 2px 0px rgba(0, 0, 0, 0.1),
0px 1px 3px 1px rgba(0, 0, 0, 0.05); 0px 1px 3px 1px rgba(0, 0, 0, 0.05);
} }
.elevation-2 { .elevation-2 {
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.12), box-shadow:
0px 1px 2px 0px rgba(0, 0, 0, 0.12),
0px 2px 6px 2px rgba(0, 0, 0, 0.08); 0px 2px 6px 2px rgba(0, 0, 0, 0.08);
} }
.elevation-3 { .elevation-3 {
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.15), box-shadow:
0px 1px 3px 0px rgba(0, 0, 0, 0.15),
0px 4px 8px 3px rgba(0, 0, 0, 0.08); 0px 4px 8px 3px rgba(0, 0, 0, 0.08);
} }
.elevation-4 { .elevation-4 {
box-shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.15), box-shadow:
0px 6px 10px 4px rgba(0, 0, 0, 0.10); 0px 2px 3px 0px rgba(0, 0, 0, 0.15),
0px 6px 10px 4px rgba(0, 0, 0, 0.1);
} }
.elevation-5 { .elevation-5 {
box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.18), box-shadow:
0px 4px 4px 0px rgba(0, 0, 0, 0.18),
0px 8px 12px 6px rgba(0, 0, 0, 0.12); 0px 8px 12px 6px rgba(0, 0, 0, 0.12);
} }
+62 -62
View File
@@ -5,24 +5,24 @@
* @returns {string} * @returns {string}
*/ */
export function formatDateTime(date, includeTime = true) { export function formatDateTime(date, includeTime = true) {
if (!date) return '-' if (!date) return '-';
const d = new Date(date) const d = new Date(date);
if (isNaN(d.getTime())) return '-' if (isNaN(d.getTime())) return '-';
const year = d.getFullYear() const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0') const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0');
if (!includeTime) { if (!includeTime) {
return `${year}-${month}-${day}` return `${year}-${month}-${day}`;
} }
const hours = String(d.getHours()).padStart(2, '0') const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0') const minutes = String(d.getMinutes()).padStart(2, '0');
const seconds = String(d.getSeconds()).padStart(2, '0') const seconds = String(d.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
} }
/** /**
@@ -31,25 +31,25 @@ export function formatDateTime(date, includeTime = true) {
* @returns {string} * @returns {string}
*/ */
export function formatRelativeTime(date) { export function formatRelativeTime(date) {
if (!date) return '-' if (!date) return '-';
const d = new Date(date) const d = new Date(date);
if (isNaN(d.getTime())) return '-' if (isNaN(d.getTime())) return '-';
const now = new Date() const now = new Date();
const diff = now - d // 毫秒差 const diff = now - d; // 毫秒差
const seconds = Math.floor(diff / 1000) const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60) const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60) const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24) const days = Math.floor(hours / 24);
if (seconds < 60) return '刚刚' if (seconds < 60) return '刚刚';
if (minutes < 60) return `${minutes} 分钟前` if (minutes < 60) return `${minutes} 分钟前`;
if (hours < 24) return `${hours} 小时前` if (hours < 24) return `${hours} 小时前`;
if (days < 7) return `${days} 天前` if (days < 7) return `${days} 天前`;
return formatDateTime(date, false) return formatDateTime(date, false);
} }
/** /**
@@ -58,13 +58,13 @@ export function formatRelativeTime(date) {
* @returns {string} * @returns {string}
*/ */
export function formatFileSize(bytes) { export function formatFileSize(bytes) {
if (!bytes || bytes === 0) return '0 B' if (!bytes || bytes === 0) return '0 B';
const k = 1024 const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k)) const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}` return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
} }
/** /**
@@ -74,13 +74,13 @@ export function formatFileSize(bytes) {
* @returns {Function} * @returns {Function}
*/ */
export function debounce(fn, delay = 300) { export function debounce(fn, delay = 300) {
let timer = null let timer = null;
return function (...args) { return function (...args) {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer);
timer = setTimeout(() => { timer = setTimeout(() => {
fn.apply(this, args) fn.apply(this, args);
}, delay) }, delay);
} };
} }
/** /**
@@ -90,23 +90,23 @@ export function debounce(fn, delay = 300) {
* @returns {Function} * @returns {Function}
*/ */
export function throttle(fn, delay = 300) { export function throttle(fn, delay = 300) {
let timer = null let timer = null;
let lastTime = 0 let lastTime = 0;
return function (...args) { return function (...args) {
const now = Date.now() const now = Date.now();
if (now - lastTime < delay) { if (now - lastTime < delay) {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer);
timer = setTimeout(() => { timer = setTimeout(() => {
lastTime = now lastTime = now;
fn.apply(this, args) fn.apply(this, args);
}, delay) }, delay);
} else { } else {
lastTime = now lastTime = now;
fn.apply(this, args) fn.apply(this, args);
}
} }
};
} }
/** /**
@@ -117,29 +117,29 @@ export function throttle(fn, delay = 300) {
export async function copyToClipboard(text) { export async function copyToClipboard(text) {
try { try {
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text);
return true return true;
} else { } else {
// 降级方案 // 降级方案
const textArea = document.createElement('textarea') const textArea = document.createElement('textarea');
textArea.value = text textArea.value = text;
textArea.style.position = 'fixed' textArea.style.position = 'fixed';
textArea.style.left = '-999999px' textArea.style.left = '-999999px';
document.body.appendChild(textArea) document.body.appendChild(textArea);
textArea.focus() textArea.focus();
textArea.select() textArea.select();
try { try {
document.execCommand('copy') document.execCommand('copy');
textArea.remove() textArea.remove();
return true return true;
} catch (error) { } catch (error) {
console.error('复制失败', error) console.error('复制失败', error);
textArea.remove() textArea.remove();
return false return false;
} }
} }
} catch (error) { } catch (error) {
console.error('复制失败', error) console.error('复制失败', error);
return false return false;
} }
} }
+104 -98
View File
@@ -29,16 +29,17 @@
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="剩余时间"> <a-descriptions-item label="剩余时间">
<a-tag v-if="tokenStatus.is_valid" :color="tokenStatus.expiring_soon ? 'warning' : 'success'"> <a-tag
v-if="tokenStatus.is_valid"
:color="tokenStatus.expiring_soon ? 'warning' : 'success'"
>
{{ formatRemainTime }} {{ formatRemainTime }}
</a-tag> </a-tag>
<a-tag v-else color="error">已过期</a-tag> <a-tag v-else color="error">已过期</a-tag>
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="即将过期"> <a-descriptions-item label="即将过期">
<a-tag v-if="!tokenStatus.is_valid" color="error"> <a-tag v-if="!tokenStatus.is_valid" color="error"> 已过期 </a-tag>
已过期
</a-tag>
<a-tag v-else :color="tokenStatus.expiring_soon ? 'warning' : 'success'"> <a-tag v-else :color="tokenStatus.expiring_soon ? 'warning' : 'success'">
{{ tokenStatus.expiring_soon ? '是' : '否' }} {{ tokenStatus.expiring_soon ? '是' : '否' }}
</a-tag> </a-tag>
@@ -78,11 +79,7 @@
:loading="taskStore.loading" :loading="taskStore.loading"
style="width: 100%; max-width: 400px; margin-bottom: 20px" style="width: 100%; max-width: 400px; margin-bottom: 20px"
> >
<a-select-option <a-select-option v-for="task in taskStore.tasks" :key="task.id" :value="task.id">
v-for="task in taskStore.tasks"
:key="task.id"
:value="task.id"
>
<div style="display: flex; justify-content: space-between; align-items: center"> <div style="display: flex; justify-content: space-between; align-items: center">
<span>{{ task.name }}</span> <span>{{ task.name }}</span>
<a-tag size="small" :color="task.is_active ? 'success' : 'default'"> <a-tag size="small" :color="task.is_active ? 'success' : 'default'">
@@ -112,14 +109,24 @@
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="状态"> <a-descriptions-item label="状态">
<a-tag <a-tag
:color="lastCheckIn.status === 'success' ? 'success' : :color="
lastCheckIn.status === 'out_of_time' ? 'default' : lastCheckIn.status === 'success'
lastCheckIn.status === 'unknown' ? 'warning' : 'error'" ? 'success'
: lastCheckIn.status === 'out_of_time'
? 'default'
: lastCheckIn.status === 'unknown'
? 'warning'
: 'error'
"
> >
{{ {{
lastCheckIn.status === 'success' ? '成功' : lastCheckIn.status === 'success'
lastCheckIn.status === 'out_of_time' ? '时间范围外' : ? '成功'
lastCheckIn.status === 'unknown' ? '异常' : '失败' : lastCheckIn.status === 'out_of_time'
? '时间范围外'
: lastCheckIn.status === 'unknown'
? '异常'
: '失败'
}} }}
</a-tag> </a-tag>
</a-descriptions-item> </a-descriptions-item>
@@ -166,161 +173,160 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue';
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue';
import { CalendarOutlined, KeyOutlined, UserOutlined } from '@ant-design/icons-vue' import { CalendarOutlined, KeyOutlined, UserOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue' import Layout from '@/components/Layout.vue';
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth';
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user';
import { useTaskStore } from '@/stores/task' import { useTaskStore } from '@/stores/task';
import { useCheckInStore } from '@/stores/checkIn' import { useCheckInStore } from '@/stores/checkIn';
import { formatDateTime } from '@/utils/helpers' import { formatDateTime } from '@/utils/helpers';
import { usePollStatus } from '@/composables/usePollStatus' import { usePollStatus } from '@/composables/usePollStatus';
const authStore = useAuthStore() const authStore = useAuthStore();
const userStore = useUserStore() const userStore = useUserStore();
const taskStore = useTaskStore() const taskStore = useTaskStore();
const checkInStore = useCheckInStore() const checkInStore = useCheckInStore();
// 使用轮询 composable // 使用轮询 composable
const { startPolling } = usePollStatus({ const { startPolling } = usePollStatus({
interval: 2000, // 每 2 秒轮询一次 interval: 2000, // 每 2 秒轮询一次
maxRetries: 15, // 最多 15 次 (30 秒) maxRetries: 15, // 最多 15 次 (30 秒)
backoff: false // 不使用指数退避 backoff: false, // 不使用指数退避
}) });
const tokenStatusLoading = ref(false) const tokenStatusLoading = ref(false);
const checkInLoading = ref(false) const checkInLoading = ref(false);
const selectedTaskId = ref(null) const selectedTaskId = ref(null);
const tokenStatus = computed(() => userStore.tokenStatus) const tokenStatus = computed(() => userStore.tokenStatus);
const lastCheckIn = computed(() => { const lastCheckIn = computed(() => {
if (checkInStore.myRecords.length > 0) { if (checkInStore.myRecords.length > 0) {
return checkInStore.myRecords[0] return checkInStore.myRecords[0];
} }
return null return null;
}) });
const formatExpireTime = computed(() => { const formatExpireTime = computed(() => {
if (!tokenStatus.value || !tokenStatus.value.expires_at) return '-' if (!tokenStatus.value || !tokenStatus.value.expires_at) return '-';
return formatDateTime(tokenStatus.value.expires_at * 1000) return formatDateTime(tokenStatus.value.expires_at * 1000);
}) });
const formatRemainTime = computed(() => { const formatRemainTime = computed(() => {
if (!tokenStatus.value || !tokenStatus.value.expires_at) return '-' if (!tokenStatus.value || !tokenStatus.value.expires_at) return '-';
const now = Date.now() const now = Date.now();
const expireTime = tokenStatus.value.expires_at * 1000 const expireTime = tokenStatus.value.expires_at * 1000;
const diff = expireTime - now const diff = expireTime - now;
if (diff <= 0) return '已过期' if (diff <= 0) return '已过期';
const days = Math.floor(diff / (1000 * 60 * 60 * 24)) const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)) const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (days > 0) return `${days}${hours} 小时` if (days > 0) return `${days}${hours} 小时`;
if (hours > 0) return `${hours} 小时 ${minutes} 分钟` if (hours > 0) return `${hours} 小时 ${minutes} 分钟`;
return `${minutes} 分钟` return `${minutes} 分钟`;
}) });
// 获取 Token 状态 // 获取 Token 状态
const fetchTokenStatus = async () => { const fetchTokenStatus = async () => {
tokenStatusLoading.value = true tokenStatusLoading.value = true;
try { try {
await userStore.fetchTokenStatus() await userStore.fetchTokenStatus();
} catch (error) { } catch (error) {
message.error(error.message || '获取 Token 状态失败') message.error(error.message || '获取 Token 状态失败');
} finally { } finally {
tokenStatusLoading.value = false tokenStatusLoading.value = false;
} }
} };
// 手动打卡 // 手动打卡
const handleCheckIn = async () => { const handleCheckIn = async () => {
if (!selectedTaskId.value) { if (!selectedTaskId.value) {
message.warning('请先选择要打卡的任务') message.warning('请先选择要打卡的任务');
return return;
} }
checkInLoading.value = true checkInLoading.value = true;
try { try {
// 调用异步打卡接口,立即返回 record_id // 调用异步打卡接口,立即返回 record_id
const result = await taskStore.checkInTask(selectedTaskId.value) const result = await taskStore.checkInTask(selectedTaskId.value);
// 获取 record_id // 获取 record_id
const recordId = result.record_id const recordId = result.record_id;
if (!recordId) { if (!recordId) {
message.error('打卡请求失败:未获取到记录ID') message.error('打卡请求失败:未获取到记录ID');
checkInLoading.value = false checkInLoading.value = false;
return return;
} }
// 如果初始状态就是失败,显示错误并刷新记录 // 如果初始状态就是失败,显示错误并刷新记录
if (result.status === 'failure') { if (result.status === 'failure') {
message.error(result.message || '打卡失败') message.error(result.message || '打卡失败');
checkInLoading.value = false checkInLoading.value = false;
checkInStore.fetchMyRecords({ limit: 1 }) checkInStore.fetchMyRecords({ limit: 1 });
return return;
} }
// 显示提示消息 // 显示提示消息
message.info('打卡任务已启动,正在后台处理...') message.info('打卡任务已启动,正在后台处理...');
// 使用轮询 composable 检查打卡状态 // 使用轮询 composable 检查打卡状态
startPolling( startPolling(
async () => { async () => {
const status = await taskStore.getCheckInRecordStatus(recordId) const status = await taskStore.getCheckInRecordStatus(recordId);
return { return {
completed: status.status !== 'pending', completed: status.status !== 'pending',
success: status.status === 'success', success: status.status === 'success',
data: status data: status,
} };
}, },
{ {
onSuccess: () => { onSuccess: () => {
checkInLoading.value = false checkInLoading.value = false;
message.success('打卡成功!') message.success('打卡成功!');
checkInStore.fetchMyRecords({ limit: 1 }) checkInStore.fetchMyRecords({ limit: 1 });
}, },
onFailure: (statusData) => { onFailure: statusData => {
checkInLoading.value = false checkInLoading.value = false;
const errorMsg = statusData.error_message || statusData.response_text || '打卡失败' const errorMsg = statusData.error_message || statusData.response_text || '打卡失败';
message.error(errorMsg) message.error(errorMsg);
checkInStore.fetchMyRecords({ limit: 1 }) checkInStore.fetchMyRecords({ limit: 1 });
}, },
onTimeout: () => { onTimeout: () => {
checkInLoading.value = false checkInLoading.value = false;
message.warning('打卡处理时间较长,请稍后查看打卡记录') message.warning('打卡处理时间较长,请稍后查看打卡记录');
},
} }
} );
)
} catch (error) { } catch (error) {
console.error('启动打卡失败:', error) console.error('启动打卡失败:', error);
checkInLoading.value = false checkInLoading.value = false;
message.error(error.message || '启动打卡任务失败') message.error(error.message || '启动打卡任务失败');
} }
} };
onMounted(async () => { onMounted(async () => {
fetchTokenStatus() fetchTokenStatus();
checkInStore.fetchMyRecords({ limit: 1 }) checkInStore.fetchMyRecords({ limit: 1 });
// 加载任务列表 // 加载任务列表
try { try {
await taskStore.fetchMyTasks() await taskStore.fetchMyTasks();
// 如果只有一个任务,自动选中(优先选择启用的任务) // 如果只有一个任务,自动选中(优先选择启用的任务)
if (taskStore.activeTasks.length === 1) { if (taskStore.activeTasks.length === 1) {
selectedTaskId.value = taskStore.activeTasks[0].id selectedTaskId.value = taskStore.activeTasks[0].id;
} else if (taskStore.tasks.length === 1) { } else if (taskStore.tasks.length === 1) {
selectedTaskId.value = taskStore.tasks[0].id selectedTaskId.value = taskStore.tasks[0].id;
} }
} catch (error) { } catch (error) {
message.error(error.message || '加载任务列表失败') message.error(error.message || '加载任务列表失败');
} }
}) });
</script> </script>
<style scoped> <style scoped>
+75 -75
View File
@@ -6,7 +6,9 @@
<template #title> <template #title>
<div class="card-header"> <div class="card-header">
<h2>接龙自动打卡系统</h2> <h2>接龙自动打卡系统</h2>
<p class="subtitle">{{ loginMode === 'qrcode' ? 'QQ 扫码登录/注册' : '用户名密码登录' }}</p> <p class="subtitle">
{{ loginMode === 'qrcode' ? 'QQ 扫码登录/注册' : '用户名密码登录' }}
</p>
</div> </div>
</template> </template>
@@ -18,9 +20,9 @@
<!-- QR码登录表单 --> <!-- QR码登录表单 -->
<a-form <a-form
v-if="loginMode === 'qrcode'" v-if="loginMode === 'qrcode'"
ref="qrcodeFormRef"
:model="qrcodeForm" :model="qrcodeForm"
:rules="qrcodeRules" :rules="qrcodeRules"
ref="qrcodeFormRef"
layout="vertical" layout="vertical"
@submit.prevent="handleQRCodeLogin" @submit.prevent="handleQRCodeLogin"
> >
@@ -54,9 +56,9 @@
<!-- 别名+密码登录表单 --> <!-- 别名+密码登录表单 -->
<a-form <a-form
v-else v-else
ref="passwordFormRef"
:model="passwordForm" :model="passwordForm"
:rules="passwordRules" :rules="passwordRules"
ref="passwordFormRef"
layout="vertical" layout="vertical"
> >
<a-form-item name="alias"> <a-form-item name="alias">
@@ -98,9 +100,7 @@
</a-form-item> </a-form-item>
<div class="tips-link"> <div class="tips-link">
<a @click="loginMode = 'qrcode'" class="link-text"> <a class="link-text" @click="loginMode = 'qrcode'"> 没有密码使用扫码登录 </a>
没有密码使用扫码登录
</a>
</div> </div>
</a-form> </a-form>
@@ -142,59 +142,59 @@
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue';
import { UserOutlined, KeyOutlined } from '@ant-design/icons-vue' import { UserOutlined, KeyOutlined } from '@ant-design/icons-vue';
import { authAPI } from '@/api' import { authAPI } from '@/api';
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth';
import QRCodeModal from '@/components/QRCodeModal.vue' import QRCodeModal from '@/components/QRCodeModal.vue';
const router = useRouter() const router = useRouter();
const route = useRoute() const route = useRoute();
const authStore = useAuthStore() const authStore = useAuthStore();
const qrcodeFormRef = ref(null) const qrcodeFormRef = ref(null);
const passwordFormRef = ref(null) const passwordFormRef = ref(null);
const loading = ref(false) const loading = ref(false);
const qrcodeVisible = ref(false) const qrcodeVisible = ref(false);
// 登录模式 // 登录模式
const loginMode = ref('qrcode') const loginMode = ref('qrcode');
const loginModeOptions = [ const loginModeOptions = [
{ label: '扫码登录', value: 'qrcode' }, { label: '扫码登录', value: 'qrcode' },
{ label: '密码登录', value: 'password' } { label: '密码登录', value: 'password' },
] ];
// 监听登录模式切换,同步用户名 // 监听登录模式切换,同步用户名
watch(loginMode, () => { watch(loginMode, () => {
// 从密码登录切换到扫码登录 // 从密码登录切换到扫码登录
if (loginMode.value === 'qrcode' && passwordForm.value.alias) { if (loginMode.value === 'qrcode' && passwordForm.value.alias) {
qrcodeForm.value.alias = passwordForm.value.alias qrcodeForm.value.alias = passwordForm.value.alias;
} }
// 从扫码登录切换到密码登录 // 从扫码登录切换到密码登录
else if (loginMode.value === 'password' && qrcodeForm.value.alias) { else if (loginMode.value === 'password' && qrcodeForm.value.alias) {
passwordForm.value.alias = qrcodeForm.value.alias passwordForm.value.alias = qrcodeForm.value.alias;
} }
}) });
// QR码登录表单 // QR码登录表单
const qrcodeForm = ref({ const qrcodeForm = ref({
alias: '', alias: '',
}) });
const qrcodeRules = { const qrcodeRules = {
alias: [ alias: [
{ required: true, message: '请输入用户名', trigger: 'blur' }, { required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }, { min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
], ],
} };
// 密码登录表单 // 密码登录表单
const passwordForm = ref({ const passwordForm = ref({
alias: '', alias: '',
password: '', password: '',
}) });
const passwordRules = { const passwordRules = {
alias: [ alias: [
@@ -205,34 +205,34 @@ const passwordRules = {
{ required: true, message: '请输入密码', trigger: 'blur' }, { required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6个字符', trigger: 'blur' }, { min: 6, message: '密码至少6个字符', trigger: 'blur' },
], ],
} };
// QR码登录 // QR码登录
const handleQRCodeLogin = async () => { const handleQRCodeLogin = async () => {
if (!qrcodeFormRef.value) return if (!qrcodeFormRef.value) return;
try { try {
await qrcodeFormRef.value.validate() await qrcodeFormRef.value.validate();
// 显示 QR 码弹窗 // 显示 QR 码弹窗
qrcodeVisible.value = true qrcodeVisible.value = true;
} catch (error) { } catch {
// 表单验证失败,不需要打印错误(由 Ant Design 自动显示错误提示) // 表单验证失败,不需要打印错误(由 Ant Design 自动显示错误提示)
} }
} };
// 密码登录 // 密码登录
const handlePasswordLogin = async () => { const handlePasswordLogin = async () => {
if (!passwordFormRef.value) return if (!passwordFormRef.value) return;
try { try {
await passwordFormRef.value.validate() await passwordFormRef.value.validate();
loading.value = true loading.value = true;
const response = await authAPI.aliasLogin( const response = await authAPI.aliasLogin(
passwordForm.value.alias, passwordForm.value.alias,
passwordForm.value.password passwordForm.value.password
) );
if (response.success) { if (response.success) {
// 使用 authStore 保存认证信息 // 使用 authStore 保存认证信息
@@ -241,18 +241,18 @@ const handlePasswordLogin = async () => {
alias: response.alias, alias: response.alias,
role: response.role || 'user', role: response.role || 'user',
is_approved: response.is_approved !== false, is_approved: response.is_approved !== false,
} };
// 如果没有 authorization(测试账号),使用 user_id 作为认证凭据 // 如果没有 authorization(测试账号),使用 user_id 作为认证凭据
const authToken = response.authorization || `user_id:${response.user_id}` const authToken = response.authorization || `user_id:${response.user_id}`;
authStore.setAuth(authToken, user) authStore.setAuth(authToken, user);
// 只有当有真实 authorization 时才获取完整用户信息 // 只有当有真实 authorization 时才获取完整用户信息
if (response.authorization) { if (response.authorization) {
try { try {
await authStore.fetchCurrentUser() await authStore.fetchCurrentUser();
} catch (err) { } catch (err) {
console.warn('获取完整用户信息失败,使用基本信息:', err) console.warn('获取完整用户信息失败,使用基本信息:', err);
// 即使失败也继续登录流程 // 即使失败也继续登录流程
} }
} else { } else {
@@ -260,7 +260,7 @@ const handlePasswordLogin = async () => {
message.info({ message.info({
content: '您正在使用密码登录模式。如需使用打卡功能,请先扫码绑定 QQ。', content: '您正在使用密码登录模式。如需使用打卡功能,请先扫码绑定 QQ。',
duration: 5, duration: 5,
}) });
} }
// 如果有 Token 警告,显示提示 // 如果有 Token 警告,显示提示
@@ -268,71 +268,71 @@ const handlePasswordLogin = async () => {
message.warning({ message.warning({
content: response.warning_message, content: response.warning_message,
duration: 5, duration: 5,
}) });
} else if (response.authorization) { } else if (response.authorization) {
// 只有有 token 的用户才显示"欢迎回来" // 只有有 token 的用户才显示"欢迎回来"
message.success(`欢迎回来,${response.alias}`) message.success(`欢迎回来,${response.alias}`);
} else { } else {
// 测试账号登录成功提示 // 测试账号登录成功提示
message.success(`登录成功,${response.alias}`) message.success(`登录成功,${response.alias}`);
} }
// 跳转到重定向页面或仪表盘 // 跳转到重定向页面或仪表盘
const redirect = route.query.redirect || '/dashboard' const redirect = route.query.redirect || '/dashboard';
router.push(redirect) router.push(redirect);
} else { } else {
// 根据不同错误类型提供友好提示 // 根据不同错误类型提供友好提示
handlePasswordLoginError(response.message) handlePasswordLoginError(response.message);
} }
} catch (error) { } catch (error) {
console.error('密码登录失败:', error) console.error('密码登录失败:', error);
const errorMsg = error.response?.data?.detail || error.message || '登录失败,请稍后重试' const errorMsg = error.response?.data?.detail || error.message || '登录失败,请稍后重试';
handlePasswordLoginError(errorMsg) handlePasswordLoginError(errorMsg);
} finally { } finally {
loading.value = false loading.value = false;
} }
} };
// 处理密码登录错误 // 处理密码登录错误
const handlePasswordLoginError = (msg) => { const handlePasswordLoginError = msg => {
if (!msg) { if (!msg) {
message.error('登录失败,请稍后重试') message.error('登录失败,请稍后重试');
return return;
} }
// 用户不存在或密码错误 // 用户不存在或密码错误
if (msg.includes('用户名或密码错误')) { if (msg.includes('用户名或密码错误')) {
message.error('用户名或密码错误') message.error('用户名或密码错误');
return return;
} }
// 未设置密码 // 未设置密码
if (msg.includes('未设置密码')) { if (msg.includes('未设置密码')) {
message.warning('该账户未设置密码,请使用扫码登录') message.warning('该账户未设置密码,请使用扫码登录');
return return;
} }
// 用户不存在 // 用户不存在
if (msg.includes('用户不存在')) { if (msg.includes('用户不存在')) {
message.error('用户不存在,请检查用户名或使用扫码登录注册') message.error('用户不存在,请检查用户名或使用扫码登录注册');
return return;
} }
// 其他错误 // 其他错误
message.error(msg || '登录失败,请稍后重试') message.error(msg || '登录失败,请稍后重试');
} };
const handleLoginSuccess = (user) => { const handleLoginSuccess = user => {
message.success(`欢迎回来,${user.alias}`) message.success(`欢迎回来,${user.alias}`);
// 跳转到重定向页面或仪表盘 // 跳转到重定向页面或仪表盘
const redirect = route.query.redirect || '/dashboard' const redirect = route.query.redirect || '/dashboard';
router.push(redirect) router.push(redirect);
} };
const handleLoginError = (error) => { const handleLoginError = error => {
message.error(error.message || '登录失败') message.error(error.message || '登录失败');
} };
</script> </script>
<style scoped> <style scoped>
+4 -4
View File
@@ -9,13 +9,13 @@
</template> </template>
<script setup> <script setup>
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router';
const router = useRouter() const router = useRouter();
const goHome = () => { const goHome = () => {
router.push('/') router.push('/');
} };
</script> </script>
<style scoped> <style scoped>
+73 -83
View File
@@ -44,13 +44,7 @@
</a-descriptions-item> </a-descriptions-item>
</a-descriptions> </a-descriptions>
<a-alert <a-alert message="⚠️ 审批说明" type="info" :closable="false" show-icon class="mb-6">
message="⚠️ 审批说明"
type="info"
:closable="false"
show-icon
class="mb-6"
>
<template #description> <template #description>
<ul class="tips-list"> <ul class="tips-list">
<li>管理员将在 <strong>24 小时内</strong> 审核您的注册申请</li> <li>管理员将在 <strong>24 小时内</strong> 审核您的注册申请</li>
@@ -84,17 +78,13 @@
v-model:open="showProfileModal" v-model:open="showProfileModal"
title="完善个人信息" title="完善个人信息"
:confirm-loading="profileLoading" :confirm-loading="profileLoading"
width="500px"
@ok="handleUpdateProfile" @ok="handleUpdateProfile"
@cancel="resetProfileForm" @cancel="resetProfileForm"
width="500px"
> >
<a-form :model="profileForm" layout="vertical"> <a-form :model="profileForm" layout="vertical">
<a-form-item label="邮箱地址(可选)" name="email"> <a-form-item label="邮箱地址(可选)" name="email">
<a-input <a-input v-model:value="profileForm.email" placeholder="用于接收审批通知" type="email" />
v-model:value="profileForm.email"
placeholder="用于接收审批通知"
type="email"
/>
<div class="form-hint">建议设置邮箱方便接收审批结果通知</div> <div class="form-hint">建议设置邮箱方便接收审批结果通知</div>
</a-form-item> </a-form-item>
@@ -110,11 +100,7 @@
/> />
</a-form-item> </a-form-item>
<a-form-item <a-form-item v-if="profileForm.new_password" label="确认密码" name="confirm_password">
v-if="profileForm.new_password"
label="确认密码"
name="confirm_password"
>
<a-input-password <a-input-password
v-model:value="profileForm.confirm_password" v-model:value="profileForm.confirm_password"
placeholder="再次输入新密码" placeholder="再次输入新密码"
@@ -139,118 +125,122 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue';
import { ReloadOutlined, LogoutOutlined, SettingOutlined } from '@ant-design/icons-vue' import { ReloadOutlined, LogoutOutlined, SettingOutlined } from '@ant-design/icons-vue';
import { userAPI } from '@/api' import { userAPI } from '@/api';
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth';
const router = useRouter() const router = useRouter();
const authStore = useAuthStore() const authStore = useAuthStore();
const user = ref(null) const user = ref(null);
const showProfileModal = ref(false) const showProfileModal = ref(false);
const profileLoading = ref(false) const profileLoading = ref(false);
const profileForm = ref({ const profileForm = ref({
email: '', email: '',
new_password: '', new_password: '',
confirm_password: '', confirm_password: '',
current_password: '', current_password: '',
}) });
const checkStatus = async () => { const checkStatus = async () => {
try { try {
const response = await userAPI.getUserStatus() const response = await userAPI.getUserStatus();
user.value = response user.value = response;
if (response.is_approved) { if (response.is_approved) {
message.success('恭喜!您的账户已通过审批') message.success('恭喜!您的账户已通过审批');
router.push('/dashboard') router.push('/dashboard');
} else { } else {
message.info('仍在等待审批中') message.info('仍在等待审批中');
} }
} catch (error) { } catch (error) {
console.error('获取状态失败:', error) console.error('获取状态失败:', error);
message.error('获取状态失败:' + (error.message || '未知错误')) message.error('获取状态失败:' + (error.message || '未知错误'));
} }
} };
const loadUserInfo = async () => { const loadUserInfo = async () => {
try { try {
const response = await userAPI.getCurrentUser() const response = await userAPI.getCurrentUser();
user.value = response user.value = response;
// 初始化表单 // 初始化表单
profileForm.value.email = response.email || '' profileForm.value.email = response.email || '';
} catch (error) { } catch (error) {
console.error('加载用户信息失败:', error) console.error('加载用户信息失败:', error);
} }
} };
const handleUpdateProfile = async () => { const handleUpdateProfile = async () => {
// 验证 // 验证
if (profileForm.value.new_password && profileForm.value.new_password.length < 6) { if (profileForm.value.new_password && profileForm.value.new_password.length < 6) {
message.error('密码至少需要 6 位字符') message.error('密码至少需要 6 位字符');
return return;
} }
if (profileForm.value.new_password !== profileForm.value.confirm_password) { if (profileForm.value.new_password !== profileForm.value.confirm_password) {
message.error('两次输入的密码不一致') message.error('两次输入的密码不一致');
return return;
} }
if (user.value?.has_password && profileForm.value.new_password && !profileForm.value.current_password) { if (
message.error('修改密码时需要提供当前密码') user.value?.has_password &&
return profileForm.value.new_password &&
!profileForm.value.current_password
) {
message.error('修改密码时需要提供当前密码');
return;
} }
profileLoading.value = true profileLoading.value = true;
try { try {
const updateData = {} const updateData = {};
// 只提交有变化的字段 // 只提交有变化的字段
if (profileForm.value.email !== (user.value?.email || '')) { if (profileForm.value.email !== (user.value?.email || '')) {
updateData.email = profileForm.value.email || null updateData.email = profileForm.value.email || null;
} }
if (profileForm.value.new_password) { if (profileForm.value.new_password) {
updateData.new_password = profileForm.value.new_password updateData.new_password = profileForm.value.new_password;
if (user.value?.has_password) { if (user.value?.has_password) {
updateData.current_password = profileForm.value.current_password updateData.current_password = profileForm.value.current_password;
} }
} }
// 如果没有要更新的字段 // 如果没有要更新的字段
if (Object.keys(updateData).length === 0) { if (Object.keys(updateData).length === 0) {
message.info('没有需要更新的信息') message.info('没有需要更新的信息');
showProfileModal.value = false showProfileModal.value = false;
return return;
} }
await userAPI.updateProfile(updateData) await userAPI.updateProfile(updateData);
message.success('个人信息更新成功') message.success('个人信息更新成功');
showProfileModal.value = false showProfileModal.value = false;
resetProfileForm() resetProfileForm();
// 重新加载用户信息 // 重新加载用户信息
await loadUserInfo() await loadUserInfo();
// 如果设置了密码,更新本地存储的用户信息 // 如果设置了密码,更新本地存储的用户信息
if (updateData.new_password) { if (updateData.new_password) {
const currentUser = authStore.user const currentUser = authStore.user;
if (currentUser) { if (currentUser) {
currentUser.has_password = true currentUser.has_password = true;
localStorage.setItem('user', JSON.stringify(currentUser)) localStorage.setItem('user', JSON.stringify(currentUser));
} }
} }
} catch (error) { } catch (error) {
console.error('更新个人信息失败:', error) console.error('更新个人信息失败:', error);
message.error(error.message || '更新失败,请重试') message.error(error.message || '更新失败,请重试');
} finally { } finally {
profileLoading.value = false profileLoading.value = false;
} }
} };
const resetProfileForm = () => { const resetProfileForm = () => {
profileForm.value = { profileForm.value = {
@@ -258,24 +248,24 @@ const resetProfileForm = () => {
new_password: '', new_password: '',
confirm_password: '', confirm_password: '',
current_password: '', current_password: '',
} };
} };
const logout = () => { const logout = () => {
authStore.logout() authStore.logout();
router.push('/login') router.push('/login');
} };
const formatDate = (dateStr) => { const formatDate = dateStr => {
if (!dateStr) return '未知' if (!dateStr) return '未知';
const date = new Date(dateStr) const date = new Date(dateStr);
return date.toLocaleString('zh-CN') return date.toLocaleString('zh-CN');
} };
onMounted(() => { onMounted(() => {
loadUserInfo() loadUserInfo();
checkStatus() checkStatus();
}) });
</script> </script>
<style scoped> <style scoped>
+34 -30
View File
@@ -44,7 +44,7 @@
<!-- 桌面端表格 --> <!-- 桌面端表格 -->
<a-table <a-table
v-if="!isMobile" v-if="!isMobile"
:dataSource="checkInStore.myRecords" :data-source="checkInStore.myRecords"
:columns="columns" :columns="columns"
:loading="checkInStore.loading" :loading="checkInStore.loading"
:pagination="false" :pagination="false"
@@ -58,7 +58,9 @@
</template> </template>
<template v-else-if="column.key === 'status'"> <template v-else-if="column.key === 'status'">
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag> <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 === 'out_of_time'" color="default"
>🕐 时间范围外</a-tag
>
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag> <a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
<a-tag v-else color="error">❌ 打卡失败</a-tag> <a-tag v-else color="error">❌ 打卡失败</a-tag>
</template> </template>
@@ -86,7 +88,9 @@
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="状态"> <a-descriptions-item label="状态">
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag> <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 === 'out_of_time'" color="default"
>🕐 时间范围外</a-tag
>
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag> <a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
<a-tag v-else color="error">❌ 打卡失败</a-tag> <a-tag v-else color="error">❌ 打卡失败</a-tag>
</a-descriptions-item> </a-descriptions-item>
@@ -107,14 +111,14 @@
<div class="pagination-container"> <div class="pagination-container">
<a-pagination <a-pagination
v-model:current="checkInStore.currentPage" v-model:current="checkInStore.currentPage"
v-model:pageSize="checkInStore.pageSize" v-model:page-size="checkInStore.pageSize"
:total="total" :total="total"
:pageSizeOptions="['10', '20', '50', '100']" :page-size-options="['10', '20', '50', '100']"
show-size-changer show-size-changer
show-quick-jumper show-quick-jumper
:show-total="total => `${total} 条记录`" :show-total="total => `${total} 条记录`"
@change="handlePageChange" @change="handlePageChange"
@showSizeChange="handleSizeChange" @show-size-change="handleSizeChange"
/> />
</div> </div>
</a-card> </a-card>
@@ -123,22 +127,22 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { computed, onMounted } from 'vue';
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue';
import { UnorderedListOutlined, ReloadOutlined } from '@ant-design/icons-vue' import { UnorderedListOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue' import Layout from '@/components/Layout.vue';
import { useBreakpoint } from '@/composables/useBreakpoint' import { useBreakpoint } from '@/composables/useBreakpoint';
import { useCheckInStore } from '@/stores/checkIn' import { useCheckInStore } from '@/stores/checkIn';
import { formatDateTime } from '@/utils/helpers' import { formatDateTime } from '@/utils/helpers';
const checkInStore = useCheckInStore() const checkInStore = useCheckInStore();
const { isMobile } = useBreakpoint() const { isMobile } = useBreakpoint();
const total = computed(() => checkInStore.total) const total = computed(() => checkInStore.total);
const successCount = computed(() => { const successCount = computed(() => {
return checkInStore.myRecords.filter((r) => r.status === 'success').length return checkInStore.myRecords.filter(r => r.status === 'success').length;
}) });
// 表格列配置 // 表格列配置
const columns = [ const columns = [
@@ -172,32 +176,32 @@ const columns = [
key: 'response_text', key: 'response_text',
ellipsis: true, ellipsis: true,
}, },
] ];
// 刷新数据 // 刷新数据
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
await checkInStore.fetchMyRecords() await checkInStore.fetchMyRecords();
message.success('刷新成功') message.success('刷新成功');
} catch (error) { } catch (error) {
message.error(error.message || '刷新失败') message.error(error.message || '刷新失败');
} }
} };
// 页码改变 // 页码改变
const handlePageChange = () => { const handlePageChange = () => {
checkInStore.fetchMyRecords() checkInStore.fetchMyRecords();
} };
// 每页数量改变 // 每页数量改变
const handleSizeChange = () => { const handleSizeChange = () => {
checkInStore.currentPage = 1 checkInStore.currentPage = 1;
checkInStore.fetchMyRecords() checkInStore.fetchMyRecords();
} };
onMounted(() => { onMounted(() => {
checkInStore.fetchMyRecords() checkInStore.fetchMyRecords();
}) });
</script> </script>
<style scoped> <style scoped>
+68 -89
View File
@@ -37,12 +37,7 @@
修改个人信息 修改个人信息
</h2> </h2>
<a-form <a-form ref="profileFormRef" :model="profileForm" :rules="profileRules" layout="vertical">
:model="profileForm"
:rules="profileRules"
ref="profileFormRef"
layout="vertical"
>
<a-form-item label="邮箱" name="email"> <a-form-item label="邮箱" name="email">
<a-input <a-input
v-model:value="profileForm.email" v-model:value="profileForm.email"
@@ -62,11 +57,7 @@
<a-form-item style="margin-top: 8px"> <a-form-item style="margin-top: 8px">
<a-space> <a-space>
<a-button <a-button type="primary" :loading="profileLoading" @click="handleUpdateProfile">
type="primary"
:loading="profileLoading"
@click="handleUpdateProfile"
>
保存 保存
</a-button> </a-button>
<a-button @click="resetProfileForm">重置</a-button> <a-button @click="resetProfileForm">重置</a-button>
@@ -92,14 +83,8 @@
:closable="false" :closable="false"
/> />
<a-form <a-form :model="passwordForm" layout="vertical">
:model="passwordForm" <a-form-item v-if="hasPassword" label="当前密码">
layout="vertical"
>
<a-form-item
v-if="hasPassword"
label="当前密码"
>
<a-input-password <a-input-password
v-model:value="passwordForm.currentPassword" v-model:value="passwordForm.currentPassword"
placeholder="请输入当前密码" placeholder="请输入当前密码"
@@ -125,11 +110,7 @@
<a-form-item style="margin-top: 8px"> <a-form-item style="margin-top: 8px">
<a-space> <a-space>
<a-button <a-button type="primary" :loading="passwordLoading" @click="handleUpdatePassword">
type="primary"
:loading="passwordLoading"
@click="handleUpdatePassword"
>
{{ hasPassword ? '修改密码' : '设置密码' }} {{ hasPassword ? '修改密码' : '设置密码' }}
</a-button> </a-button>
<a-button @click="resetPasswordForm">重置</a-button> <a-button @click="resetPasswordForm">重置</a-button>
@@ -143,130 +124,128 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue';
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue';
import { UserOutlined, EditOutlined, KeyOutlined } from '@ant-design/icons-vue' import { UserOutlined, EditOutlined, KeyOutlined } from '@ant-design/icons-vue';
import { userAPI } from '@/api' import { userAPI } from '@/api';
import Layout from '@/components/Layout.vue' import Layout from '@/components/Layout.vue';
const profileFormRef = ref(null) const profileFormRef = ref(null);
const profileLoading = ref(false) const profileLoading = ref(false);
const passwordLoading = ref(false) const passwordLoading = ref(false);
const user = ref(null) const user = ref(null);
const hasPassword = ref(false) const hasPassword = ref(false);
// 个人信息表单 // 个人信息表单
const profileForm = ref({ const profileForm = ref({
email: '', email: '',
}) });
const profileRules = { const profileRules = {
email: [ email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }, };
],
}
// 密码表单 // 密码表单
const passwordForm = ref({ const passwordForm = ref({
currentPassword: '', currentPassword: '',
newPassword: '', newPassword: '',
confirmPassword: '', confirmPassword: '',
}) });
// 加载用户信息 // 加载用户信息
const loadUserInfo = async () => { const loadUserInfo = async () => {
try { try {
user.value = await userAPI.getCurrentUser() user.value = await userAPI.getCurrentUser();
profileForm.value.email = user.value.email || '' profileForm.value.email = user.value.email || '';
// 从后端返回的数据中获取密码状态 // 从后端返回的数据中获取密码状态
hasPassword.value = user.value.has_password || false hasPassword.value = user.value.has_password || false;
} catch (error) { } catch (error) {
message.error(error.message || '加载用户信息失败') message.error(error.message || '加载用户信息失败');
} }
} };
// 更新个人信息 // 更新个人信息
const handleUpdateProfile = async () => { const handleUpdateProfile = async () => {
if (!profileFormRef.value) return if (!profileFormRef.value) return;
try { try {
await profileFormRef.value.validate() await profileFormRef.value.validate();
profileLoading.value = true profileLoading.value = true;
await userAPI.updateProfile({ await userAPI.updateProfile({
email: profileForm.value.email || null, email: profileForm.value.email || null,
}) });
message.success('个人信息修改成功') message.success('个人信息修改成功');
await loadUserInfo() await loadUserInfo();
} catch (error) { } catch (error) {
if (error.errorFields) return // 验证错误 if (error.errorFields) return; // 验证错误
const errorMsg = error.response?.data?.detail || error.message || '修改失败' const errorMsg = error.response?.data?.detail || error.message || '修改失败';
message.error(errorMsg) message.error(errorMsg);
} finally { } finally {
profileLoading.value = false profileLoading.value = false;
} }
} };
// 重置个人信息表单 // 重置个人信息表单
const resetProfileForm = () => { const resetProfileForm = () => {
profileForm.value.email = user.value?.email || '' profileForm.value.email = user.value?.email || '';
profileFormRef.value?.clearValidate() profileFormRef.value?.clearValidate();
} };
// 更新密码 // 更新密码
const handleUpdatePassword = async () => { const handleUpdatePassword = async () => {
try { try {
// 手动验证 // 手动验证
if (hasPassword.value && !passwordForm.value.currentPassword) { if (hasPassword.value && !passwordForm.value.currentPassword) {
message.error('请输入当前密码') message.error('请输入当前密码');
return return;
} }
if (!passwordForm.value.newPassword) { if (!passwordForm.value.newPassword) {
message.error('请输入新密码') message.error('请输入新密码');
return return;
} }
if (passwordForm.value.newPassword.length < 6) { if (passwordForm.value.newPassword.length < 6) {
message.error('密码至少需要6个字符') message.error('密码至少需要6个字符');
return return;
} }
if (!passwordForm.value.confirmPassword) { if (!passwordForm.value.confirmPassword) {
message.error('请再次输入新密码') message.error('请再次输入新密码');
return return;
} }
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) { if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
message.error('两次输入的密码不一致') message.error('两次输入的密码不一致');
return return;
} }
passwordLoading.value = true passwordLoading.value = true;
const updateData = { const updateData = {
new_password: passwordForm.value.newPassword, new_password: passwordForm.value.newPassword,
} };
if (hasPassword.value) { if (hasPassword.value) {
updateData.current_password = passwordForm.value.currentPassword updateData.current_password = passwordForm.value.currentPassword;
} }
await userAPI.updateProfile(updateData) await userAPI.updateProfile(updateData);
message.success(hasPassword.value ? '密码修改成功' : '密码设置成功') message.success(hasPassword.value ? '密码修改成功' : '密码设置成功');
hasPassword.value = true hasPassword.value = true;
resetPasswordForm() resetPasswordForm();
} catch (error) { } catch (error) {
const errorMsg = error.response?.data?.detail || error.message || '操作失败' const errorMsg = error.response?.data?.detail || error.message || '操作失败';
message.error(errorMsg) message.error(errorMsg);
} finally { } finally {
passwordLoading.value = false passwordLoading.value = false;
} }
} };
// 重置密码表单 // 重置密码表单
const resetPasswordForm = () => { const resetPasswordForm = () => {
@@ -274,25 +253,25 @@ const resetPasswordForm = () => {
currentPassword: '', currentPassword: '',
newPassword: '', newPassword: '',
confirmPassword: '', confirmPassword: '',
} };
} };
// 格式化日期 // 格式化日期
const formatDate = (dateString) => { const formatDate = dateString => {
if (!dateString) return '-' if (!dateString) return '-';
const date = new Date(dateString) const date = new Date(dateString);
return date.toLocaleString('zh-CN', { return date.toLocaleString('zh-CN', {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}) });
} };
onMounted(() => { onMounted(() => {
loadUserInfo() loadUserInfo();
}) });
</script> </script>
<style scoped> <style scoped>
+125 -131
View File
@@ -4,11 +4,7 @@
<div class="max-w-7xl mx-auto"> <div class="max-w-7xl mx-auto">
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="mb-8">
<a-button <a-button type="link" class="mb-4 flex items-center" @click="router.back()">
@click="router.back()"
type="link"
class="mb-4 flex items-center"
>
<template #icon><LeftOutlined /></template> <template #icon><LeftOutlined /></template>
返回任务列表 返回任务列表
</a-button> </a-button>
@@ -16,7 +12,9 @@
<a-card v-if="currentTask" class="md3-card"> <a-card v-if="currentTask" class="md3-card">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<h1 class="text-3xl font-bold text-gradient mb-2">{{ currentTask.name || '未命名任务' }}</h1> <h1 class="text-3xl font-bold text-gradient mb-2">
{{ currentTask.name || '未命名任务' }}
</h1>
<div class="flex items-center gap-4 text-sm text-on-surface-variant"> <div class="flex items-center gap-4 text-sm text-on-surface-variant">
<span class="flex items-center"> <span class="flex items-center">
<NumberOutlined class="mr-1" /> <NumberOutlined class="mr-1" />
@@ -27,11 +25,7 @@
</a-tag> </a-tag>
</div> </div>
</div> </div>
<a-button <a-button type="primary" :loading="checkInLoading" @click="handleManualCheckIn">
type="primary"
:loading="checkInLoading"
@click="handleManualCheckIn"
>
{{ checkInLoading ? '打卡中...' : '立即打卡' }} {{ checkInLoading ? '打卡中...' : '立即打卡' }}
</a-button> </a-button>
</div> </div>
@@ -49,31 +43,41 @@
<a-col :xs="12" :sm="8" :md="4"> <a-col :xs="12" :sm="8" :md="4">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.05s"> <a-card class="md3-card animate-slide-up" style="animation-delay: 0.05s">
<p class="text-sm text-on-surface-variant mb-1">成功次数</p> <p class="text-sm text-on-surface-variant mb-1">成功次数</p>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">{{ recordStats.success }}</p> <p class="text-2xl font-bold text-green-600 dark:text-green-400">
{{ recordStats.success }}
</p>
</a-card> </a-card>
</a-col> </a-col>
<a-col :xs="12" :sm="8" :md="4"> <a-col :xs="12" :sm="8" :md="4">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.1s"> <a-card class="md3-card animate-slide-up" style="animation-delay: 0.1s">
<p class="text-sm text-on-surface-variant mb-1">时间范围外</p> <p class="text-sm text-on-surface-variant mb-1">时间范围外</p>
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">{{ recordStats.outOfTime }}</p> <p class="text-2xl font-bold text-blue-600 dark:text-blue-400">
{{ recordStats.outOfTime }}
</p>
</a-card> </a-card>
</a-col> </a-col>
<a-col :xs="12" :sm="8" :md="4"> <a-col :xs="12" :sm="8" :md="4">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.15s"> <a-card class="md3-card animate-slide-up" style="animation-delay: 0.15s">
<p class="text-sm text-on-surface-variant mb-1">失败次数</p> <p class="text-sm text-on-surface-variant mb-1">失败次数</p>
<p class="text-2xl font-bold text-red-600 dark:text-red-400">{{ recordStats.failure }}</p> <p class="text-2xl font-bold text-red-600 dark:text-red-400">
{{ recordStats.failure }}
</p>
</a-card> </a-card>
</a-col> </a-col>
<a-col :xs="12" :sm="8" :md="4"> <a-col :xs="12" :sm="8" :md="4">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.2s"> <a-card class="md3-card animate-slide-up" style="animation-delay: 0.2s">
<p class="text-sm text-on-surface-variant mb-1">异常次数</p> <p class="text-sm text-on-surface-variant mb-1">异常次数</p>
<p class="text-2xl font-bold text-orange-600 dark:text-orange-400">{{ recordStats.unknown }}</p> <p class="text-2xl font-bold text-orange-600 dark:text-orange-400">
{{ recordStats.unknown }}
</p>
</a-card> </a-card>
</a-col> </a-col>
<a-col :xs="12" :sm="8" :md="4"> <a-col :xs="12" :sm="8" :md="4">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.25s"> <a-card class="md3-card animate-slide-up" style="animation-delay: 0.25s">
<p class="text-sm text-on-surface-variant mb-1">成功率</p> <p class="text-sm text-on-surface-variant mb-1">成功率</p>
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400">{{ recordStats.successRate }}%</p> <p class="text-2xl font-bold text-purple-600 dark:text-purple-400">
{{ recordStats.successRate }}%
</p>
</a-card> </a-card>
</a-col> </a-col>
</a-row> </a-row>
@@ -83,7 +87,12 @@
<a-space wrap :size="[16, 16]"> <a-space wrap :size="[16, 16]">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm font-medium text-on-surface">状态筛选:</span> <span class="text-sm font-medium text-on-surface">状态筛选:</span>
<a-radio-group v-model:value="filterStatus" button-style="solid" size="small" @change="handleFilterChange"> <a-radio-group
v-model:value="filterStatus"
button-style="solid"
size="small"
@change="handleFilterChange"
>
<a-radio-button value="">全部</a-radio-button> <a-radio-button value="">全部</a-radio-button>
<a-radio-button value="success">成功</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="out_of_time">时间范围外</a-radio-button>
@@ -94,7 +103,12 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm font-medium text-on-surface">触发方式:</span> <span class="text-sm font-medium text-on-surface">触发方式:</span>
<a-radio-group v-model:value="filterTrigger" button-style="solid" size="small" @change="handleFilterChange"> <a-radio-group
v-model:value="filterTrigger"
button-style="solid"
size="small"
@change="handleFilterChange"
>
<a-radio-button value="">全部</a-radio-button> <a-radio-button value="">全部</a-radio-button>
<a-radio-button value="scheduler">自动</a-radio-button> <a-radio-button value="scheduler">自动</a-radio-button>
<a-radio-button value="manual">手动</a-radio-button> <a-radio-button value="manual">手动</a-radio-button>
@@ -115,7 +129,11 @@
</a-card> </a-card>
</div> </div>
<a-card v-else-if="records.length === 0" class="md3-card text-center" style="padding: 48px 20px;"> <a-card
v-else-if="records.length === 0"
class="md3-card text-center"
style="padding: 48px 20px"
>
<FileTextOutlined class="text-8xl text-on-surface-variant opacity-30 mb-4" /> <FileTextOutlined class="text-8xl text-on-surface-variant opacity-30 mb-4" />
<h3 class="text-xl font-semibold text-on-surface mb-2">暂无打卡记录</h3> <h3 class="text-xl font-semibold text-on-surface mb-2">暂无打卡记录</h3>
<p class="text-on-surface-variant">当前筛选条件下没有找到任何打卡记录</p> <p class="text-on-surface-variant">当前筛选条件下没有找到任何打卡记录</p>
@@ -130,25 +148,13 @@
<div class="flex items-start justify-between mb-4"> <div class="flex items-start justify-between mb-4">
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-3 mb-2 flex-wrap"> <div class="flex items-center gap-3 mb-2 flex-wrap">
<h3 class="text-lg font-semibold text-on-surface"> <h3 class="text-lg font-semibold text-on-surface">打卡记录 #{{ record.id }}</h3>
打卡记录 #{{ record.id }} <a-tag v-if="record.status === 'success'" color="success"> 打卡成功</a-tag>
</h3> <a-tag v-else-if="record.status === 'out_of_time'" color="default"
<a-tag >🕐 时间范围外</a-tag
v-if="record.status === 'success'" >
color="success" <a-tag v-else-if="record.status === 'unknown'" color="warning"> 打卡异常</a-tag>
> 打卡成功</a-tag> <a-tag v-else color="error"> 打卡失败</a-tag>
<a-tag
v-else-if="record.status === 'out_of_time'"
color="default"
>🕐 时间范围外</a-tag>
<a-tag
v-else-if="record.status === 'unknown'"
color="warning"
> 打卡异常</a-tag>
<a-tag
v-else
color="error"
> 打卡失败</a-tag>
<a-tag :color="record.trigger_type === 'scheduled' ? 'blue' : 'orange'"> <a-tag :color="record.trigger_type === 'scheduled' ? 'blue' : 'orange'">
{{ record.trigger_type === 'scheduled' ? '自动触发' : '手动触发' }} {{ record.trigger_type === 'scheduled' ? '自动触发' : '手动触发' }}
</a-tag> </a-tag>
@@ -161,7 +167,9 @@
</div> </div>
<!-- Record Details --> <!-- Record Details -->
<div class="bg-surface-container-high dark:bg-surface-container rounded-lg p-4 space-y-2"> <div
class="bg-surface-container-high dark:bg-surface-container rounded-lg p-4 space-y-2"
>
<div v-if="record.response_text" class="flex items-start"> <div v-if="record.response_text" class="flex items-start">
<span class="text-sm font-medium text-on-surface-variant w-20">响应:</span> <span class="text-sm font-medium text-on-surface-variant w-20">响应:</span>
<span class="text-sm text-on-surface flex-1">{{ record.response_text }}</span> <span class="text-sm text-on-surface flex-1">{{ record.response_text }}</span>
@@ -179,14 +187,14 @@
<div v-if="!loading && records.length > 0" class="mt-6 flex justify-center"> <div v-if="!loading && records.length > 0" class="mt-6 flex justify-center">
<a-pagination <a-pagination
v-model:current="currentPage" v-model:current="currentPage"
v-model:pageSize="pageSize" v-model:page-size="pageSize"
:total="total" :total="total"
:pageSizeOptions="['10', '20', '50', '100']" :page-size-options="['10', '20', '50', '100']"
show-size-changer show-size-changer
show-quick-jumper show-quick-jumper
:show-total="total => `${total} 条记录`" :show-total="total => `${total} 条记录`"
@change="handlePageChange" @change="handlePageChange"
@showSizeChange="handleSizeChange" @show-size-change="handleSizeChange"
/> />
</div> </div>
</div> </div>
@@ -195,47 +203,47 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue';
import { import {
LeftOutlined, LeftOutlined,
NumberOutlined, NumberOutlined,
FileTextOutlined, FileTextOutlined,
ClockCircleOutlined, ClockCircleOutlined,
ReloadOutlined, ReloadOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue' import Layout from '@/components/Layout.vue';
import { useTaskStore } from '@/stores/task' import { useTaskStore } from '@/stores/task';
import { formatDateTime } from '@/utils/helpers' import { formatDateTime } from '@/utils/helpers';
const route = useRoute() const route = useRoute();
const router = useRouter() const router = useRouter();
const taskStore = useTaskStore() const taskStore = useTaskStore();
const taskId = computed(() => parseInt(route.params.taskId)) const taskId = computed(() => parseInt(route.params.taskId));
const currentTask = ref(null) const currentTask = ref(null);
const records = ref([]) const records = ref([]);
const loading = ref(false) const loading = ref(false);
const checkInLoading = ref(false) const checkInLoading = ref(false);
// Pagination // Pagination
const currentPage = ref(1) const currentPage = ref(1);
const pageSize = ref(20) const pageSize = ref(20);
const total = ref(0) const total = ref(0);
// Filters // Filters
const filterStatus = ref('') const filterStatus = ref('');
const filterTrigger = ref('') const filterTrigger = ref('');
// Stats // Stats
const recordStats = computed(() => { const recordStats = computed(() => {
const success = records.value.filter(r => r.status === 'success').length const success = records.value.filter(r => r.status === 'success').length;
const outOfTime = records.value.filter(r => r.status === 'out_of_time').length const outOfTime = records.value.filter(r => r.status === 'out_of_time').length;
const failure = records.value.filter(r => r.status === 'failure').length const failure = records.value.filter(r => r.status === 'failure').length;
const unknown = records.value.filter(r => r.status === 'unknown').length const unknown = records.value.filter(r => r.status === 'unknown').length;
const totalRecords = records.value.length const totalRecords = records.value.length;
const successRate = totalRecords > 0 ? Math.round((success / totalRecords) * 100) : 0 const successRate = totalRecords > 0 ? Math.round((success / totalRecords) * 100) : 0;
return { return {
total: totalRecords, total: totalRecords,
@@ -244,129 +252,115 @@ const recordStats = computed(() => {
failure, failure,
unknown, unknown,
successRate, successRate,
} };
}) });
// 从 payload_config 中提取 ThreadId // 从 payload_config 中提取 ThreadId
const getThreadId = (task) => { const getThreadId = task => {
if (!task || !task.payload_config) return '未知' if (!task || !task.payload_config) return '未知';
try { try {
const payload = JSON.parse(task.payload_config) const payload = JSON.parse(task.payload_config);
return payload.ThreadId || '未知' return payload.ThreadId || '未知';
} catch (e) { } catch (e) {
console.error('解析 payload_config 失败:', e) console.error('解析 payload_config 失败:', e);
return '未知' return '未知';
} }
} };
// 获取任务详情 // 获取任务详情
const fetchTaskDetail = async () => { const fetchTaskDetail = async () => {
try { try {
currentTask.value = await taskStore.fetchTask(taskId.value) currentTask.value = await taskStore.fetchTask(taskId.value);
} catch (error) { } catch (error) {
message.error(error.message || '获取任务详情失败') message.error(error.message || '获取任务详情失败');
router.push('/tasks') router.push('/tasks');
} }
} };
// 获取打卡记录 // 获取打卡记录
const fetchRecords = async () => { const fetchRecords = async () => {
loading.value = true loading.value = true;
try { try {
const params = { const params = {
skip: (currentPage.value - 1) * pageSize.value, skip: (currentPage.value - 1) * pageSize.value,
limit: pageSize.value, limit: pageSize.value,
} };
if (filterStatus.value) { if (filterStatus.value) {
params.status = filterStatus.value params.status = filterStatus.value;
} }
if (filterTrigger.value) { if (filterTrigger.value) {
params.trigger_type = filterTrigger.value params.trigger_type = filterTrigger.value;
} }
const response = await taskStore.fetchTaskRecords(taskId.value, params) const response = await taskStore.fetchTaskRecords(taskId.value, params);
// API 可能返回数组或对象 // API 可能返回数组或对象
if (Array.isArray(response)) { if (Array.isArray(response)) {
records.value = response records.value = response;
total.value = response.length total.value = response.length;
} else if (response.items) { } else if (response.items) {
records.value = response.items records.value = response.items;
total.value = response.total || response.items.length total.value = response.total || response.items.length;
} else { } else {
records.value = [] records.value = [];
total.value = 0 total.value = 0;
} }
} catch (error) { } catch (error) {
message.error(error.message || '获取打卡记录失败') message.error(error.message || '获取打卡记录失败');
} finally { } finally {
loading.value = false loading.value = false;
} }
} };
// 手动打卡 // 手动打卡
const handleManualCheckIn = async () => { const handleManualCheckIn = async () => {
checkInLoading.value = true checkInLoading.value = true;
// 显示持久化通知 // 显示持久化通知
const hide = message.loading('正在打卡中,请稍候... 您可以继续浏览其他页面', 0) const hide = message.loading('正在打卡中,请稍候... 您可以继续浏览其他页面', 0);
try { try {
const result = await taskStore.checkInTask(taskId.value) const result = await taskStore.checkInTask(taskId.value);
hide() hide();
if (result.success) { if (result.success) {
message.success('打卡成功') message.success('打卡成功');
// 刷新记录列表 // 刷新记录列表
await fetchRecords() await fetchRecords();
} else { } else {
message.warning(result.message || '打卡失败') message.warning(result.message || '打卡失败');
} }
} catch (error) { } catch (error) {
hide() hide();
message.error(error.message || '打卡失败') message.error(error.message || '打卡失败');
} finally { } finally {
checkInLoading.value = false checkInLoading.value = false;
} }
} };
// 筛选变化 // 筛选变化
const handleFilterChange = () => { const handleFilterChange = () => {
currentPage.value = 1 currentPage.value = 1;
fetchRecords() fetchRecords();
} };
// 分页变化 // 分页变化
const handlePageChange = () => { const handlePageChange = () => {
fetchRecords() fetchRecords();
} };
const handleSizeChange = () => { const handleSizeChange = () => {
currentPage.value = 1 currentPage.value = 1;
fetchRecords() fetchRecords();
} };
// 格式化响应数据
const formatResponse = (data) => {
if (!data) return '-'
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data)
return JSON.stringify(parsed, null, 2).substring(0, 200) + (data.length > 200 ? '...' : '')
} catch {
return data.substring(0, 200) + (data.length > 200 ? '...' : '')
}
}
return JSON.stringify(data, null, 2).substring(0, 200)
}
onMounted(async () => { onMounted(async () => {
await fetchTaskDetail() await fetchTaskDetail();
await fetchRecords() await fetchRecords();
}) });
</script> </script>
<style scoped> <style scoped>
+276 -259
View File
@@ -12,8 +12,8 @@
<a-button <a-button
type="primary" type="primary"
size="large" size="large"
@click="showCreateDialog = true"
class="shadow-md3-3" class="shadow-md3-3"
@click="showCreateDialog = true"
> >
<template #icon> <template #icon>
<PlusOutlined /> <PlusOutlined />
@@ -31,7 +31,9 @@
<p class="text-sm text-on-surface-variant mb-1">总任务数</p> <p class="text-sm text-on-surface-variant mb-1">总任务数</p>
<p class="text-3xl font-bold text-primary">{{ taskStore.taskStats.total }}</p> <p class="text-3xl font-bold text-primary">{{ taskStore.taskStats.total }}</p>
</div> </div>
<div class="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-md3 flex items-center justify-center"> <div
class="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-md3 flex items-center justify-center"
>
<FileTextOutlined class="text-2xl text-primary" /> <FileTextOutlined class="text-2xl text-primary" />
</div> </div>
</div> </div>
@@ -43,9 +45,13 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm text-on-surface-variant mb-1">启用中</p> <p class="text-sm text-on-surface-variant mb-1">启用中</p>
<p class="text-3xl font-bold text-green-600 dark:text-green-400">{{ taskStore.taskStats.active }}</p> <p class="text-3xl font-bold text-green-600 dark:text-green-400">
{{ taskStore.taskStats.active }}
</p>
</div> </div>
<div class="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-md3 flex items-center justify-center"> <div
class="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-md3 flex items-center justify-center"
>
<CheckCircleOutlined class="text-2xl text-green-600 dark:text-green-400" /> <CheckCircleOutlined class="text-2xl text-green-600 dark:text-green-400" />
</div> </div>
</div> </div>
@@ -57,9 +63,13 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm text-on-surface-variant mb-1">已禁用</p> <p class="text-sm text-on-surface-variant mb-1">已禁用</p>
<p class="text-3xl font-bold text-on-surface-variant">{{ taskStore.taskStats.inactive }}</p> <p class="text-3xl font-bold text-on-surface-variant">
{{ taskStore.taskStats.inactive }}
</p>
</div> </div>
<div class="w-12 h-12 bg-surface-container-high rounded-md3 flex items-center justify-center"> <div
class="w-12 h-12 bg-surface-container-high rounded-md3 flex items-center justify-center"
>
<StopOutlined class="text-2xl text-on-surface-variant" /> <StopOutlined class="text-2xl text-on-surface-variant" />
</div> </div>
</div> </div>
@@ -71,7 +81,7 @@
<!-- Tasks List --> <!-- Tasks List -->
<div v-if="loading"> <div v-if="loading">
<a-row :gutter="[16, 16]"> <a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12" :lg="8" v-for="i in 6" :key="i"> <a-col v-for="i in 6" :key="i" :xs="24" :sm="12" :lg="8">
<a-card> <a-card>
<a-skeleton :active="true" :paragraph="{ rows: 4 }" /> <a-skeleton :active="true" :paragraph="{ rows: 4 }" />
</a-card> </a-card>
@@ -79,21 +89,21 @@
</a-row> </a-row>
</div> </div>
<a-card v-else-if="taskStore.tasks.length === 0" class="md3-card text-center" style="padding: 48px 20px;"> <a-card
v-else-if="taskStore.tasks.length === 0"
class="md3-card text-center"
style="padding: 48px 20px"
>
<FileTextOutlined class="text-8xl text-on-surface-variant opacity-30 mb-4" /> <FileTextOutlined class="text-8xl text-on-surface-variant opacity-30 mb-4" />
<h3 class="text-xl font-semibold text-on-surface mb-2">暂无任务</h3> <h3 class="text-xl font-semibold text-on-surface mb-2">暂无任务</h3>
<p class="text-on-surface-variant mb-6">点击右上角的"创建任务"按钮开始添加您的第一个打卡任务</p> <p class="text-on-surface-variant mb-6">
<a-button type="primary" @click="showCreateDialog = true"> 点击右上角的"创建任务"按钮开始添加您的第一个打卡任务
创建第一个任务 </p>
</a-button> <a-button type="primary" @click="showCreateDialog = true"> 创建第一个任务 </a-button>
</a-card> </a-card>
<a-row v-else :gutter="[16, 16]"> <a-row v-else :gutter="[16, 16]">
<a-col <a-col v-for="task in taskStore.tasks" :key="task.id" :xs="24" :sm="12" :lg="8">
:xs="24" :sm="12" :lg="8"
v-for="task in taskStore.tasks"
:key="task.id"
>
<a-card <a-card
class="md3-card hover:scale-105 transform transition-all cursor-pointer animate-slide-up" class="md3-card hover:scale-105 transform transition-all cursor-pointer animate-slide-up"
@click="viewTask(task)" @click="viewTask(task)"
@@ -101,8 +111,10 @@
<!-- Task Header --> <!-- Task Header -->
<div class="flex items-start justify-between mb-4"> <div class="flex items-start justify-between mb-4">
<div class="flex-1"> <div class="flex-1">
<h3 class="text-lg font-semibold text-on-surface mb-1">{{ task.name || '未命名任务' }}</h3> <h3 class="text-lg font-semibold text-on-surface mb-1">
<a-divider style="margin: 8px 0;" /> {{ task.name || '未命名任务' }}
</h3>
<a-divider style="margin: 8px 0" />
<p class="text-sm text-on-surface-variant">任务 ID: {{ task.id }}</p> <p class="text-sm text-on-surface-variant">任务 ID: {{ task.id }}</p>
</div> </div>
<a-tag :color="task.is_active ? 'success' : 'default'"> <a-tag :color="task.is_active ? 'success' : 'default'">
@@ -118,21 +130,32 @@
</div> </div>
<div class="flex items-center text-sm text-on-surface-variant"> <div class="flex items-center text-sm text-on-surface-variant">
<ClockCircleOutlined class="mr-2" /> <ClockCircleOutlined class="mr-2" />
最后打卡: {{ task.last_check_in_time ? formatDateTime(task.last_check_in_time) : '未打卡' }} 最后打卡:
{{ task.last_check_in_time ? formatDateTime(task.last_check_in_time) : '未打卡' }}
</div> </div>
<div class="flex items-center text-sm"> <div class="flex items-center text-sm">
<CheckCircleOutlined class="mr-2 text-on-surface-variant" /> <CheckCircleOutlined class="mr-2 text-on-surface-variant" />
<span v-if="task.last_check_in_status" :class="{ <span
'text-green-600 dark:text-green-400 font-medium': task.last_check_in_status === 'success', v-if="task.last_check_in_status"
'text-blue-600 dark:text-blue-400 font-medium': task.last_check_in_status === 'out_of_time', :class="{
'text-red-600 dark:text-red-400 font-medium': task.last_check_in_status === 'failure', 'text-green-600 dark:text-green-400 font-medium':
'text-yellow-600 dark:text-yellow-400 font-medium': task.last_check_in_status === 'unknown' task.last_check_in_status === 'success',
}"> 'text-blue-600 dark:text-blue-400 font-medium':
task.last_check_in_status === 'out_of_time',
'text-red-600 dark:text-red-400 font-medium':
task.last_check_in_status === 'failure',
'text-yellow-600 dark:text-yellow-400 font-medium':
task.last_check_in_status === 'unknown',
}"
>
{{ {{
task.last_check_in_status === 'success' ? '✅ 打卡成功' : task.last_check_in_status === 'success'
task.last_check_in_status === 'out_of_time' ? '🕐 时间范围外' : ? '✅ 打卡成功'
task.last_check_in_status === 'failure' ? '❌ 打卡失败' : : task.last_check_in_status === 'out_of_time'
'❗ 打卡异常' ? '🕐 时间范围外'
: task.last_check_in_status === 'failure'
? '❌ 打卡失败'
: '❗ 打卡异常'
}} }}
</span> </span>
<span v-else class="text-on-surface-variant">暂无打卡记录</span> <span v-else class="text-on-surface-variant">暂无打卡记录</span>
@@ -145,33 +168,24 @@
type="primary" type="primary"
size="small" size="small"
:loading="checkInLoading[task.id]" :loading="checkInLoading[task.id]"
@click.stop="handleCheckIn(task.id)"
class="flex-1" class="flex-1"
@click.stop="handleCheckIn(task.id)"
> >
{{ checkInLoading[task.id] ? '打卡中...' : '立即打卡' }} {{ checkInLoading[task.id] ? '打卡中...' : '立即打卡' }}
</a-button> </a-button>
<a-button <a-button size="small" class="flex-1" @click.stop="toggleTaskStatus(task)">
size="small"
@click.stop="toggleTaskStatus(task)"
class="flex-1"
>
{{ task.is_active ? '禁用' : '启用' }} {{ task.is_active ? '禁用' : '启用' }}
</a-button> </a-button>
<a-button <a-button
type="primary" type="primary"
size="small" size="small"
ghost ghost
@click.stop="editTask(task)"
class="icon-button" class="icon-button"
@click.stop="editTask(task)"
> >
<template #icon><EditOutlined /></template> <template #icon><EditOutlined /></template>
</a-button> </a-button>
<a-button <a-button danger size="small" class="icon-button" @click.stop="deleteTask(task)">
danger
size="small"
@click.stop="deleteTask(task)"
class="icon-button"
>
<template #icon><DeleteOutlined /></template> <template #icon><DeleteOutlined /></template>
</a-button> </a-button>
</div> </div>
@@ -187,7 +201,7 @@
:title="editingTask ? '编辑任务' : '从模板创建任务'" :title="editingTask ? '编辑任务' : '从模板创建任务'"
:width="isMobile ? '100%' : 700" :width="isMobile ? '100%' : 700"
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}" :style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
:maskClosable="false" :mask-closable="false"
> >
<!-- 只显示从模板创建 --> <!-- 只显示从模板创建 -->
<div v-if="!editingTask"> <div v-if="!editingTask">
@@ -203,32 +217,46 @@
<div v-else> <div v-else>
<!-- Template Selection --> <!-- Template Selection -->
<a-form-item label="选择模板" v-if="!selectedTemplate"> <a-form-item v-if="!selectedTemplate" label="选择模板">
<div class="grid grid-cols-1 gap-3"> <div class="grid grid-cols-1 gap-3">
<div <div
v-for="template in activeTemplates" v-for="template in activeTemplates"
:key="template.id" :key="template.id"
@click="selectTemplate(template)"
class="border border-outline-variant rounded-lg p-4 cursor-pointer hover:border-primary hover:bg-primary-container/10 transition-all" class="border border-outline-variant rounded-lg p-4 cursor-pointer hover:border-primary hover:bg-primary-container/10 transition-all"
@click="selectTemplate(template)"
> >
<h4 class="font-semibold text-on-surface mb-1">{{ template.name }}</h4> <h4 class="font-semibold text-on-surface mb-1">{{ template.name }}</h4>
<p class="text-sm text-on-surface-variant">{{ template.description || '无描述' }}</p> <p class="text-sm text-on-surface-variant">
{{ template.description || '无描述' }}
</p>
</div> </div>
</div> </div>
</a-form-item> </a-form-item>
<!-- Template Form --> <!-- Template Form -->
<a-form v-if="selectedTemplate" :model="templateTaskForm" ref="templateFormRef" layout="vertical"> <a-form
v-if="selectedTemplate"
ref="templateFormRef"
:model="templateTaskForm"
layout="vertical"
>
<div class="mb-4 p-3 bg-blue-50 rounded-lg flex items-center justify-between"> <div class="mb-4 p-3 bg-blue-50 rounded-lg flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<FileTextOutlined class="text-blue-600 mr-2" /> <FileTextOutlined class="text-blue-600 mr-2" />
<span class="text-sm font-medium text-blue-900">使用模板{{ selectedTemplate.name }}</span> <span class="text-sm font-medium text-blue-900"
>使用模板{{ selectedTemplate.name }}</span
>
</div> </div>
<a-button size="small" type="link" @click="selectedTemplate = null">更换模板</a-button> <a-button size="small" type="link" @click="selectedTemplate = null"
>更换模板</a-button
>
</div> </div>
<a-form-item label="任务名称" name="task_name"> <a-form-item label="任务名称" name="task_name">
<a-input v-model:value="templateTaskForm.task_name" placeholder="可选,留空则自动生成" /> <a-input
v-model:value="templateTaskForm.task_name"
placeholder="可选,留空则自动生成"
/>
</a-form-item> </a-form-item>
<a-form-item label="接龙 ID" name="thread_id" required> <a-form-item label="接龙 ID" name="thread_id" required>
@@ -239,10 +267,7 @@
<!-- Dynamic Fields --> <!-- Dynamic Fields -->
<div v-for="(fieldConfig, key) in visibleFields" :key="key"> <div v-for="(fieldConfig, key) in visibleFields" :key="key">
<a-form-item <a-form-item :label="fieldConfig.display_name" :required="fieldConfig.required">
:label="fieldConfig.display_name"
:required="fieldConfig.required"
>
<!-- Text Input --> <!-- Text Input -->
<a-input <a-input
v-if="fieldConfig.field_type === 'text'" v-if="fieldConfig.field_type === 'text'"
@@ -292,7 +317,13 @@
</div> </div>
<!-- Edit Mode Form - 简化版只显示任务名称和启用状态 --> <!-- Edit Mode Form - 简化版只显示任务名称和启用状态 -->
<a-form v-if="editingTask" :model="taskForm" :rules="taskRules" ref="taskFormRef" layout="vertical"> <a-form
v-if="editingTask"
ref="taskFormRef"
:model="taskForm"
:rules="taskRules"
layout="vertical"
>
<a-form-item label="任务名称" name="name"> <a-form-item label="任务名称" name="name">
<a-input v-model:value="taskForm.name" placeholder="请输入任务名称(例如:公司打卡)" /> <a-input v-model:value="taskForm.name" placeholder="请输入任务名称(例如:公司打卡)" />
</a-form-item> </a-form-item>
@@ -314,12 +345,7 @@
<div class="mb-4"> <div class="mb-4">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span class="text-sm text-on-surface-variant">完整的打卡请求配置</span> <span class="text-sm text-on-surface-variant">完整的打卡请求配置</span>
<a-button <a-button size="small" type="primary" ghost @click="copyPayload">
size="small"
type="primary"
ghost
@click="copyPayload"
>
<template #icon><CopyOutlined /></template> <template #icon><CopyOutlined /></template>
复制 复制
</a-button> </a-button>
@@ -329,7 +355,7 @@
:rows="12" :rows="12"
readonly readonly
class="font-mono text-xs" class="font-mono text-xs"
style="resize: vertical; min-height: 200px; max-height: 400px;" style="resize: vertical; min-height: 200px; max-height: 400px"
/> />
<p class="text-xs text-on-surface-variant mt-1"> <p class="text-xs text-on-surface-variant mt-1">
💡 此配置由模板自动生成如需修改请删除任务后从模板重新创建 💡 此配置由模板自动生成如需修改请删除任务后从模板重新创建
@@ -341,7 +367,7 @@
<div class="flex gap-3 justify-end"> <div class="flex gap-3 justify-end">
<a-button @click="showCreateDialog = false">取消</a-button> <a-button @click="showCreateDialog = false">取消</a-button>
<a-button type="primary" :loading="submitting" @click="handleSubmit"> <a-button type="primary" :loading="submitting" @click="handleSubmit">
{{ submitting ? '提交中...' : (editingTask ? '保存修改' : '创建任务') }} {{ submitting ? '提交中...' : editingTask ? '保存修改' : '创建任务' }}
</a-button> </a-button>
</div> </div>
</template> </template>
@@ -350,9 +376,9 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue' import { ref, reactive, onMounted, computed, watch } from 'vue';
import { message, Modal } from 'ant-design-vue' import { message, Modal } from 'ant-design-vue';
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router';
import { import {
PlusOutlined, PlusOutlined,
FileTextOutlined, FileTextOutlined,
@@ -363,41 +389,41 @@ import {
EditOutlined, EditOutlined,
DeleteOutlined, DeleteOutlined,
CopyOutlined, CopyOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue' import Layout from '@/components/Layout.vue';
import CrontabEditor from '@/components/CrontabEditor.vue' import CrontabEditor from '@/components/CrontabEditor.vue';
import { useBreakpoint } from '@/composables/useBreakpoint' import { useBreakpoint } from '@/composables/useBreakpoint';
import { useTaskStore } from '@/stores/task' import { useTaskStore } from '@/stores/task';
import { useTemplateStore } from '@/stores/template' import { useTemplateStore } from '@/stores/template';
import { copyToClipboard, formatDateTime } from '@/utils/helpers' import { copyToClipboard, formatDateTime } from '@/utils/helpers';
import { usePollStatus } from '@/composables/usePollStatus' import { usePollStatus } from '@/composables/usePollStatus';
const router = useRouter() const router = useRouter();
const taskStore = useTaskStore() const taskStore = useTaskStore();
const templateStore = useTemplateStore() const templateStore = useTemplateStore();
const { isMobile } = useBreakpoint() const { isMobile } = useBreakpoint();
// 使用轮询 composable // 使用轮询 composable
const { startPolling } = usePollStatus({ const { startPolling } = usePollStatus({
interval: 2000, interval: 2000,
maxRetries: 15, maxRetries: 15,
backoff: false backoff: false,
}) });
const loading = ref(false) const loading = ref(false);
const showCreateDialog = ref(false) const showCreateDialog = ref(false);
const submitting = ref(false) const submitting = ref(false);
const editingTask = ref(null) const editingTask = ref(null);
const taskFormRef = ref(null) const taskFormRef = ref(null);
const templateFormRef = ref(null) const templateFormRef = ref(null);
const checkInLoading = ref({}) const checkInLoading = ref({});
// Template mode // Template mode
const createMode = ref('template') // 'template' or 'manual' const createMode = ref('template'); // 'template' or 'manual'
const loadingTemplates = ref(false) const loadingTemplates = ref(false);
const activeTemplates = ref([]) const activeTemplates = ref([]);
const selectedTemplate = ref(null) const selectedTemplate = ref(null);
const templatePreview = ref(null) // 存储从 preview 接口获取的合并后配置 const templatePreview = ref(null); // 存储从 preview 接口获取的合并后配置
// Manual create form // Manual create form
const taskForm = reactive({ const taskForm = reactive({
@@ -406,214 +432,206 @@ const taskForm = reactive({
is_active: true, is_active: true,
payload_config: '', payload_config: '',
cron_expression: '0 20 * * *', // 新增:Crontab 表达式,默认每天 20:00 cron_expression: '0 20 * * *', // 新增:Crontab 表达式,默认每天 20:00
}) });
// Template create form // Template create form
const templateTaskForm = reactive({ const templateTaskForm = reactive({
task_name: '', task_name: '',
thread_id: '', thread_id: '',
field_values: {} field_values: {},
}) });
const taskRules = { const taskRules = {
name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }], name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
thread_id: [{ required: true, message: '请输入接龙 ID', trigger: 'blur' }], thread_id: [{ required: true, message: '请输入接龙 ID', trigger: 'blur' }],
} };
// Compute visible fields from selected template (using merged config) // Compute visible fields from selected template (using merged config)
const visibleFields = computed(() => { const visibleFields = computed(() => {
if (!templatePreview.value) return {} if (!templatePreview.value) return {};
// 使用合并后的完整字段配置(包含从父模板继承的字段) // 使用合并后的完整字段配置(包含从父模板继承的字段)
const fieldConfig = templatePreview.value.field_config const fieldConfig = templatePreview.value.field_config;
const visible = {} const visible = {};
// 递归函数:提取所有可见的普通字段 // 递归函数:提取所有可见的普通字段
const extractVisibleFields = (config, parentPath = '') => { const extractVisibleFields = (config, parentPath = '') => {
for (const [key, value] of Object.entries(config)) { for (const [key, value] of Object.entries(config)) {
const currentPath = parentPath ? `${parentPath}.${key}` : key const currentPath = parentPath ? `${parentPath}.${key}` : key;
// 判断是否为字段配置对象(包含 display_name // 判断是否为字段配置对象(包含 display_name
if (value && typeof value === 'object' && 'display_name' in value) { if (value && typeof value === 'object' && 'display_name' in value) {
// 这是一个普通字段配置 // 这是一个普通字段配置
if (!value.hidden) { if (!value.hidden) {
visible[currentPath] = value visible[currentPath] = value;
} }
} }
// 判断是否为数组字段 // 判断是否为数组字段
else if (Array.isArray(value)) { else if (Array.isArray(value)) {
// 数组字段:遍历每个元素 // 数组字段:遍历每个元素
if (value.length > 0) { if (value.length > 0) {
const firstElement = value[0] const firstElement = value[0];
// 如果数组元素是字段配置对象,直接提取 // 如果数组元素是字段配置对象,直接提取
if (firstElement && typeof firstElement === 'object' && 'display_name' in firstElement) { if (firstElement && typeof firstElement === 'object' && 'display_name' in firstElement) {
if (!firstElement.hidden) { if (!firstElement.hidden) {
visible[`${currentPath}[0]`] = firstElement visible[`${currentPath}[0]`] = firstElement;
} }
} }
// 如果数组元素是对象(但不是字段配置),递归处理 // 如果数组元素是对象(但不是字段配置),递归处理
else if (firstElement && typeof firstElement === 'object') { else if (firstElement && typeof firstElement === 'object') {
extractVisibleFields(firstElement, `${currentPath}[0]`) extractVisibleFields(firstElement, `${currentPath}[0]`);
} }
} }
} }
// 判断是否为对象字段(不包含 display_name 的对象) // 判断是否为对象字段(不包含 display_name 的对象)
else if (value && typeof value === 'object' && !('display_name' in value)) { else if (value && typeof value === 'object' && !('display_name' in value)) {
// 递归处理对象字段 // 递归处理对象字段
extractVisibleFields(value, currentPath) extractVisibleFields(value, currentPath);
}
} }
} }
};
extractVisibleFields(fieldConfig) extractVisibleFields(fieldConfig);
return visible return visible;
}) });
// Formatted payload for display in edit mode // Formatted payload for display in edit mode
const formattedPayload = computed(() => { const formattedPayload = computed(() => {
if (!taskForm.payload_config) return '{}' if (!taskForm.payload_config) return '{}';
try { try {
const payload = JSON.parse(taskForm.payload_config) const payload = JSON.parse(taskForm.payload_config);
return JSON.stringify(payload, null, 2) return JSON.stringify(payload, null, 2);
} catch (e) { } catch {
return taskForm.payload_config return taskForm.payload_config;
} }
}) });
// Copy payload to clipboard // Copy payload to clipboard
const copyPayload = async () => { const copyPayload = async () => {
const success = await copyToClipboard(formattedPayload.value) const success = await copyToClipboard(formattedPayload.value);
if (success) { if (success) {
message.success('Payload 已复制到剪贴板') message.success('Payload 已复制到剪贴板');
} else { } else {
message.error('复制失败') message.error('复制失败');
} }
} };
// Initialize field values with defaults when template is selected // Initialize field values with defaults when template is selected
watch(selectedTemplate, async (newTemplate) => { watch(selectedTemplate, async newTemplate => {
if (!newTemplate) { if (!newTemplate) {
templatePreview.value = null templatePreview.value = null;
return return;
} }
// 获取模板的合并后配置(包含父模板的字段) // 获取模板的合并后配置(包含父模板的字段)
try { try {
templatePreview.value = await templateStore.previewTemplate(newTemplate.id) templatePreview.value = await templateStore.previewTemplate(newTemplate.id);
} catch (error) { } catch {
message.error('获取模板配置失败') message.error('获取模板配置失败');
templatePreview.value = null templatePreview.value = null;
return return;
} }
const fieldConfig = templatePreview.value.field_config const fieldConfig = templatePreview.value.field_config;
const fieldValues = {} const fieldValues = {};
// 递归函数:提取所有字段的默认值 // 递归函数:提取所有字段的默认值
const extractDefaultValues = (config, parentPath = '') => { const extractDefaultValues = (config, parentPath = '') => {
for (const [key, value] of Object.entries(config)) { for (const [key, value] of Object.entries(config)) {
const currentPath = parentPath ? `${parentPath}.${key}` : key const currentPath = parentPath ? `${parentPath}.${key}` : key;
// 判断是否为字段配置对象(包含 display_name // 判断是否为字段配置对象(包含 display_name
if (value && typeof value === 'object' && 'display_name' in value) { if (value && typeof value === 'object' && 'display_name' in value) {
fieldValues[currentPath] = value.default_value || '' fieldValues[currentPath] = value.default_value || '';
} }
// 判断是否为数组字段 // 判断是否为数组字段
else if (Array.isArray(value)) { else if (Array.isArray(value)) {
// 数组字段:处理第一个元素的默认值 // 数组字段:处理第一个元素的默认值
if (value.length > 0) { if (value.length > 0) {
const firstElement = value[0] const firstElement = value[0];
// 如果数组元素是字段配置对象,直接提取默认值 // 如果数组元素是字段配置对象,直接提取默认值
if (firstElement && typeof firstElement === 'object' && 'display_name' in firstElement) { if (firstElement && typeof firstElement === 'object' && 'display_name' in firstElement) {
fieldValues[`${currentPath}[0]`] = firstElement.default_value || '' fieldValues[`${currentPath}[0]`] = firstElement.default_value || '';
} }
// 如果数组元素是对象(但不是字段配置),递归处理 // 如果数组元素是对象(但不是字段配置),递归处理
else if (firstElement && typeof firstElement === 'object') { else if (firstElement && typeof firstElement === 'object') {
extractDefaultValues(firstElement, `${currentPath}[0]`) extractDefaultValues(firstElement, `${currentPath}[0]`);
} }
} }
} }
// 判断是否为对象字段(不包含 display_name 的对象) // 判断是否为对象字段(不包含 display_name 的对象)
else if (value && typeof value === 'object' && !('display_name' in value)) { else if (value && typeof value === 'object' && !('display_name' in value)) {
// 递归处理对象字段 // 递归处理对象字段
extractDefaultValues(value, currentPath) extractDefaultValues(value, currentPath);
}
} }
} }
};
extractDefaultValues(fieldConfig) extractDefaultValues(fieldConfig);
templateTaskForm.field_values = fieldValues templateTaskForm.field_values = fieldValues;
}) });
// Load templates // Load templates
const loadTemplates = async () => { const loadTemplates = async () => {
loadingTemplates.value = true loadingTemplates.value = true;
try { try {
activeTemplates.value = await templateStore.fetchActiveTemplates() activeTemplates.value = await templateStore.fetchActiveTemplates();
} catch (error) { } catch (error) {
message.error(error.message || '加载模板失败') message.error(error.message || '加载模板失败');
} finally { } finally {
loadingTemplates.value = false loadingTemplates.value = false;
} }
} };
// Select template // Select template
const selectTemplate = (template) => { const selectTemplate = template => {
selectedTemplate.value = template selectedTemplate.value = template;
} };
// Handle mode change
const handleModeChange = (mode) => {
selectedTemplate.value = null
templateTaskForm.task_name = ''
templateTaskForm.thread_id = ''
templateTaskForm.field_values = {}
}
// 从 payload_config 中提取 ThreadId // 从 payload_config 中提取 ThreadId
const getThreadId = (task) => { const getThreadId = task => {
if (!task.payload_config) return '未知' if (!task.payload_config) return '未知';
try { try {
const payload = JSON.parse(task.payload_config) const payload = JSON.parse(task.payload_config);
return payload.ThreadId || '未知' return payload.ThreadId || '未知';
} catch (e) { } catch (e) {
console.error('解析 payload_config 失败:', e) console.error('解析 payload_config 失败:', e);
return '未知' return '未知';
} }
} };
// 加载任务列表 // 加载任务列表
const fetchTasks = async () => { const fetchTasks = async () => {
loading.value = true loading.value = true;
try { try {
await taskStore.fetchMyTasks() await taskStore.fetchMyTasks();
} catch (error) { } catch (error) {
message.error(error.message || '加载任务列表失败') message.error(error.message || '加载任务列表失败');
} finally { } finally {
loading.value = false loading.value = false;
} }
} };
// 查看任务详情 // 查看任务详情
const viewTask = (task) => { const viewTask = task => {
router.push(`/tasks/${task.id}/records`) router.push(`/tasks/${task.id}/records`);
} };
// 编辑任务 // 编辑任务
const editTask = (task) => { const editTask = task => {
editingTask.value = task editingTask.value = task;
// 从 payload_config 中提取 thread_id // 从 payload_config 中提取 thread_id
let threadId = '' let threadId = '';
try { try {
const payload = JSON.parse(task.payload_config || '{}') const payload = JSON.parse(task.payload_config || '{}');
threadId = payload.ThreadId || '' threadId = payload.ThreadId || '';
} catch (e) { } catch (e) {
console.error('解析 payload_config 失败:', e) console.error('解析 payload_config 失败:', e);
} }
Object.assign(taskForm, { Object.assign(taskForm, {
@@ -622,12 +640,12 @@ const editTask = (task) => {
is_active: task.is_active, is_active: task.is_active,
payload_config: task.payload_config || '{}', payload_config: task.payload_config || '{}',
cron_expression: task.cron_expression || '0 20 * * *', cron_expression: task.cron_expression || '0 20 * * *',
}) });
showCreateDialog.value = true showCreateDialog.value = true;
} };
// 删除任务 // 删除任务
const deleteTask = (task) => { const deleteTask = task => {
Modal.confirm({ Modal.confirm({
title: '删除确认', title: '删除确认',
content: `确定要删除任务"${task.name || task.id}"吗?此操作不可恢复。`, content: `确定要删除任务"${task.name || task.id}"吗?此操作不可恢复。`,
@@ -636,112 +654,111 @@ const deleteTask = (task) => {
okType: 'danger', okType: 'danger',
onOk: async () => { onOk: async () => {
try { try {
await taskStore.deleteTask(task.id) await taskStore.deleteTask(task.id);
message.success('任务删除成功') message.success('任务删除成功');
await fetchTasks() await fetchTasks();
} catch (error) { } catch (error) {
message.error(error.message || '删除任务失败') message.error(error.message || '删除任务失败');
} }
}, },
}) });
} };
// 切换任务状态 // 切换任务状态
const toggleTaskStatus = async (task) => { const toggleTaskStatus = async task => {
try { try {
await taskStore.toggleTask(task.id) await taskStore.toggleTask(task.id);
message.success(task.is_active ? '任务已禁用' : '任务已启用') message.success(task.is_active ? '任务已禁用' : '任务已启用');
} catch (error) { } catch (error) {
message.error(error.message || '切换任务状态失败') message.error(error.message || '切换任务状态失败');
} }
} };
// 手动打卡 (异步轮询方式) // 手动打卡 (异步轮询方式)
const handleCheckIn = async (taskId) => { const handleCheckIn = async taskId => {
checkInLoading.value[taskId] = true checkInLoading.value[taskId] = true;
try { try {
// 调用异步打卡接口,立即返回 record_id // 调用异步打卡接口,立即返回 record_id
const result = await taskStore.checkInTask(taskId) const result = await taskStore.checkInTask(taskId);
// 获取 record_id // 获取 record_id
const recordId = result.record_id const recordId = result.record_id;
if (!recordId) { if (!recordId) {
message.error('打卡请求失败:未获取到记录ID') message.error('打卡请求失败:未获取到记录ID');
checkInLoading.value[taskId] = false checkInLoading.value[taskId] = false;
return return;
} }
// 如果初始状态就是失败,显示错误并刷新任务列表 // 如果初始状态就是失败,显示错误并刷新任务列表
if (result.status === 'failure') { if (result.status === 'failure') {
message.error(result.message || '打卡失败') message.error(result.message || '打卡失败');
checkInLoading.value[taskId] = false checkInLoading.value[taskId] = false;
await fetchTasks() await fetchTasks();
return return;
} }
// 显示提示消息 // 显示提示消息
message.info('打卡任务已启动,正在后台处理...') message.info('打卡任务已启动,正在后台处理...');
// 使用轮询 composable 检查打卡状态 // 使用轮询 composable 检查打卡状态
startPolling( startPolling(
async () => { async () => {
const status = await taskStore.getCheckInRecordStatus(recordId) const status = await taskStore.getCheckInRecordStatus(recordId);
return { return {
completed: status.status !== 'pending', completed: status.status !== 'pending',
success: status.status === 'success', success: status.status === 'success',
data: status data: status,
} };
}, },
{ {
onSuccess: async () => { onSuccess: async () => {
checkInLoading.value[taskId] = false checkInLoading.value[taskId] = false;
message.success('打卡成功!') message.success('打卡成功!');
await fetchTasks() await fetchTasks();
}, },
onFailure: async (statusData) => { onFailure: async statusData => {
checkInLoading.value[taskId] = false checkInLoading.value[taskId] = false;
const errorMsg = statusData.error_message || statusData.response_text || '打卡失败' const errorMsg = statusData.error_message || statusData.response_text || '打卡失败';
message.error(errorMsg) message.error(errorMsg);
await fetchTasks() await fetchTasks();
}, },
onTimeout: () => { onTimeout: () => {
checkInLoading.value[taskId] = false checkInLoading.value[taskId] = false;
message.warning('打卡处理时间较长,请稍后查看打卡记录') message.warning('打卡处理时间较长,请稍后查看打卡记录');
},
} }
} );
)
} catch (error) { } catch (error) {
console.error('启动打卡失败:', error) console.error('启动打卡失败:', error);
checkInLoading.value[taskId] = false checkInLoading.value[taskId] = false;
message.error(error.message || '启动打卡任务失败') message.error(error.message || '启动打卡任务失败');
} }
} };
// 提交表单 // 提交表单
const handleSubmit = async () => { const handleSubmit = async () => {
submitting.value = true submitting.value = true;
try { try {
// Edit mode // Edit mode
if (editingTask.value) { if (editingTask.value) {
if (!taskFormRef.value) return if (!taskFormRef.value) return;
await taskFormRef.value.validate() await taskFormRef.value.validate();
await taskStore.updateTask(editingTask.value.id, taskForm) await taskStore.updateTask(editingTask.value.id, taskForm);
message.success('任务更新成功') message.success('任务更新成功');
} }
// Create from template // Create from template
else if (createMode.value === 'template') { else if (createMode.value === 'template') {
if (!selectedTemplate.value) { if (!selectedTemplate.value) {
message.warning('请选择一个模板') message.warning('请选择一个模板');
return return;
} }
if (!templateTaskForm.thread_id) { if (!templateTaskForm.thread_id) {
message.warning('请输入接龙 ID') message.warning('请输入接龙 ID');
return return;
} }
await templateStore.createTaskFromTemplate( await templateStore.createTaskFromTemplate(
@@ -749,59 +766,59 @@ const handleSubmit = async () => {
templateTaskForm.thread_id, templateTaskForm.thread_id,
templateTaskForm.field_values, templateTaskForm.field_values,
templateTaskForm.task_name || null templateTaskForm.task_name || null
) );
message.success('任务创建成功') message.success('任务创建成功');
} }
// Create manually // Create manually
else { else {
if (!taskFormRef.value) return if (!taskFormRef.value) return;
await taskFormRef.value.validate() await taskFormRef.value.validate();
await taskStore.createTask(taskForm) await taskStore.createTask(taskForm);
message.success('任务创建成功') message.success('任务创建成功');
} }
showCreateDialog.value = false showCreateDialog.value = false;
resetForm() resetForm();
await fetchTasks() await fetchTasks();
} catch (error) { } catch (error) {
message.error(error.message || '操作失败') message.error(error.message || '操作失败');
} finally { } finally {
submitting.value = false submitting.value = false;
} }
} };
// 重置表单 // 重置表单
const resetForm = () => { const resetForm = () => {
editingTask.value = null editingTask.value = null;
selectedTemplate.value = null selectedTemplate.value = null;
createMode.value = 'template' createMode.value = 'template';
Object.assign(taskForm, { Object.assign(taskForm, {
name: '', name: '',
thread_id: '', thread_id: '',
is_active: true, is_active: true,
payload_config: '', payload_config: '',
}) });
templateTaskForm.task_name = '' templateTaskForm.task_name = '';
templateTaskForm.thread_id = '' templateTaskForm.thread_id = '';
templateTaskForm.field_values = {} templateTaskForm.field_values = {};
taskFormRef.value?.resetFields() taskFormRef.value?.resetFields();
} };
// Watch dialog open to load templates // Watch dialog open to load templates
watch(showCreateDialog, (isOpen) => { watch(showCreateDialog, isOpen => {
if (isOpen && !editingTask.value) { if (isOpen && !editingTask.value) {
loadTemplates() loadTemplates();
} }
}) });
onMounted(() => { onMounted(() => {
fetchTasks() fetchTasks();
}) });
</script> </script>
<style scoped> <style scoped>
+23 -22
View File
@@ -48,43 +48,44 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue';
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue';
import { FileTextOutlined, ReloadOutlined } from '@ant-design/icons-vue' import { FileTextOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue' import Layout from '@/components/Layout.vue';
import { useAdminStore } from '@/stores/admin' import { useAdminStore } from '@/stores/admin';
import { formatDateTime } from '@/utils/helpers' import { formatDateTime } from '@/utils/helpers';
const adminStore = useAdminStore() const adminStore = useAdminStore();
const logContent = ref('') const logContent = ref('');
const lastUpdate = ref('') const lastUpdate = ref('');
const logLines = computed(() => { const logLines = computed(() => {
if (!logContent.value) return 0 if (!logContent.value) return 0;
const content = typeof logContent.value === 'string' ? logContent.value : String(logContent.value) const content =
return content.split('\n').length typeof logContent.value === 'string' ? logContent.value : String(logContent.value);
}) return content.split('\n').length;
});
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
const data = await adminStore.fetchLogs({ lines: 200 }) const data = await adminStore.fetchLogs({ lines: 200 });
if (data.logs) { if (data.logs) {
// 确保是字符串 // 确保是字符串
logContent.value = typeof data.logs === 'string' ? data.logs : String(data.logs) logContent.value = typeof data.logs === 'string' ? data.logs : String(data.logs);
lastUpdate.value = formatDateTime(new Date()) lastUpdate.value = formatDateTime(new Date());
message.success('刷新成功') message.success('刷新成功');
} else { } else {
logContent.value = '无日志内容' logContent.value = '无日志内容';
} }
} catch (error) { } catch (error) {
message.error(error.message || '刷新失败') message.error(error.message || '刷新失败');
} }
} };
onMounted(() => { onMounted(() => {
handleRefresh() handleRefresh();
}) });
</script> </script>
<style scoped> <style scoped>
+57 -35
View File
@@ -18,7 +18,7 @@
<!-- Desktop table --> <!-- Desktop table -->
<a-table <a-table
v-if="!isMobile" v-if="!isMobile"
:dataSource="checkInStore.allRecords" :data-source="checkInStore.allRecords"
:columns="columns" :columns="columns"
:loading="checkInStore.loading" :loading="checkInStore.loading"
:pagination="false" :pagination="false"
@@ -32,7 +32,9 @@
</template> </template>
<template v-else-if="column.key === 'status'"> <template v-else-if="column.key === 'status'">
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag> <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 === 'out_of_time'" color="default"
>🕐 时间范围外</a-tag
>
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag> <a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
<a-tag v-else color="error">❌ 打卡失败</a-tag> <a-tag v-else color="error">❌ 打卡失败</a-tag>
</template> </template>
@@ -47,17 +49,32 @@
<!-- Mobile card view --> <!-- Mobile card view -->
<a-space v-else direction="vertical" :size="16" style="width: 100%"> <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-card
v-for="record in checkInStore.allRecords"
:key="record.id"
size="small"
:loading="checkInStore.loading"
>
<a-descriptions :column="1" size="small" bordered> <a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="ID">{{ record.id }}</a-descriptions-item> <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="用户ID">{{ record.user_id }}</a-descriptions-item>
<a-descriptions-item label="用户邮箱">{{ record.user_email || '-' }}</a-descriptions-item> <a-descriptions-item label="用户邮箱">{{
<a-descriptions-item label="任务名称">{{ record.task_name || '-' }}</a-descriptions-item> record.user_email || '-'
<a-descriptions-item label="接龙ID">{{ record.thread_id || '-' }}</a-descriptions-item> }}</a-descriptions-item>
<a-descriptions-item label="打卡时间">{{ formatDateTime(record.check_in_time) }}</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-descriptions-item label="状态">
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag> <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 === 'out_of_time'" color="default"
>🕐 时间范围外</a-tag
>
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag> <a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
<a-tag v-else color="error">❌ 打卡失败</a-tag> <a-tag v-else color="error">❌ 打卡失败</a-tag>
</a-descriptions-item> </a-descriptions-item>
@@ -67,26 +84,31 @@
<a-tag v-else-if="record.trigger_type === 'admin'" color="orange">管理员</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-tag v-else>{{ record.trigger_type }}</a-tag>
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="消息">{{ record.response_text || '-' }}</a-descriptions-item> <a-descriptions-item label="消息">{{
record.response_text || '-'
}}</a-descriptions-item>
</a-descriptions> </a-descriptions>
</a-card> </a-card>
</a-space> </a-space>
<!-- Empty state --> <!-- Empty state -->
<a-empty v-if="!checkInStore.loading && checkInStore.allRecords.length === 0" description="暂无打卡记录" /> <a-empty
v-if="!checkInStore.loading && checkInStore.allRecords.length === 0"
description="暂无打卡记录"
/>
<!-- Pagination --> <!-- Pagination -->
<div class="pagination-container" v-if="checkInStore.total > 0"> <div v-if="checkInStore.total > 0" class="pagination-container">
<a-pagination <a-pagination
v-model:current="checkInStore.currentPage" v-model:current="checkInStore.currentPage"
v-model:pageSize="checkInStore.pageSize" v-model:page-size="checkInStore.pageSize"
:total="checkInStore.total" :total="checkInStore.total"
:pageSizeOptions="['10', '20', '50', '100']" :page-size-options="['10', '20', '50', '100']"
show-size-changer show-size-changer
show-quick-jumper show-quick-jumper
:show-total="total => `${total} 条记录`" :show-total="total => `${total} 条记录`"
@change="handlePageChange" @change="handlePageChange"
@showSizeChange="handleSizeChange" @show-size-change="handleSizeChange"
/> />
</div> </div>
</a-card> </a-card>
@@ -95,16 +117,16 @@
</template> </template>
<script setup> <script setup>
import { onMounted } from 'vue' import { onMounted } from 'vue';
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue';
import { UnorderedListOutlined, ReloadOutlined } from '@ant-design/icons-vue' import { UnorderedListOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue' import Layout from '@/components/Layout.vue';
import { useCheckInStore } from '@/stores/checkIn' import { useCheckInStore } from '@/stores/checkIn';
import { useBreakpoint } from '@/composables/useBreakpoint' import { useBreakpoint } from '@/composables/useBreakpoint';
import { formatDateTime } from '@/utils/helpers' import { formatDateTime } from '@/utils/helpers';
const checkInStore = useCheckInStore() const checkInStore = useCheckInStore();
const { isMobile } = useBreakpoint() const { isMobile } = useBreakpoint();
// Table columns configuration // Table columns configuration
const columns = [ const columns = [
@@ -117,29 +139,29 @@ const columns = [
{ title: '状态', dataIndex: 'status', key: 'status', width: 120 }, { title: '状态', dataIndex: 'status', key: 'status', width: 120 },
{ title: '触发方式', dataIndex: 'trigger_type', key: 'trigger_type', width: 120 }, { title: '触发方式', dataIndex: 'trigger_type', key: 'trigger_type', width: 120 },
{ title: '消息', dataIndex: 'response_text', key: 'response_text', ellipsis: true }, { title: '消息', dataIndex: 'response_text', key: 'response_text', ellipsis: true },
] ];
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
await checkInStore.fetchAllRecords() await checkInStore.fetchAllRecords();
message.success('刷新成功') message.success('刷新成功');
} catch (error) { } catch (error) {
message.error(error.message || '刷新失败') message.error(error.message || '刷新失败');
} }
} };
const handlePageChange = () => { const handlePageChange = () => {
checkInStore.fetchAllRecords() checkInStore.fetchAllRecords();
} };
const handleSizeChange = () => { const handleSizeChange = () => {
checkInStore.currentPage = 1 checkInStore.currentPage = 1;
checkInStore.fetchAllRecords() checkInStore.fetchAllRecords();
} };
onMounted(() => { onMounted(() => {
checkInStore.fetchAllRecords() checkInStore.fetchAllRecords();
}) });
</script> </script>
<style scoped> <style scoped>
+38 -36
View File
@@ -22,10 +22,7 @@
<div v-else-if="adminStore.stats" class="stats-content"> <div v-else-if="adminStore.stats" class="stats-content">
<a-row :gutter="[20, 20]"> <a-row :gutter="[20, 20]">
<a-col :xs="24" :sm="12" :md="6"> <a-col :xs="24" :sm="12" :md="6">
<a-statistic <a-statistic title="总用户数" :value="adminStore.totalUsers">
title="总用户数"
:value="adminStore.totalUsers"
>
<template #prefix> <template #prefix>
<UserOutlined /> <UserOutlined />
</template> </template>
@@ -43,10 +40,7 @@
</a-statistic> </a-statistic>
</a-col> </a-col>
<a-col :xs="24" :sm="12" :md="6"> <a-col :xs="24" :sm="12" :md="6">
<a-statistic <a-statistic title="总打卡次数" :value="adminStore.totalRecords">
title="总打卡次数"
:value="adminStore.totalRecords"
>
<template #prefix> <template #prefix>
<UnorderedListOutlined /> <UnorderedListOutlined />
</template> </template>
@@ -75,16 +69,24 @@
{{ adminStore.stats?.users?.regular || 0 }} {{ adminStore.stats?.users?.regular || 0 }}
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="今日成功打卡"> <a-descriptions-item label="今日成功打卡">
<a-tag color="success">{{ adminStore.stats?.check_in_records?.today_success || 0 }}</a-tag> <a-tag color="success">{{
adminStore.stats?.check_in_records?.today_success || 0
}}</a-tag>
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="今日失败打卡"> <a-descriptions-item label="今日失败打卡">
<a-tag color="error">{{ adminStore.stats?.check_in_records?.today_failure || 0 }}</a-tag> <a-tag color="error">{{
adminStore.stats?.check_in_records?.today_failure || 0
}}</a-tag>
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="今日时间范围外"> <a-descriptions-item label="今日时间范围外">
<a-tag color="default">{{ adminStore.stats?.check_in_records?.today_out_of_time || 0 }}</a-tag> <a-tag color="default">{{
adminStore.stats?.check_in_records?.today_out_of_time || 0
}}</a-tag>
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="今日异常打卡"> <a-descriptions-item label="今日异常打卡">
<a-tag color="warning">{{ adminStore.stats?.check_in_records?.today_unknown || 0 }}</a-tag> <a-tag color="warning">{{
adminStore.stats?.check_in_records?.today_unknown || 0
}}</a-tag>
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="总成功率" :span="2"> <a-descriptions-item label="总成功率" :span="2">
<a-progress <a-progress
@@ -102,8 +104,8 @@
</template> </template>
<script setup> <script setup>
import { onMounted } from 'vue' import { onMounted } from 'vue';
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue';
import { import {
BarChartOutlined, BarChartOutlined,
ReloadOutlined, ReloadOutlined,
@@ -111,45 +113,45 @@ import {
CheckOutlined, CheckOutlined,
UnorderedListOutlined, UnorderedListOutlined,
CalendarOutlined, CalendarOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue' import Layout from '@/components/Layout.vue';
import { useAdminStore } from '@/stores/admin' import { useAdminStore } from '@/stores/admin';
const adminStore = useAdminStore() const adminStore = useAdminStore();
const getProgressColor = (percentage) => { const getProgressColor = percentage => {
if (percentage >= 90) return '#52c41a' if (percentage >= 90) return '#52c41a';
if (percentage >= 70) return '#faad14' if (percentage >= 70) return '#faad14';
return '#ff4d4f' return '#ff4d4f';
} };
const calculateSuccessRate = () => { const calculateSuccessRate = () => {
const total = adminStore.stats?.check_in_records?.total || 0 const total = adminStore.stats?.check_in_records?.total || 0;
const todaySuccess = adminStore.stats?.check_in_records?.today_success || 0 const todaySuccess = adminStore.stats?.check_in_records?.today_success || 0;
if (total === 0) return 0 if (total === 0) return 0;
// Calculate success rate based on all records (not just today) // Calculate success rate based on all records (not just today)
// We need to get success count from backend or calculate differently // We need to get success count from backend or calculate differently
// For now, use today's success rate as approximation // For now, use today's success rate as approximation
const todayTotal = adminStore.stats?.check_in_records?.today || 0 const todayTotal = adminStore.stats?.check_in_records?.today || 0;
if (todayTotal === 0) return 0 if (todayTotal === 0) return 0;
return Math.round((todaySuccess / todayTotal) * 100) return Math.round((todaySuccess / todayTotal) * 100);
} };
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
await adminStore.fetchStats() await adminStore.fetchStats();
message.success('刷新成功') message.success('刷新成功');
} catch (error) { } catch (error) {
message.error(error.message || '刷新失败') message.error(error.message || '刷新失败');
} }
} };
onMounted(() => { onMounted(() => {
adminStore.fetchStats() adminStore.fetchStats();
}) });
</script> </script>
<style scoped> <style scoped>
+341 -220
View File
@@ -9,9 +9,14 @@
<h1 class="text-3xl font-bold text-gradient mb-2">任务模板管理</h1> <h1 class="text-3xl font-bold text-gradient mb-2">任务模板管理</h1>
<p class="text-on-surface-variant">JSON 映射架构 - 配置即结构</p> <p class="text-on-surface-variant">JSON 映射架构 - 配置即结构</p>
</div> </div>
<button @click="showCreateDialog" class="md3-button-filled"> <button class="md3-button-filled" @click="showCreateDialog">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg> </svg>
新建模板 新建模板
</button> </button>
@@ -25,13 +30,27 @@
</a-card> </a-card>
</div> </div>
<a-card v-else-if="templates.length === 0" class="md3-card text-center" style="padding: 48px 20px;"> <a-card
<svg class="w-20 h-20 mx-auto text-on-surface-variant opacity-30 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> v-else-if="templates.length === 0"
<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" /> class="md3-card text-center"
style="padding: 48px 20px"
>
<svg
class="w-20 h-20 mx-auto text-on-surface-variant opacity-30 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
<h3 class="text-xl font-semibold text-on-surface mb-2">暂无模板</h3> <h3 class="text-xl font-semibold text-on-surface mb-2">暂无模板</h3>
<p class="text-on-surface-variant mb-4">创建第一个模板让用户更轻松地创建打卡任务</p> <p class="text-on-surface-variant mb-4">创建第一个模板让用户更轻松地创建打卡任务</p>
<button @click="showCreateDialog" class="md3-button-filled">新建模板</button> <button class="md3-button-filled" @click="showCreateDialog">新建模板</button>
</a-card> </a-card>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -43,8 +62,10 @@
<div class="flex items-start justify-between mb-3"> <div class="flex items-start justify-between mb-3">
<div class="flex-1"> <div class="flex-1">
<h3 class="text-lg font-semibold text-on-surface mb-2">{{ template.name }}</h3> <h3 class="text-lg font-semibold text-on-surface mb-2">{{ template.name }}</h3>
<a-divider style="margin: 8px 0;" /> <a-divider style="margin: 8px 0" />
<p class="text-sm text-on-surface-variant mb-2">{{ template.description || '无描述' }}</p> <p class="text-sm text-on-surface-variant mb-2">
{{ template.description || '无描述' }}
</p>
<span :class="template.is_active ? 'md3-badge-success' : 'md3-badge-info'"> <span :class="template.is_active ? 'md3-badge-success' : 'md3-badge-info'">
{{ template.is_active ? '已启用' : '已禁用' }} {{ template.is_active ? '已启用' : '已禁用' }}
</span> </span>
@@ -55,19 +76,50 @@
<!-- 第一行预览在左半部分居中编辑在右半部分居中 --> <!-- 第一行预览在左半部分居中编辑在右半部分居中 -->
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<div class="flex justify-center"> <div class="flex justify-center">
<button @click="previewTemplate(template)" class="md3-button-outlined text-sm flex-shrink-0"> <button
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="md3-button-outlined text-sm flex-shrink-0"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> @click="previewTemplate(template)"
<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
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> </svg>
预览 预览
</button> </button>
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<button @click="editTemplate(template)" class="md3-button-outlined text-sm flex-shrink-0"> <button
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="md3-button-outlined text-sm flex-shrink-0"
<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" /> @click="editTemplate(template)"
>
<svg
class="w-4 h-4 mr-1.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg> </svg>
编辑 编辑
</button> </button>
@@ -78,9 +130,22 @@
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<div></div> <div></div>
<div class="flex justify-center"> <div class="flex justify-center">
<button @click="deleteTemplate(template)" class="md3-button-outlined text-sm !text-red-600 dark:!text-red-500 !border-red-600 dark:!border-red-500 hover:!bg-red-50 dark:hover:!bg-red-900/20 flex-shrink-0"> <button
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="md3-button-outlined text-sm !text-red-600 dark:!text-red-500 !border-red-600 dark:!border-red-500 hover:!bg-red-50 dark:hover:!bg-red-900/20 flex-shrink-0"
<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" /> @click="deleteTemplate(template)"
>
<svg
class="w-4 h-4 mr-1.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg> </svg>
删除 删除
</button> </button>
@@ -96,16 +161,25 @@
:title="dialogMode === 'create' ? '新建模板' : '编辑模板'" :title="dialogMode === 'create' ? '新建模板' : '编辑模板'"
:width="dialogWidth" :width="dialogWidth"
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}" :style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
:maskClosable="false" :mask-closable="false"
class="template-editor-modal" class="template-editor-modal"
> >
<a-form :model="formData" layout="vertical" ref="formRef"> <a-form ref="formRef" :model="formData" layout="vertical">
<a-form-item label="模板名称" required> <a-form-item label="模板名称" required>
<a-input v-model:value="formData.name" placeholder="请输入模板名称" :maxlength="100" show-count /> <a-input
v-model:value="formData.name"
placeholder="请输入模板名称"
:maxlength="100"
show-count
/>
</a-form-item> </a-form-item>
<a-form-item label="模板描述"> <a-form-item label="模板描述">
<a-textarea v-model:value="formData.description" :rows="2" placeholder="请输入模板描述" /> <a-textarea
v-model:value="formData.description"
:rows="2"
placeholder="请输入模板描述"
/>
</a-form-item> </a-form-item>
<a-form-item label="父模板"> <a-form-item label="父模板">
@@ -145,12 +219,8 @@
<p class="text-sm mb-2"> <p class="text-sm mb-2">
<strong>配置即结构</strong>模板配置完全映射到生成的 Payload 结构 <strong>配置即结构</strong>模板配置完全映射到生成的 Payload 结构
</p> </p>
<p class="text-sm mb-2"> <p class="text-sm mb-2"><strong>字段名保持原样</strong>不进行任何大小写转换</p>
<strong>字段名保持原样</strong>不进行任何大小写转换 <p class="text-sm"><strong>ThreadId</strong> 由用户填写无需在模板中配置</p>
</p>
<p class="text-sm">
<strong>ThreadId</strong> 由用户填写无需在模板中配置
</p>
</template> </template>
</a-alert> </a-alert>
@@ -166,20 +236,50 @@
<template #overlay> <template #overlay>
<a-menu @click="handleAddField"> <a-menu @click="handleAddField">
<a-menu-item key="field"> <a-menu-item key="field">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<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" /> 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> </svg>
普通字段 普通字段
</a-menu-item> </a-menu-item>
<a-menu-item key="array"> <a-menu-item key="array">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" /> 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> </svg>
数组字段 数组字段
</a-menu-item> </a-menu-item>
<a-menu-item key="object"> <a-menu-item key="object">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<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" /> 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> </svg>
对象字段 对象字段
</a-menu-item> </a-menu-item>
@@ -189,9 +289,22 @@
</div> </div>
<!-- 递归渲染字段树 --> <!-- 递归渲染字段树 -->
<div v-if="Object.keys(formData.field_config).length === 0" class="text-center py-12 border-2 border-dashed border-outline-variant rounded-lg bg-surface-container"> <div
<svg class="w-16 h-16 mx-auto text-on-surface-variant opacity-40 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> v-if="Object.keys(formData.field_config).length === 0"
<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" /> class="text-center py-12 border-2 border-dashed border-outline-variant rounded-lg bg-surface-container"
>
<svg
class="w-16 h-16 mx-auto text-on-surface-variant opacity-40 mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
<h3 class="text-lg font-semibold text-on-surface mb-2">暂无字段配置</h3> <h3 class="text-lg font-semibold text-on-surface mb-2">暂无字段配置</h3>
<p class="text-sm text-on-surface-variant">点击上方"添加字段"开始配置模板</p> <p class="text-sm text-on-surface-variant">点击上方"添加字段"开始配置模板</p>
@@ -204,9 +317,9 @@
:field-key="key" :field-key="key"
:field-config="config" :field-config="config"
:path="[key]" :path="[key]"
@update="(event) => updateField(event.path, event.value)" @update="event => updateField(event.path, event.value)"
@delete="(path) => deleteField(path)" @delete="path => deleteField(path)"
@move="(event) => moveField(event.path, event.direction)" @move="event => moveField(event.path, event.direction)"
/> />
</div> </div>
</div> </div>
@@ -216,14 +329,16 @@
<span class="text-lg font-bold">JSON 预览</span> <span class="text-lg font-bold">JSON 预览</span>
</a-divider> </a-divider>
<div class="bg-surface-container text-green-400 p-4 rounded-lg font-mono text-sm overflow-auto max-h-96"> <div
class="bg-surface-container text-green-400 p-4 rounded-lg font-mono text-sm overflow-auto max-h-96"
>
<pre>{{ JSON.stringify(formData.field_config, null, 2) }}</pre> <pre>{{ JSON.stringify(formData.field_config, null, 2) }}</pre>
</div> </div>
</a-form> </a-form>
<template #footer> <template #footer>
<a-button @click="dialogVisible = false">取消</a-button> <a-button @click="dialogVisible = false">取消</a-button>
<a-button type="primary" @click="handleSubmit" :loading="submitting"> <a-button type="primary" :loading="submitting" @click="handleSubmit">
{{ dialogMode === 'create' ? '创建' : '更新' }} {{ dialogMode === 'create' ? '创建' : '更新' }}
</a-button> </a-button>
</template> </template>
@@ -265,12 +380,18 @@
<div v-if="previewData" class="space-y-4"> <div v-if="previewData" class="space-y-4">
<div class="bg-surface-container rounded p-4"> <div class="bg-surface-container rounded p-4">
<h4 class="font-semibold mb-2 text-on-surface">生成的 Payload使用默认值</h4> <h4 class="font-semibold mb-2 text-on-surface">生成的 Payload使用默认值</h4>
<pre class="text-xs bg-surface text-on-surface p-3 rounded border border-outline-variant overflow-auto max-h-96">{{ JSON.stringify(previewData.preview_payload, null, 2) }}</pre> <pre
class="text-xs bg-surface text-on-surface p-3 rounded border border-outline-variant overflow-auto max-h-96"
>{{ JSON.stringify(previewData.preview_payload, null, 2) }}</pre
>
</div> </div>
<div class="bg-surface-container rounded p-4"> <div class="bg-surface-container rounded p-4">
<h4 class="font-semibold mb-2 text-on-surface">字段配置</h4> <h4 class="font-semibold mb-2 text-on-surface">字段配置</h4>
<pre class="text-xs bg-surface text-on-surface p-3 rounded border border-outline-variant overflow-auto max-h-96">{{ JSON.stringify(previewData.field_config, null, 2) }}</pre> <pre
class="text-xs bg-surface text-on-surface p-3 rounded border border-outline-variant overflow-auto max-h-96"
>{{ JSON.stringify(previewData.field_config, null, 2) }}</pre
>
</div> </div>
</div> </div>
@@ -284,68 +405,68 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue';
import { message, Modal } from 'ant-design-vue' import { message, Modal } from 'ant-design-vue';
import { DownOutlined } from '@ant-design/icons-vue' import { DownOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue' import Layout from '@/components/Layout.vue';
import FieldTreeNode from '@/components/FieldTreeNode.vue' import FieldTreeNode from '@/components/FieldTreeNode.vue';
import { useTemplateStore } from '@/stores/template' import { useTemplateStore } from '@/stores/template';
import { useBreakpoint } from '@/composables/useBreakpoint' import { useBreakpoint } from '@/composables/useBreakpoint';
const templateStore = useTemplateStore() const templateStore = useTemplateStore();
const { isMobile, isTablet } = useBreakpoint() const { isMobile, isTablet } = useBreakpoint();
// 计算对话框宽度 - 响应式设计 // 计算对话框宽度 - 响应式设计
const dialogWidth = computed(() => { const dialogWidth = computed(() => {
if (isMobile.value) return '100%' if (isMobile.value) return '100%';
if (isTablet.value) return 900 if (isTablet.value) return 900;
return 1200 return 1200;
}) });
const previewDialogWidth = computed(() => { const previewDialogWidth = computed(() => {
if (isMobile.value) return '100%' if (isMobile.value) return '100%';
if (isTablet.value) return 800 if (isTablet.value) return 800;
return 1000 return 1000;
}) });
const templates = ref([]) const templates = ref([]);
const loading = ref(false) const loading = ref(false);
const dialogVisible = ref(false) const dialogVisible = ref(false);
const dialogMode = ref('create') const dialogMode = ref('create');
const currentTemplateId = ref(null) const currentTemplateId = ref(null);
const submitting = ref(false) const submitting = ref(false);
const previewDialogVisible = ref(false) const previewDialogVisible = ref(false);
const previewData = ref(null) const previewData = ref(null);
const addFieldDialogVisible = ref(false) const addFieldDialogVisible = ref(false);
const newFieldName = ref('') const newFieldName = ref('');
const newFieldType = ref('field') const newFieldType = ref('field');
const fieldConfigVersion = ref(0) // 用于强制刷新字段列表 const fieldConfigVersion = ref(0); // 用于强制刷新字段列表
const formData = ref({ const formData = ref({
name: '', name: '',
description: '', description: '',
parent_id: null, parent_id: null,
is_active: true, is_active: true,
field_config: {} field_config: {},
}) });
const availableParentTemplates = computed(() => { const availableParentTemplates = computed(() => {
if (dialogMode.value === 'create') { if (dialogMode.value === 'create') {
return templates.value return templates.value;
} }
return templates.value.filter(t => t.id !== currentTemplateId.value) return templates.value.filter(t => t.id !== currentTemplateId.value);
}) });
const fieldTypeLabel = computed(() => { const fieldTypeLabel = computed(() => {
const labels = { const labels = {
field: '普通字段', field: '普通字段',
array: '数组字段', array: '数组字段',
object: '对象字段' object: '对象字段',
} };
return labels[newFieldType.value] || '字段' return labels[newFieldType.value] || '字段';
}) });
function createDefaultFieldConfig() { function createDefaultFieldConfig() {
return { return {
@@ -356,85 +477,85 @@ function createDefaultFieldConfig() {
hidden: false, hidden: false,
placeholder: '', placeholder: '',
value_type: 'string', value_type: 'string',
options: [] options: [],
} };
} }
const fetchTemplates = async () => { const fetchTemplates = async () => {
loading.value = true loading.value = true;
try { try {
templates.value = await templateStore.fetchTemplates() templates.value = await templateStore.fetchTemplates();
} catch (error) { } catch (error) {
message.error(error.message || '获取模板列表失败') message.error(error.message || '获取模板列表失败');
} finally { } finally {
loading.value = false loading.value = false;
} }
} };
const showCreateDialog = () => { const showCreateDialog = () => {
dialogMode.value = 'create' dialogMode.value = 'create';
currentTemplateId.value = null currentTemplateId.value = null;
formData.value = { formData.value = {
name: '', name: '',
description: '', description: '',
parent_id: null, parent_id: null,
is_active: true, is_active: true,
field_config: {} field_config: {},
} };
dialogVisible.value = true dialogVisible.value = true;
} };
const editTemplate = (template) => { const editTemplate = template => {
dialogMode.value = 'edit' dialogMode.value = 'edit';
currentTemplateId.value = template.id currentTemplateId.value = template.id;
const fieldConfig = JSON.parse(template.field_config) const fieldConfig = JSON.parse(template.field_config);
formData.value = { formData.value = {
name: template.name, name: template.name,
description: template.description || '', description: template.description || '',
parent_id: template.parent_id || null, parent_id: template.parent_id || null,
is_active: template.is_active, is_active: template.is_active,
field_config: fieldConfig field_config: fieldConfig,
} };
dialogVisible.value = true dialogVisible.value = true;
} };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!formData.value.name) { if (!formData.value.name) {
message.warning('请输入模板名称') message.warning('请输入模板名称');
return return;
} }
submitting.value = true submitting.value = true;
try { try {
const templateData = { const templateData = {
name: formData.value.name, name: formData.value.name,
description: formData.value.description, description: formData.value.description,
parent_id: formData.value.parent_id, parent_id: formData.value.parent_id,
is_active: formData.value.is_active, is_active: formData.value.is_active,
field_config: JSON.stringify(formData.value.field_config) field_config: JSON.stringify(formData.value.field_config),
} };
if (dialogMode.value === 'create') { if (dialogMode.value === 'create') {
await templateStore.createTemplate(templateData) await templateStore.createTemplate(templateData);
message.success('模板创建成功') message.success('模板创建成功');
} else { } else {
await templateStore.updateTemplate(currentTemplateId.value, templateData) await templateStore.updateTemplate(currentTemplateId.value, templateData);
message.success('模板更新成功') message.success('模板更新成功');
} }
dialogVisible.value = false dialogVisible.value = false;
await fetchTemplates() await fetchTemplates();
} catch (error) { } catch (error) {
message.error(error.message || '操作失败') message.error(error.message || '操作失败');
} finally { } finally {
submitting.value = false submitting.value = false;
} }
} };
const deleteTemplate = (template) => { const deleteTemplate = template => {
Modal.confirm({ Modal.confirm({
title: '确认删除', title: '确认删除',
content: `确定要删除模板"${template.name}"吗?此操作不可撤销。`, content: `确定要删除模板"${template.name}"吗?此操作不可撤销。`,
@@ -443,224 +564,224 @@ const deleteTemplate = (template) => {
okType: 'danger', okType: 'danger',
onOk: async () => { onOk: async () => {
try { try {
await templateStore.deleteTemplate(template.id) await templateStore.deleteTemplate(template.id);
message.success('模板删除成功') message.success('模板删除成功');
await fetchTemplates() await fetchTemplates();
} catch (error) { } catch (error) {
message.error(error.message || '删除失败') message.error(error.message || '删除失败');
} }
}, },
}) });
} };
const previewTemplate = async (template) => { const previewTemplate = async template => {
try { try {
previewData.value = await templateStore.previewTemplate(template.id) previewData.value = await templateStore.previewTemplate(template.id);
previewDialogVisible.value = true previewDialogVisible.value = true;
} catch (error) { } catch (error) {
message.error(error.message || '预览失败') message.error(error.message || '预览失败');
} }
} };
const handleAddField = ({ key }) => { const handleAddField = ({ key }) => {
newFieldType.value = key newFieldType.value = key;
newFieldName.value = '' newFieldName.value = '';
addFieldDialogVisible.value = true addFieldDialogVisible.value = true;
} };
const confirmAddField = () => { const confirmAddField = () => {
if (!newFieldName.value) { if (!newFieldName.value) {
message.warning('请输入字段名') message.warning('请输入字段名');
return return;
} }
if (formData.value.field_config[newFieldName.value]) { if (formData.value.field_config[newFieldName.value]) {
message.warning('该字段已存在') message.warning('该字段已存在');
return return;
} }
// 创建一个新对象,确保新字段被添加到末尾 // 创建一个新对象,确保新字段被添加到末尾
const newConfig = { ...formData.value.field_config } const newConfig = { ...formData.value.field_config };
// 创建对应类型的字段 // 创建对应类型的字段
if (newFieldType.value === 'field') { if (newFieldType.value === 'field') {
newConfig[newFieldName.value] = createDefaultFieldConfig() newConfig[newFieldName.value] = createDefaultFieldConfig();
} else if (newFieldType.value === 'array') { } else if (newFieldType.value === 'array') {
newConfig[newFieldName.value] = [] newConfig[newFieldName.value] = [];
} else if (newFieldType.value === 'object') { } else if (newFieldType.value === 'object') {
newConfig[newFieldName.value] = {} newConfig[newFieldName.value] = {};
} }
// 替换整个 field_config 以确保顺序和响应性 // 替换整个 field_config 以确保顺序和响应性
formData.value.field_config = newConfig formData.value.field_config = newConfig;
fieldConfigVersion.value++ // 强制刷新 fieldConfigVersion.value++; // 强制刷新
addFieldDialogVisible.value = false addFieldDialogVisible.value = false;
message.success('字段添加成功') message.success('字段添加成功');
} };
const updateField = (path, newValue) => { const updateField = (path, newValue) => {
// 通过路径更新嵌套字段 // 通过路径更新嵌套字段
let target = formData.value.field_config let target = formData.value.field_config;
for (let i = 0; i < path.length - 1; i++) { for (let i = 0; i < path.length - 1; i++) {
target = target[path[i]] target = target[path[i]];
} }
target[path[path.length - 1]] = newValue target[path[path.length - 1]] = newValue;
} };
const deleteField = (path) => { const deleteField = path => {
// 通过路径删除嵌套字段 // 通过路径删除嵌套字段
if (!path || path.length === 0) return if (!path || path.length === 0) return;
// 创建一个新的 field_config 副本以触发响应性 // 创建一个新的 field_config 副本以触发响应性
const newConfig = JSON.parse(JSON.stringify(formData.value.field_config)) const newConfig = JSON.parse(JSON.stringify(formData.value.field_config));
let target = newConfig let target = newConfig;
// 导航到父对象/数组 // 导航到父对象/数组
for (let i = 0; i < path.length - 1; i++) { for (let i = 0; i < path.length - 1; i++) {
if (!target || typeof target !== 'object') { if (!target || typeof target !== 'object') {
console.error('❌ 删除失败:路径无效', path, 'at index', i) console.error('❌ 删除失败:路径无效', path, 'at index', i);
return return;
} }
target = target[path[i]] target = target[path[i]];
} }
if (!target || typeof target !== 'object') { if (!target || typeof target !== 'object') {
console.error('❌ 删除失败:父对象不存在', path) console.error('❌ 删除失败:父对象不存在', path);
return return;
} }
const lastKey = path[path.length - 1] const lastKey = path[path.length - 1];
// 如果父容器是数组,使用 splice;如果是对象,使用 delete // 如果父容器是数组,使用 splice;如果是对象,使用 delete
if (Array.isArray(target)) { if (Array.isArray(target)) {
target.splice(lastKey, 1) target.splice(lastKey, 1);
} else { } else {
delete target[lastKey] delete target[lastKey];
} }
// 替换整个 field_config 以触发 Vue 响应性 // 替换整个 field_config 以触发 Vue 响应性
formData.value.field_config = newConfig formData.value.field_config = newConfig;
fieldConfigVersion.value++ // 强制刷新 fieldConfigVersion.value++; // 强制刷新
} };
const moveField = (path, direction) => { const moveField = (path, direction) => {
// 通过路径移动字段 // 通过路径移动字段
if (!path || path.length === 0) return if (!path || path.length === 0) return;
// 如果是根级别字段,直接重建整个 field_config // 如果是根级别字段,直接重建整个 field_config
if (path.length === 1) { if (path.length === 1) {
const fieldKey = path[0] const fieldKey = path[0];
const keys = Object.keys(formData.value.field_config) const keys = Object.keys(formData.value.field_config);
const currentIndex = keys.indexOf(fieldKey) const currentIndex = keys.indexOf(fieldKey);
if (currentIndex === -1) { if (currentIndex === -1) {
console.error('❌ 字段不存在:', fieldKey) console.error('❌ 字段不存在:', fieldKey);
return return;
} }
let targetIndex = currentIndex let targetIndex = currentIndex;
if (direction === 'up' && currentIndex > 0) { if (direction === 'up' && currentIndex > 0) {
targetIndex = currentIndex - 1 targetIndex = currentIndex - 1;
} else if (direction === 'down' && currentIndex < keys.length - 1) { } else if (direction === 'down' && currentIndex < keys.length - 1) {
targetIndex = currentIndex + 1 targetIndex = currentIndex + 1;
} else { } else {
return return;
} }
// 交换键的位置 // 交换键的位置
const temp = keys[currentIndex] const temp = keys[currentIndex];
keys[currentIndex] = keys[targetIndex] keys[currentIndex] = keys[targetIndex];
keys[targetIndex] = temp keys[targetIndex] = temp;
// 重建整个 field_config - 使用深拷贝确保完全新的对象 // 重建整个 field_config - 使用深拷贝确保完全新的对象
const newConfig = {} const newConfig = {};
keys.forEach(key => { keys.forEach(key => {
// 深拷贝每个字段配置 // 深拷贝每个字段配置
newConfig[key] = JSON.parse(JSON.stringify(formData.value.field_config[key])) newConfig[key] = JSON.parse(JSON.stringify(formData.value.field_config[key]));
}) });
// 替换整个 formData,而不只是 field_config // 替换整个 formData,而不只是 field_config
formData.value = { formData.value = {
...formData.value, ...formData.value,
field_config: newConfig field_config: newConfig,
} };
fieldConfigVersion.value++ fieldConfigVersion.value++;
return return;
} }
// 嵌套字段的情况(保留原有逻辑) // 嵌套字段的情况(保留原有逻辑)
const newConfig = JSON.parse(JSON.stringify(formData.value.field_config)) const newConfig = JSON.parse(JSON.stringify(formData.value.field_config));
// 导航到目标的父容器 // 导航到目标的父容器
let parent = newConfig let parent = newConfig;
for (let i = 0; i < path.length - 1; i++) { for (let i = 0; i < path.length - 1; i++) {
parent = parent[path[i]] parent = parent[path[i]];
if (!parent) { if (!parent) {
console.error('❌ 路径无效:', path) console.error('❌ 路径无效:', path);
return return;
} }
} }
const fieldKey = path[path.length - 1] const fieldKey = path[path.length - 1];
if (Array.isArray(parent)) { if (Array.isArray(parent)) {
// 数组情况:直接交换元素 // 数组情况:直接交换元素
const index = Number(fieldKey) const index = Number(fieldKey);
if (direction === 'up' && index > 0) { if (direction === 'up' && index > 0) {
const temp = parent[index] const temp = parent[index];
parent[index] = parent[index - 1] parent[index] = parent[index - 1];
parent[index - 1] = temp parent[index - 1] = temp;
} else if (direction === 'down' && index < parent.length - 1) { } else if (direction === 'down' && index < parent.length - 1) {
const temp = parent[index] const temp = parent[index];
parent[index] = parent[index + 1] parent[index] = parent[index + 1];
parent[index + 1] = temp parent[index + 1] = temp;
} else { } else {
return return;
} }
} else { } else {
// 对象情况:重建对象以改变键顺序 // 对象情况:重建对象以改变键顺序
const keys = Object.keys(parent) const keys = Object.keys(parent);
const currentIndex = keys.indexOf(fieldKey) const currentIndex = keys.indexOf(fieldKey);
if (currentIndex === -1) { if (currentIndex === -1) {
console.error('❌ 字段不存在:', fieldKey) console.error('❌ 字段不存在:', fieldKey);
return return;
} }
let targetIndex = currentIndex let targetIndex = currentIndex;
if (direction === 'up' && currentIndex > 0) { if (direction === 'up' && currentIndex > 0) {
targetIndex = currentIndex - 1 targetIndex = currentIndex - 1;
} else if (direction === 'down' && currentIndex < keys.length - 1) { } else if (direction === 'down' && currentIndex < keys.length - 1) {
targetIndex = currentIndex + 1 targetIndex = currentIndex + 1;
} else { } else {
return return;
} }
// 交换键数组中的位置 // 交换键数组中的位置
const temp = keys[currentIndex] const temp = keys[currentIndex];
keys[currentIndex] = keys[targetIndex] keys[currentIndex] = keys[targetIndex];
keys[targetIndex] = temp keys[targetIndex] = temp;
// 重建父对象 // 重建父对象
const reorderedParent = {} const reorderedParent = {};
keys.forEach(key => { keys.forEach(key => {
reorderedParent[key] = parent[key] reorderedParent[key] = parent[key];
}) });
// 替换父容器的所有属性 // 替换父容器的所有属性
Object.keys(parent).forEach(key => delete parent[key]) Object.keys(parent).forEach(key => delete parent[key]);
Object.assign(parent, reorderedParent) Object.assign(parent, reorderedParent);
} }
// 强制触发响应性更新 // 强制触发响应性更新
formData.value.field_config = newConfig formData.value.field_config = newConfig;
fieldConfigVersion.value++ fieldConfigVersion.value++;
} };
onMounted(() => { onMounted(() => {
fetchTemplates() fetchTemplates();
}) });
</script> </script>
<style scoped> <style scoped>
+158 -156
View File
@@ -22,13 +22,13 @@
</template> </template>
<!-- Tab 切换 --> <!-- Tab 切换 -->
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange"> <a-tabs v-model:active-key="activeTab" @change="handleTabChange">
<!-- 待审批用户 Tab --> <!-- 待审批用户 Tab -->
<a-tab-pane key="pending" tab="待审批用户"> <a-tab-pane key="pending" tab="待审批用户">
<!-- 桌面端表格 --> <!-- 桌面端表格 -->
<a-table <a-table
v-if="!isMobile" v-if="!isMobile"
:dataSource="pendingUsers" :data-source="pendingUsers"
:columns="pendingColumns" :columns="pendingColumns"
:loading="loading" :loading="loading"
:row-key="record => record.id" :row-key="record => record.id"
@@ -44,9 +44,7 @@
<a-button type="primary" size="small" @click="handleApprove(record)"> <a-button type="primary" size="small" @click="handleApprove(record)">
通过 通过
</a-button> </a-button>
<a-button danger size="small" @click="handleReject(record)"> <a-button danger size="small" @click="handleReject(record)"> 拒绝 </a-button>
拒绝
</a-button>
</a-space> </a-space>
</template> </template>
</template> </template>
@@ -59,10 +57,14 @@
<a-descriptions-item label="ID">{{ user.id }}</a-descriptions-item> <a-descriptions-item label="ID">{{ user.id }}</a-descriptions-item>
<a-descriptions-item label="用户名">{{ user.alias }}</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="邮箱">{{ user.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="注册时间">{{ formatDateTime(user.created_at) }}</a-descriptions-item> <a-descriptions-item label="注册时间">{{
formatDateTime(user.created_at)
}}</a-descriptions-item>
</a-descriptions> </a-descriptions>
<a-space class="mt-3" style="width: 100%"> <a-space class="mt-3" style="width: 100%">
<a-button type="primary" size="small" block @click="handleApprove(user)">通过</a-button> <a-button type="primary" size="small" block @click="handleApprove(user)"
>通过</a-button
>
<a-button danger size="small" block @click="handleReject(user)">拒绝</a-button> <a-button danger size="small" block @click="handleReject(user)">拒绝</a-button>
</a-space> </a-space>
</a-card> </a-card>
@@ -75,7 +77,7 @@
<!-- 桌面端表格 --> <!-- 桌面端表格 -->
<a-table <a-table
v-if="!isMobile" v-if="!isMobile"
:dataSource="userStore.users" :data-source="userStore.users"
:columns="allColumns" :columns="allColumns"
:loading="loading" :loading="loading"
:row-key="record => record.id" :row-key="record => record.id"
@@ -95,7 +97,11 @@
</a-tag> </a-tag>
</template> </template>
<template v-else-if="column.key === 'jwt_exp'"> <template v-else-if="column.key === 'jwt_exp'">
{{ record.jwt_exp && record.jwt_exp !== '0' ? formatDateTime(parseInt(record.jwt_exp) * 1000) : '-' }} {{
record.jwt_exp && record.jwt_exp !== '0'
? formatDateTime(parseInt(record.jwt_exp) * 1000)
: '-'
}}
</template> </template>
<template v-else-if="column.key === 'created_at'"> <template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }} {{ formatDateTime(record.created_at) }}
@@ -105,9 +111,7 @@
<a-button type="primary" size="small" @click="handleEdit(record)"> <a-button type="primary" size="small" @click="handleEdit(record)">
编辑 编辑
</a-button> </a-button>
<a-button danger size="small" @click="handleDelete(record)"> <a-button danger size="small" @click="handleDelete(record)"> 删除 </a-button>
删除
</a-button>
</a-space> </a-space>
</template> </template>
</template> </template>
@@ -115,7 +119,12 @@
<!-- 移动端卡片视图 --> <!-- 移动端卡片视图 -->
<a-space v-else direction="vertical" :size="16" style="width: 100%"> <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-card
v-for="user in userStore.users"
:key="user.id"
size="small"
:loading="loading"
>
<a-descriptions :column="1" size="small" bordered> <a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="ID">{{ user.id }}</a-descriptions-item> <a-descriptions-item label="ID">{{ user.id }}</a-descriptions-item>
<a-descriptions-item label="用户名">{{ user.alias }}</a-descriptions-item> <a-descriptions-item label="用户名">{{ user.alias }}</a-descriptions-item>
@@ -131,32 +140,38 @@
</a-tag> </a-tag>
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="Token过期"> <a-descriptions-item label="Token过期">
{{ user.jwt_exp && user.jwt_exp !== '0' ? formatDateTime(parseInt(user.jwt_exp) * 1000) : '-' }} {{
user.jwt_exp && user.jwt_exp !== '0'
? formatDateTime(parseInt(user.jwt_exp) * 1000)
: '-'
}}
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="创建时间">{{ formatDateTime(user.created_at) }}</a-descriptions-item> <a-descriptions-item label="创建时间">{{
formatDateTime(user.created_at)
}}</a-descriptions-item>
</a-descriptions> </a-descriptions>
<a-space class="mt-3" style="width: 100%"> <a-space class="mt-3" style="width: 100%">
<a-button type="primary" size="small" block @click="handleEdit(user)">编辑</a-button> <a-button type="primary" size="small" block @click="handleEdit(user)"
>编辑</a-button
>
<a-button danger size="small" block @click="handleDelete(user)">删除</a-button> <a-button danger size="small" block @click="handleDelete(user)">删除</a-button>
</a-space> </a-space>
</a-card> </a-card>
</a-space> </a-space>
<!-- 批量操作 --> <!-- 批量操作 -->
<div class="batch-actions" v-if="selectedUsers.length > 0"> <div v-if="selectedUsers.length > 0" class="batch-actions">
<a-alert <a-alert
:message="`已选择 ${selectedUsers.length} 个用户`" :message="`已选择 ${selectedUsers.length} 个用户`"
type="info" type="info"
:closable="false" :closable="false"
> >
<template #description> <template #description>
<a-space style="margin-top: 10px;"> <a-space style="margin-top: 10px">
<a-button type="primary" size="small" @click="handleBatchApprove"> <a-button type="primary" size="small" @click="handleBatchApprove">
批量审批 批量审批
</a-button> </a-button>
<a-button danger size="small" @click="handleBatchDelete"> <a-button danger size="small" @click="handleBatchDelete"> 批量删除 </a-button>
批量删除
</a-button>
</a-space> </a-space>
</template> </template>
</a-alert> </a-alert>
@@ -167,17 +182,12 @@
<!-- 创建/编辑用户对话框 --> <!-- 创建/编辑用户对话框 -->
<a-modal <a-modal
:title="dialogMode === 'create' ? '创建用户' : '编辑用户'"
v-model:open="dialogVisible" v-model:open="dialogVisible"
:title="dialogMode === 'create' ? '创建用户' : '编辑用户'"
:width="isMobile ? '100%' : 600" :width="isMobile ? '100%' : 600"
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}" :style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
> >
<a-form <a-form ref="formRef" :model="formData" :rules="formRules" layout="vertical">
ref="formRef"
:model="formData"
:rules="formRules"
layout="vertical"
>
<a-form-item label="用户名" name="alias"> <a-form-item label="用户名" name="alias">
<a-input v-model:value="formData.alias" placeholder="请输入用户名" /> <a-input v-model:value="formData.alias" placeholder="请输入用户名" />
</a-form-item> </a-form-item>
@@ -203,14 +213,12 @@
v-model:value="formData.password" v-model:value="formData.password"
:placeholder="dialogMode === 'create' ? '请输入密码' : '留空则不修改密码'" :placeholder="dialogMode === 'create' ? '请输入密码' : '留空则不修改密码'"
/> />
<span class="form-hint" v-if="dialogMode === 'edit'"> <span v-if="dialogMode === 'edit'" class="form-hint"> 留空则不修改密码 </span>
留空则不修改密码
</span>
</a-form-item> </a-form-item>
<a-form-item label="重置密码" v-if="dialogMode === 'edit'"> <a-form-item v-if="dialogMode === 'edit'" label="重置密码">
<a-switch v-model:checked="formData.reset_password" /> <a-switch v-model:checked="formData.reset_password" />
<span class="form-hint-danger" v-if="formData.reset_password"> <span v-if="formData.reset_password" class="form-hint-danger">
⚠️ 将重置为默认密码 ⚠️ 将重置为默认密码
</span> </span>
</a-form-item> </a-form-item>
@@ -218,9 +226,7 @@
<template #footer> <template #footer>
<a-button @click="dialogVisible = false">取消</a-button> <a-button @click="dialogVisible = false">取消</a-button>
<a-button type="primary" @click="handleSubmit" :loading="submitting"> <a-button type="primary" :loading="submitting" @click="handleSubmit"> 确定 </a-button>
确定
</a-button>
</template> </template>
</a-modal> </a-modal>
</div> </div>
@@ -228,31 +234,29 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, onMounted } from 'vue';
import { message, Modal } from 'ant-design-vue' import { message, Modal } from 'ant-design-vue';
import { UserOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue' import { UserOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue' import Layout from '@/components/Layout.vue';
import { useBreakpoint } from '@/composables/useBreakpoint' import { useBreakpoint } from '@/composables/useBreakpoint';
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user';
import { useAdminStore } from '@/stores/admin' import { adminAPI } from '@/api/index';
import { adminAPI } from '@/api/index'
const userStore = useUserStore() const userStore = useUserStore();
const adminStore = useAdminStore() const { isMobile } = useBreakpoint();
const { isMobile } = useBreakpoint()
// 状态 // 状态
const loading = ref(false) const loading = ref(false);
const activeTab = ref('all') // 默认展示所有用户 const activeTab = ref('all'); // 默认展示所有用户
const pendingUsers = ref([]) const pendingUsers = ref([]);
const selectedUsers = ref([]) const selectedUsers = ref([]);
const selectedRowKeys = ref([]) const selectedRowKeys = ref([]);
const dialogVisible = ref(false) const dialogVisible = ref(false);
const dialogMode = ref('create') const dialogMode = ref('create');
const submitting = ref(false) const submitting = ref(false);
// 表单 // 表单
const formRef = ref(null) const formRef = ref(null);
const formData = ref({ const formData = ref({
alias: '', alias: '',
role: 'user', role: 'user',
@@ -260,7 +264,7 @@ const formData = ref({
email: '', email: '',
password: '', password: '',
reset_password: false, reset_password: false,
}) });
// 表单验证规则 // 表单验证规则
const formRules = { const formRules = {
@@ -269,15 +273,13 @@ const formRules = {
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }, { min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
], ],
role: [{ required: true, message: '请选择角色', trigger: 'change' }], role: [{ required: true, message: '请选择角色', trigger: 'change' }],
email: [ email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }, };
],
}
// 时间格式化 // 时间格式化
const formatDateTime = (timestamp) => { const formatDateTime = timestamp => {
if (!timestamp) return '-' if (!timestamp) return '-';
const date = new Date(timestamp) const date = new Date(timestamp);
return date.toLocaleString('zh-CN', { return date.toLocaleString('zh-CN', {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
@@ -285,8 +287,8 @@ const formatDateTime = (timestamp) => {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
second: '2-digit', second: '2-digit',
}) });
} };
// 待审批用户表格列 // 待审批用户表格列
const pendingColumns = [ const pendingColumns = [
@@ -295,7 +297,7 @@ const pendingColumns = [
{ title: '邮箱', dataIndex: 'email', key: 'email', ellipsis: true }, { title: '邮箱', dataIndex: 'email', key: 'email', ellipsis: true },
{ title: '注册时间', dataIndex: 'created_at', key: 'created_at', width: 180 }, { title: '注册时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 200, fixed: 'right' }, { title: '操作', key: 'actions', width: 200, fixed: 'right' },
] ];
// 所有用户表格列 // 所有用户表格列
const allColumns = [ const allColumns = [
@@ -307,40 +309,40 @@ const allColumns = [
{ title: 'Token 过期时间', dataIndex: 'jwt_exp', key: 'jwt_exp', width: 180 }, { title: 'Token 过期时间', dataIndex: 'jwt_exp', key: 'jwt_exp', width: 180 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 }, { title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 200, fixed: 'right' }, { title: '操作', key: 'actions', width: 200, fixed: 'right' },
] ];
// 行选择配置 // 行选择配置
const rowSelection = { const rowSelection = {
selectedRowKeys: selectedRowKeys, selectedRowKeys: selectedRowKeys,
onChange: (keys, rows) => { onChange: (keys, rows) => {
selectedRowKeys.value = keys selectedRowKeys.value = keys;
selectedUsers.value = rows selectedUsers.value = rows;
}, },
} };
// 获取待审批用户 // 获取待审批用户
const fetchPendingUsers = async () => { const fetchPendingUsers = async () => {
loading.value = true loading.value = true;
try { try {
pendingUsers.value = await adminAPI.getPendingUsers() pendingUsers.value = await adminAPI.getPendingUsers();
} catch (error) { } catch (error) {
message.error(error.message || '获取待审批用户失败') message.error(error.message || '获取待审批用户失败');
} finally { } finally {
loading.value = false loading.value = false;
} }
} };
// Tab 切换 // Tab 切换
const handleTabChange = (tab) => { const handleTabChange = tab => {
if (tab === 'pending') { if (tab === 'pending') {
fetchPendingUsers() fetchPendingUsers();
} else { } else {
handleRefresh() handleRefresh();
} }
} };
// 审批通过用户 // 审批通过用户
const handleApprove = async (user) => { const handleApprove = async user => {
Modal.confirm({ Modal.confirm({
title: '审批确认', title: '审批确认',
content: `确认通过用户 "${user.alias}" 的审批吗?`, content: `确认通过用户 "${user.alias}" 的审批吗?`,
@@ -348,18 +350,18 @@ const handleApprove = async (user) => {
cancelText: '取消', cancelText: '取消',
onOk: async () => { onOk: async () => {
try { try {
await adminAPI.approveUser(user.id) await adminAPI.approveUser(user.id);
message.success('审批成功') message.success('审批成功');
fetchPendingUsers() fetchPendingUsers();
} catch (error) { } catch (error) {
message.error(error.message || '审批失败') message.error(error.message || '审批失败');
} }
}, },
}) });
} };
// 拒绝用户 // 拒绝用户
const handleReject = async (user) => { const handleReject = async user => {
Modal.confirm({ Modal.confirm({
title: '拒绝确认', title: '拒绝确认',
content: `确认拒绝用户 "${user.alias}" 的申请吗?拒绝后将删除该用户。`, content: `确认拒绝用户 "${user.alias}" 的申请吗?拒绝后将删除该用户。`,
@@ -368,36 +370,36 @@ const handleReject = async (user) => {
okType: 'danger', okType: 'danger',
onOk: async () => { onOk: async () => {
try { try {
await adminAPI.rejectUser(user.id) await adminAPI.rejectUser(user.id);
message.success('已拒绝并删除用户') message.success('已拒绝并删除用户');
fetchPendingUsers() fetchPendingUsers();
} catch (error) { } catch (error) {
message.error(error.message || '操作失败') message.error(error.message || '操作失败');
} }
}, },
}) });
} };
// 刷新数据 // 刷新数据
const handleRefresh = async () => { const handleRefresh = async () => {
if (activeTab.value === 'pending') { if (activeTab.value === 'pending') {
await fetchPendingUsers() await fetchPendingUsers();
} else { } else {
loading.value = true loading.value = true;
try { try {
await userStore.fetchUsers() await userStore.fetchUsers();
message.success('刷新成功') message.success('刷新成功');
} catch (error) { } catch (error) {
message.error(error.message || '刷新失败') message.error(error.message || '刷新失败');
} finally { } finally {
loading.value = false loading.value = false;
} }
} }
} };
// 创建用户 // 创建用户
const handleCreate = () => { const handleCreate = () => {
dialogMode.value = 'create' dialogMode.value = 'create';
formData.value = { formData.value = {
alias: '', alias: '',
role: 'user', role: 'user',
@@ -405,13 +407,13 @@ const handleCreate = () => {
email: '', email: '',
password: '', password: '',
reset_password: false, reset_password: false,
} };
dialogVisible.value = true dialogVisible.value = true;
} };
// 编辑用户 // 编辑用户
const handleEdit = (user) => { const handleEdit = user => {
dialogMode.value = 'edit' dialogMode.value = 'edit';
formData.value = { formData.value = {
id: user.id, id: user.id,
alias: user.alias, alias: user.alias,
@@ -420,44 +422,44 @@ const handleEdit = (user) => {
email: user.email || '', email: user.email || '',
password: '', password: '',
reset_password: false, reset_password: false,
} };
dialogVisible.value = true dialogVisible.value = true;
} };
// 提交表单 // 提交表单
const handleSubmit = async () => { const handleSubmit = async () => {
if (!formRef.value) return if (!formRef.value) return;
try { try {
await formRef.value.validate() await formRef.value.validate();
submitting.value = true submitting.value = true;
// 检查密码设置冲突 // 检查密码设置冲突
if (dialogMode.value === 'edit' && formData.value.password && formData.value.reset_password) { if (dialogMode.value === 'edit' && formData.value.password && formData.value.reset_password) {
message.warning('不能同时设置新密码和重置密码,请选择其一') message.warning('不能同时设置新密码和重置密码,请选择其一');
submitting.value = false submitting.value = false;
return return;
} }
if (dialogMode.value === 'create') { if (dialogMode.value === 'create') {
await userStore.createUser(formData.value) await userStore.createUser(formData.value);
message.success('创建成功') message.success('创建成功');
} else { } else {
await userStore.updateUser(formData.value.id, formData.value) await userStore.updateUser(formData.value.id, formData.value);
message.success('更新成功') message.success('更新成功');
} }
dialogVisible.value = false dialogVisible.value = false;
await handleRefresh() await handleRefresh();
} catch (error) { } catch (error) {
message.error(error.message || '操作失败') message.error(error.message || '操作失败');
} finally { } finally {
submitting.value = false submitting.value = false;
} }
} };
// 删除用户 // 删除用户
const handleDelete = (user) => { const handleDelete = user => {
Modal.confirm({ Modal.confirm({
title: '警告', title: '警告',
content: `确定要删除用户 "${user.alias}" `, content: `确定要删除用户 "${user.alias}" `,
@@ -466,15 +468,15 @@ const handleDelete = (user) => {
okType: 'danger', okType: 'danger',
onOk: async () => { onOk: async () => {
try { try {
await userStore.deleteUser(user.id) await userStore.deleteUser(user.id);
message.success('删除成功') message.success('删除成功');
await handleRefresh() await handleRefresh();
} catch (error) { } catch (error) {
message.error(error.message || '删除失败') message.error(error.message || '删除失败');
} }
}, },
}) });
} };
// 批量审批 // 批量审批
const handleBatchApprove = () => { const handleBatchApprove = () => {
@@ -484,24 +486,24 @@ const handleBatchApprove = () => {
okText: '确认', okText: '确认',
cancelText: '取消', cancelText: '取消',
onOk: async () => { onOk: async () => {
const userIds = selectedUsers.value.map((u) => u.id) const userIds = selectedUsers.value.map(u => u.id);
let successCount = 0 let successCount = 0;
let failureCount = 0 let failureCount = 0;
for (const userId of userIds) { for (const userId of userIds) {
try { try {
await adminAPI.approveUser(userId) await adminAPI.approveUser(userId);
successCount++ successCount++;
} catch (error) { } catch {
failureCount++ failureCount++;
} }
} }
message.success(`批量审批完成成功 ${successCount}失败 ${failureCount}`) message.success(`批量审批完成成功 ${successCount}失败 ${failureCount}`);
await handleRefresh() await handleRefresh();
}, },
}) });
} };
// 批量删除 // 批量删除
const handleBatchDelete = () => { const handleBatchDelete = () => {
@@ -512,29 +514,29 @@ const handleBatchDelete = () => {
cancelText: '取消', cancelText: '取消',
okType: 'danger', okType: 'danger',
onOk: async () => { onOk: async () => {
const userIds = selectedUsers.value.map((u) => u.id) const userIds = selectedUsers.value.map(u => u.id);
let successCount = 0 let successCount = 0;
let failureCount = 0 let failureCount = 0;
for (const userId of userIds) { for (const userId of userIds) {
try { try {
await userStore.deleteUser(userId) await userStore.deleteUser(userId);
successCount++ successCount++;
} catch (error) { } catch {
failureCount++ failureCount++;
} }
} }
message.success(`批量删除完成成功 ${successCount}失败 ${failureCount}`) message.success(`批量删除完成成功 ${successCount}失败 ${failureCount}`);
await handleRefresh() await handleRefresh();
}, },
}) });
} };
onMounted(() => { onMounted(() => {
// 默认加载所有用户 // 默认加载所有用户
handleRefresh() handleRefresh();
}) });
</script> </script>
<style scoped> <style scoped>
+3 -6
View File
@@ -1,10 +1,7 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: 'class', // 启用 class 模式的暗色模式 darkMode: 'class', // 启用 class 模式的暗色模式
content: [ content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: { theme: {
extend: { extend: {
colors: { colors: {
@@ -62,7 +59,7 @@ export default {
// Material Design 3 Shape System // Material Design 3 Shape System
'md3-xs': '4px', // Extra Small - chips, small tags 'md3-xs': '4px', // Extra Small - chips, small tags
'md3-sm': '8px', // Small - text fields, small components 'md3-sm': '8px', // Small - text fields, small components
'md3': '12px', // Medium - cards, buttons (default) md3: '12px', // Medium - cards, buttons (default)
'md3-lg': '16px', // Large - large cards, dialogs 'md3-lg': '16px', // Large - large cards, dialogs
'md3-xl': '28px', // Extra Large - fully rounded buttons 'md3-xl': '28px', // Extra Large - fully rounded buttons
'md3-full': '9999px', // Full - pill shape 'md3-full': '9999px', // Full - pill shape
@@ -98,4 +95,4 @@ export default {
}, },
}, },
plugins: [], plugins: [],
} };
+6 -6
View File
@@ -1,6 +1,6 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue';
import path from 'path' import path from 'path';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
@@ -33,13 +33,13 @@ export default defineConfig({
if (id.includes('node_modules')) { if (id.includes('node_modules')) {
// Ant Design Vue // Ant Design Vue
if (id.includes('ant-design-vue')) { if (id.includes('ant-design-vue')) {
return 'ant-design-vue' return 'ant-design-vue';
} }
// Group all other vendor code together // Group all other vendor code together
return 'vendor' return 'vendor';
} }
}, },
}, },
}, },
}, },
}) });