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,
)
from backend.services.auth_service import AuthService
from backend.exceptions import BusinessLogicError
router = APIRouter()
@@ -38,9 +39,10 @@ async def request_qrcode(
if reg_cookie:
if not registration_manager.check_registration_cookie(reg_cookie):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="注册过于频繁,请 10 分钟后再试"
raise BusinessLogicError(
message="注册过于频繁,请 10 分钟后再试",
error_code="RATE_LIMIT_EXCEEDED",
status_code=429
)
else:
# 生成新的 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.task_service import TaskService
from backend.dependencies import get_current_user, get_current_admin_user
from backend.exceptions import ValidationError, AuthorizationError, ResourceNotFoundError
router = APIRouter()
@@ -21,18 +22,15 @@ async def create_user(
"""
创建用户(需要管理员权限)
- **jwt_sub**: QQ 扫码登录的唯一用户标识
- **alias**: 用户别名(用于登录)
- **role**: 角色(可选,默认 "user"
- **email**: 邮箱地址(可选)
"""
try:
user = UserService.create_user(user_data, db)
return user
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
raise ValidationError(str(e))
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -51,7 +49,6 @@ async def get_current_user_info(
user_dict = {
"id": current_user.id,
"alias": current_user.alias,
"jwt_sub": current_user.jwt_sub,
"role": current_user.role,
"is_approved": current_user.is_approved,
"jwt_exp": current_user.jwt_exp,
@@ -99,10 +96,7 @@ async def update_current_user_profile(
user = UserService.update_user_profile(current_user.id, profile_data, db)
return user
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
raise ValidationError(str(e))
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -147,7 +141,6 @@ async def get_current_user_token_status(
return {
"is_valid": is_valid,
"jwt_exp": current_user.jwt_exp,
"jwt_sub": current_user.jwt_sub,
"expires_at": expires_at,
"days_until_expiry": days_until_expiry,
"expiring_soon": expiring_soon
@@ -179,7 +172,7 @@ async def get_current_user_tasks(
async def get_all_users(
skip: int = Query(0, ge=0, description="跳过记录数"),
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
search: Optional[str] = Query(None, description="搜索关键词(alias 或 jwt_sub"),
search: Optional[str] = Query(None, description="搜索关键词(alias"),
role: Optional[str] = Query(None, description="过滤角色 (user/admin)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
@@ -189,7 +182,7 @@ async def get_all_users(
- **skip**: 跳过记录数
- **limit**: 限制记录数
- **search**: 搜索关键词(模糊匹配 alias 或 jwt_sub
- **search**: 搜索关键词(模糊匹配 alias)
- **role**: 过滤角色(user/admin
"""
try:
@@ -216,17 +209,11 @@ async def get_user(
"""
# 检查权限
if current_user.role != "admin" and current_user.id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足,只能查看自己的信息"
)
raise AuthorizationError("权限不足,只能查看自己的信息")
user = UserService.get_user_by_id(user_id, db)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"用户 ID {user_id} 不存在"
)
raise ResourceNotFoundError(f"用户 ID {user_id} 不存在")
return user
@@ -247,25 +234,16 @@ async def update_user(
# 检查权限
if current_user.role != "admin":
if current_user.id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足,只能更新自己的信息"
)
raise AuthorizationError("权限不足,只能更新自己的信息")
# 普通用户不能修改 role
if user_data.role is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="普通用户不能修改角色"
)
raise AuthorizationError("普通用户不能修改角色")
try:
# 获取更新前的用户状态
old_user = UserService.get_user_by_id(user_id, db)
if not old_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"用户 ID {user_id} 不存在"
)
raise ResourceNotFoundError(f"用户 ID {user_id} 不存在")
# 保存更新前的审批状态 (先读取后转换为 Python bool)
old_approved_value = old_user.is_approved
@@ -316,10 +294,7 @@ async def delete_user(
UserService.delete_user(user_id, db)
return None
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
raise ResourceNotFoundError(str(e))
except Exception as e:
raise HTTPException(
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 fastapi import FastAPI
from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError as PydanticValidationError
import logging
from pathlib import Path
from backend.config import settings
from backend.models import init_db
from backend.exceptions import BaseAPIException
from backend.schemas.response import ErrorResponse, ErrorDetail
# 配置日志
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")
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 typing import Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, EmailStr
class UserBase(BaseModel):
@@ -11,7 +11,7 @@ class UserBase(BaseModel):
class UserCreate(UserBase):
"""创建用户 Schema(管理员手动创建,只需要别名)"""
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="是否已审批(默认已审批)")
@@ -20,7 +20,7 @@ class UserUpdate(BaseModel):
alias: Optional[str] = Field(None, min_length=2, max_length=50, description="用户别名")
role: Optional[str] = None
is_approved: Optional[bool] = None
email: Optional[str] = None
email: Optional[EmailStr] = None
password: Optional[str] = Field(None, min_length=6, description="新密码(可选,留空表示不修改)")
reset_password: Optional[bool] = Field(False, description="是否清空密码")
@@ -28,7 +28,7 @@ class UserUpdate(BaseModel):
class UserUpdateProfile(BaseModel):
"""用户更新个人信息 Schema"""
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="当前密码(修改密码时必填)")
new_password: Optional[str] = Field(None, min_length=6, description="新密码")
@@ -37,11 +37,10 @@ class UserResponse(BaseModel):
"""用户响应 Schema"""
id: int
alias: str
jwt_sub: Optional[str] = None
role: str
is_approved: bool
jwt_exp: str
email: Optional[str] = None
email: Optional[EmailStr] = None
has_password: bool = False # 是否已设置密码
created_at: datetime
updated_at: Optional[datetime] = None
@@ -59,7 +58,6 @@ class TokenStatus(BaseModel):
"""Token 状态 Schema"""
is_valid: bool
jwt_exp: str
jwt_sub: Optional[str] = None
expires_at: Optional[int] = None # Unix 时间戳(秒)
days_until_expiry: Optional[int] = None
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__)
def escape_like_pattern(text: str) -> str:
"""
转义 LIKE 查询中的特殊字符
Args:
text: 原始搜索文本
Returns:
转义后的文本
"""
return text.replace('%', r'\%').replace('_', r'\_')
class UserService:
"""用户服务"""
@@ -114,10 +127,12 @@ class UserService:
# 搜索过滤
if search:
# 转义 LIKE 特殊字符,防止通配符滥用
escaped_search = escape_like_pattern(search)
# 注意:jwt_sub 可能为 NULL,需要处理
search_conditions = [User.alias.ilike(f"%{search}%")]
search_conditions = [User.alias.ilike(f"%{escaped_search}%")]
# 只有当 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))
# 角色过滤
+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": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix",
"format": "prettier --write ."
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
@@ -17,9 +19,14 @@
"vue-router": "^4.6.4"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.6.2",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"tailwindcss": "^3.4.19",
"vite": "^7.2.4"
}
+1 -1
View File
@@ -3,4 +3,4 @@ export default {
tailwindcss: {},
autoprefixer: {},
},
}
};
+17 -16
View File
@@ -5,37 +5,37 @@
</template>
<script setup>
import { onMounted, computed } from 'vue'
import { ConfigProvider as AConfigProvider } from 'ant-design-vue'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import { useAuthStore } from '@/stores/auth'
import getAntdTheme from './antd-theme'
import { useTheme, initTheme, watchSystemTheme } from '@/composables/useTheme'
import { onMounted, computed } from 'vue';
import { ConfigProvider as AConfigProvider } from 'ant-design-vue';
import zhCN from 'ant-design-vue/es/locale/zh_CN';
import { useAuthStore } from '@/stores/auth';
import getAntdTheme from './antd-theme';
import { useTheme, initTheme, watchSystemTheme } from '@/composables/useTheme';
const authStore = useAuthStore()
const authStore = useAuthStore();
// 初始化主题(全局)
initTheme()
watchSystemTheme()
initTheme();
watchSystemTheme();
// 使用主题
const { isDark } = useTheme()
const { isDark } = useTheme();
// 动态生成 Ant Design 主题
const antdTheme = computed(() => getAntdTheme(isDark.value))
const antdTheme = computed(() => getAntdTheme(isDark.value));
// 应用启动时验证 Token
onMounted(async () => {
if (authStore.isAuthenticated) {
try {
await authStore.fetchCurrentUser()
await authStore.fetchCurrentUser();
} catch (error) {
console.error('验证用户信息失败:', error)
console.error('验证用户信息失败:', error);
// Token 可能已过期,清除认证状态
authStore.clearAuth()
authStore.clearAuth();
}
}
})
});
</script>
<style>
@@ -58,7 +58,8 @@ body {
width: 100%;
height: 100%;
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;
-webkit-font-smoothing: antialiased;
-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 主题配置
@@ -212,5 +212,5 @@ export default function getAntdTheme(isDark = false) {
// 算法配置 - 使用 Ant Design 内置的暗黑算法
algorithm: isDark ? [theme.darkAlgorithm] : [],
}
};
}
+33 -32
View File
@@ -1,4 +1,4 @@
import axios from 'axios'
import axios from 'axios';
// 创建 axios 实例
const client = axios.create({
@@ -7,45 +7,45 @@ const client = axios.create({
headers: {
'Content-Type': 'application/json',
},
})
});
// 请求拦截器 - 添加 Token
client.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`
config.headers.Authorization = `Bearer ${token}`;
}
return config
return config;
},
(error) => {
return Promise.reject(error)
error => {
return Promise.reject(error);
}
)
);
// 响应拦截器 - 统一错误处理
client.interceptors.response.use(
(response) => {
return response.data
response => {
return response.data;
},
(error) => {
error => {
if (error.response) {
// 服务器返回错误状态码
const { status, data } = error.response
const { status, data } = error.response;
if (status === 401) {
const errorDetail = data.detail || data.message || ''
const errorDetail = data.detail || data.message || '';
// 检查用户是否设置了密码
const user = JSON.parse(localStorage.getItem('user') || '{}')
const hasPassword = user.has_password || false
const user = JSON.parse(localStorage.getItem('user') || '{}');
const hasPassword = user.has_password || false;
// Token 过期的情况
if (errorDetail.includes('过期')) {
if (hasPassword) {
// 有密码的用户:不强制退出,只显示警告
// 不清除 localStorage,让用户继续使用
console.warn('Token 已过期,但用户设置了密码,允许继续使用')
console.warn('Token 已过期,但用户设置了密码,允许继续使用');
// 返回错误但不跳转登录页
return Promise.reject({
@@ -53,29 +53,29 @@ client.interceptors.response.use(
message: '登录凭证已过期,部分功能可能受限,建议刷新凭证',
data,
tokenExpired: true,
})
});
} else {
// 没有密码的用户:必须重新登录
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('token');
localStorage.removeItem('user');
// 延迟跳转,避免阻塞当前异步请求的错误处理
setTimeout(() => {
if (window.location.pathname !== '/login') {
window.location.href = '/login'
window.location.href = '/login';
}
}, 100)
}, 100);
}
} else {
// 其他 401 错误(无效 Token 等):清除登录状态
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('token');
localStorage.removeItem('user');
setTimeout(() => {
if (window.location.pathname !== '/login') {
window.location.href = '/login'
window.location.href = '/login';
}
}, 100)
}, 100);
}
}
@@ -84,23 +84,24 @@ client.interceptors.response.use(
status,
message: data.detail || data.message || '请求失败',
data,
})
});
} else if (error.request) {
// 请求已发出但没有收到响应(超时或网络错误)
return Promise.reject({
status: 0,
message: error.code === 'ECONNABORTED' ? '请求超时,请稍后重试' : '网络错误,请检查您的网络连接',
message:
error.code === 'ECONNABORTED' ? '请求超时,请稍后重试' : '网络错误,请检查您的网络连接',
data: null,
})
});
} else {
// 发生了触发请求错误的问题
return Promise.reject({
status: 0,
message: error.message || '请求配置错误',
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
*/
export const authAPI = {
// 请求 QR 码
requestQRCode: (alias) => {
return client.post('/api/auth/request_qrcode', { alias })
requestQRCode: alias => {
return client.post('/api/auth/request_qrcode', { alias });
},
// 查询扫码状态
getQRCodeStatus: (sessionId) => {
return client.get(`/api/auth/qrcode_status/${sessionId}`)
getQRCodeStatus: sessionId => {
return client.get(`/api/auth/qrcode_status/${sessionId}`);
},
// 取消 QR 码登录会话
cancelQRCodeSession: (sessionId) => {
return client.delete(`/api/auth/qrcode_session/${sessionId}`)
cancelQRCodeSession: sessionId => {
return client.delete(`/api/auth/qrcode_session/${sessionId}`);
},
// 别名+密码登录
aliasLogin: (alias, password) => {
return client.post('/api/auth/alias_login', { alias, password })
return client.post('/api/auth/alias_login', { alias, password });
},
// 验证 Token
verifyToken: (token) => {
return client.post('/api/auth/verify_token', { token })
verifyToken: token => {
return client.post('/api/auth/verify_token', { token });
},
}
};
/**
* 用户 API
@@ -36,49 +36,49 @@ export const authAPI = {
export const userAPI = {
// 获取当前用户信息
getCurrentUser: () => {
return client.get('/api/users/me')
return client.get('/api/users/me');
},
// 获取当前用户审批状态
getUserStatus: () => {
return client.get('/api/users/me/status')
return client.get('/api/users/me/status');
},
// 获取当前用户 Token 状态
getTokenStatus: () => {
return client.get('/api/users/me/token_status')
return client.get('/api/users/me/token_status');
},
// 更新当前用户个人信息
updateProfile: (profileData) => {
return client.put('/api/users/me/profile', profileData)
updateProfile: profileData => {
return client.put('/api/users/me/profile', profileData);
},
// 创建用户(管理员)
createUser: (userData) => {
return client.post('/api/users', userData)
createUser: userData => {
return client.post('/api/users', userData);
},
// 获取所有用户(管理员)
getUsers: (params = {}) => {
return client.get('/api/users', { params })
return client.get('/api/users', { params });
},
// 获取指定用户
getUser: (userId) => {
return client.get(`/api/users/${userId}`)
getUser: userId => {
return client.get(`/api/users/${userId}`);
},
// 更新用户
updateUser: (userId, userData) => {
return client.put(`/api/users/${userId}`, userData)
return client.put(`/api/users/${userId}`, userData);
},
// 删除用户
deleteUser: (userId) => {
return client.delete(`/api/users/${userId}`)
deleteUser: userId => {
return client.delete(`/api/users/${userId}`);
},
}
};
/**
* 任务 API (V2 新增)
@@ -86,77 +86,81 @@ export const userAPI = {
export const taskAPI = {
// 获取当前用户的任务列表
getMyTasks: (params = {}) => {
return client.get('/api/tasks', { params })
return client.get('/api/tasks', { params });
},
// 创建任务
createTask: (taskData) => {
return client.post('/api/tasks', taskData)
createTask: taskData => {
return client.post('/api/tasks', taskData);
},
// 获取任务详情
getTask: (taskId) => {
return client.get(`/api/tasks/${taskId}`)
getTask: taskId => {
return client.get(`/api/tasks/${taskId}`);
},
// 更新任务
updateTask: (taskId, taskData) => {
return client.put(`/api/tasks/${taskId}`, taskData)
return client.put(`/api/tasks/${taskId}`, taskData);
},
// 删除任务
deleteTask: (taskId) => {
return client.delete(`/api/tasks/${taskId}`)
deleteTask: taskId => {
return client.delete(`/api/tasks/${taskId}`);
},
// 切换任务启用状态
toggleTask: (taskId) => {
return client.post(`/api/tasks/${taskId}/toggle`)
toggleTask: taskId => {
return client.post(`/api/tasks/${taskId}/toggle`);
},
// 手动触发任务打卡(异步,立即返回)
checkInTask: (taskId) => {
return client.post(`/api/check_in/manual/${taskId}`)
checkInTask: taskId => {
return client.post(`/api/check_in/manual/${taskId}`);
},
// 查询打卡记录状态
getCheckInRecordStatus: (recordId) => {
return client.get(`/api/check_in/record/${recordId}/status`)
getCheckInRecordStatus: recordId => {
return client.get(`/api/check_in/record/${recordId}/status`);
},
// 获取任务的打卡记录
getTaskRecords: (taskId, params = {}) => {
return client.get(`/api/check_in/task/${taskId}/records`, { params })
return client.get(`/api/check_in/task/${taskId}/records`, { params });
},
}
};
/**
* 打卡 API
*/
export const checkInAPI = {
// 手动打卡(兼容旧版,推荐使用 taskAPI.checkInTask
manualCheckIn: (taskId) => {
manualCheckIn: taskId => {
// 打卡操作耗时较长,设置 120 秒超时
return client.post(`/api/check_in/manual/${taskId}`, {}, {
timeout: 120000 // 120 秒
})
return client.post(
`/api/check_in/manual/${taskId}`,
{},
{
timeout: 120000, // 120 秒
}
);
},
// 获取任务打卡记录(兼容旧版,推荐使用 taskAPI.getTaskRecords
getMyRecords: (params = {}) => {
return client.get('/api/check_in/my-records', { params })
return client.get('/api/check_in/my-records', { params });
},
// 获取所有打卡记录(管理员)
getAllRecords: (params = {}) => {
return client.get('/api/check_in/records', { params })
return client.get('/api/check_in/records', { params });
},
// 统计打卡记录数
getRecordsCount: (params = {}) => {
return client.get('/api/check_in/records/count', { params })
return client.get('/api/check_in/records/count', { params });
},
}
};
/**
* 管理员 API
@@ -164,44 +168,44 @@ export const checkInAPI = {
export const adminAPI = {
// 获取待审批用户
getPendingUsers: () => {
return client.get('/api/admin/users/pending')
return client.get('/api/admin/users/pending');
},
// 审批通过用户
approveUser: (userId) => {
return client.post(`/api/admin/users/${userId}/approve`)
approveUser: userId => {
return client.post(`/api/admin/users/${userId}/approve`);
},
// 拒绝用户
rejectUser: (userId) => {
return client.delete(`/api/admin/users/${userId}/reject`)
rejectUser: userId => {
return client.delete(`/api/admin/users/${userId}/reject`);
},
// 批量启用/禁用任务(V2 更新)
batchToggleTasks: (taskIds, isActive) => {
return client.post('/api/admin/batch_toggle_tasks', {
task_ids: taskIds,
is_active: isActive
})
is_active: isActive,
});
},
// 批量触发打卡(V2 更新)
batchCheckIn: (taskIds) => {
batchCheckIn: taskIds => {
return client.post('/api/admin/batch_check_in', {
task_ids: taskIds
})
task_ids: taskIds,
});
},
// 查看系统日志
getLogs: (params = {}) => {
return client.get('/api/admin/logs', { params })
return client.get('/api/admin/logs', { params });
},
// 系统统计信息
getStats: () => {
return client.get('/api/admin/stats')
return client.get('/api/admin/stats');
},
}
};
/**
* 模板 API
@@ -209,44 +213,44 @@ export const adminAPI = {
export const templateAPI = {
// 获取所有模板列表
getTemplates: (params = {}) => {
return client.get('/api/templates', { params })
return client.get('/api/templates', { params });
},
// 获取启用的模板列表
getActiveTemplates: (params = {}) => {
return client.get('/api/templates/active', { params })
return client.get('/api/templates/active', { params });
},
// 获取单个模板详情
getTemplate: (templateId) => {
return client.get(`/api/templates/${templateId}`)
getTemplate: templateId => {
return client.get(`/api/templates/${templateId}`);
},
// 预览模板生成的 payload
previewTemplate: (templateId) => {
return client.get(`/api/templates/${templateId}/preview`)
previewTemplate: templateId => {
return client.get(`/api/templates/${templateId}/preview`);
},
// 创建模板(管理员)
createTemplate: (templateData) => {
return client.post('/api/templates', templateData)
createTemplate: templateData => {
return client.post('/api/templates', templateData);
},
// 更新模板(管理员)
updateTemplate: (templateId, templateData) => {
return client.put(`/api/templates/${templateId}`, templateData)
return client.put(`/api/templates/${templateId}`, templateData);
},
// 删除模板(管理员)
deleteTemplate: (templateId) => {
return client.delete(`/api/templates/${templateId}`)
deleteTemplate: templateId => {
return client.delete(`/api/templates/${templateId}`);
},
// 从模板创建任务
createTaskFromTemplate: (requestData) => {
return client.post('/api/templates/create-task', requestData)
createTaskFromTemplate: requestData => {
return client.post('/api/templates/create-task', requestData);
},
}
};
// 导出所有 API
export default {
@@ -256,4 +260,4 @@ export default {
checkIn: checkInAPI,
admin: adminAPI,
template: templateAPI, // V2.2 新增
}
};
+123 -112
View File
@@ -6,9 +6,9 @@
v-for="m in modes"
:key="m"
:class="{ active: mode === m }"
@click.prevent="switchMode(m)"
class="mode-tab"
type="button"
@click.prevent="switchMode(m)"
>
{{ modeLabels[m] }}
</button>
@@ -36,16 +36,12 @@
format="HH:mm"
placeholder="选择时间"
:minute-step="30"
@change="onCustomTimeChange"
style="width: 100%"
@change="onCustomTimeChange"
/>
</a-form-item>
<a-form-item label="频率" name="customFrequency">
<a-select
id="cron-custom-frequency"
v-model:value="customFrequency"
style="width: 100%"
>
<a-select id="cron-custom-frequency" v-model:value="customFrequency" style="width: 100%">
<a-select-option value="daily">每天</a-select-option>
<a-select-option value="weekday">工作日周一-周五</a-select-option>
<a-select-option value="weekend">周末周六-周日</a-select-option>
@@ -86,67 +82,70 @@
</template>
<script setup>
import { ref, watch, onBeforeUnmount } from 'vue'
import dayjs from 'dayjs'
import client from '@/api/client'
import { ref, watch, onBeforeUnmount } from 'vue';
import dayjs from 'dayjs';
import client from '@/api/client';
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 = {
quick: '快速',
custom: '自定义',
advanced: '高级'
}
const modes = ['quick', 'custom', 'advanced']
advanced: '高级',
};
const modes = ['quick', 'custom', 'advanced'];
// 快速模式
const selectedQuick = ref('20:00')
const selectedQuick = ref('20:00');
// 自定义模式
const customTime = ref('20:00')
const customTimeValue = ref(dayjs('20:00', 'HH:mm'))
const customFrequency = ref('daily')
const customTime = ref('20:00');
const customTimeValue = ref(dayjs('20:00', 'HH:mm'));
const customFrequency = ref('daily');
// 高级模式
const advancedExpression = ref(props.modelValue || '0 20 * * *')
const validationMessage = ref('')
const validationStatus = ref('')
const advancedExpression = ref(props.modelValue || '0 20 * * *');
const validationMessage = ref('');
const validationStatus = ref('');
// 通用
const nextExecutions = ref([])
const nextExecutions = ref([]);
// 标志:是否正在手动编辑高级模式(防止自动解析导致模式切换)
let isManualEditing = false
let isManualEditing = false;
// 切换模式 - 防止页面刷新
function switchMode(newMode) {
mode.value = newMode
mode.value = newMode;
// 切换到快速模式时,自动选择默认值并触发保存
if (newMode === 'quick') {
selectedQuick.value = '20:00'
const cron = buildCrontabFromQuick()
advancedExpression.value = cron
emit('update:modelValue', cron)
if (cron) validateAndPreview(cron)
selectedQuick.value = '20:00';
const cron = buildCrontabFromQuick();
advancedExpression.value = cron;
emit('update:modelValue', cron);
if (cron) validateAndPreview(cron);
}
// 切换到自定义模式时,基于当前值构建 cron
else if (newMode === 'custom') {
const cron = buildCrontabFromCustom()
advancedExpression.value = cron
emit('update:modelValue', cron)
if (cron) validateAndPreview(cron)
const cron = buildCrontabFromCustom();
advancedExpression.value = cron;
emit('update:modelValue', cron);
if (cron) validateAndPreview(cron);
}
// 切换到高级模式时,使用当前的 advancedExpression
else if (newMode === 'advanced') {
if (advancedExpression.value) {
emit('update:modelValue', advancedExpression.value)
validateAndPreview(advancedExpression.value)
emit('update:modelValue', advancedExpression.value);
validateAndPreview(advancedExpression.value);
}
}
}
@@ -154,174 +153,185 @@ function switchMode(newMode) {
// 处理时间选择器变化
function onCustomTimeChange(time) {
if (time) {
customTime.value = time.format('HH:mm')
customTime.value = time.format('HH:mm');
}
}
// 监听 - 只在有效值时更新
watch(selectedQuick, () => {
const cron = buildCrontabFromQuick()
advancedExpression.value = cron
emit('update:modelValue', cron)
if (cron) validateAndPreview(cron)
})
const cron = buildCrontabFromQuick();
advancedExpression.value = cron;
emit('update:modelValue', cron);
if (cron) validateAndPreview(cron);
});
watch(customFrequency, () => {
const cron = buildCrontabFromCustom()
advancedExpression.value = cron
emit('update:modelValue', cron)
if (cron) validateAndPreview(cron)
})
const cron = buildCrontabFromCustom();
advancedExpression.value = cron;
emit('update:modelValue', cron);
if (cron) validateAndPreview(cron);
});
watch(customTime, () => {
const cron = buildCrontabFromCustom()
advancedExpression.value = cron
emit('update:modelValue', cron)
if (cron) validateAndPreview(cron)
})
const cron = buildCrontabFromCustom();
advancedExpression.value = cron;
emit('update:modelValue', cron);
if (cron) validateAndPreview(cron);
});
// 工具函数
function buildCrontabFromQuick() {
if (selectedQuick.value === '20:00') {
return '0 20 * * *' // 每天 20:00
return '0 20 * * *'; // 每天 20:00
}
return null
return null;
}
function buildCrontabFromCustom() {
const [hour, minute] = customTime.value.split(':')
const [hour, minute] = customTime.value.split(':');
let dow = '*' // 星期
let dow = '*'; // 星期
if (customFrequency.value === 'weekday') {
dow = '1-5' // 周一至周五
dow = '1-5'; // 周一至周五
} else if (customFrequency.value === 'weekend') {
dow = '0,6' // 周六和周日
dow = '0,6'; // 周六和周日
}
return `${minute} ${hour} * * ${dow}`
return `${minute} ${hour} * * ${dow}`;
}
// 处理高级模式输入 - 使用防抖以避免频繁调用API
let debounceTimer = null
let debounceTimer = null;
function handleAdvancedInput() {
// 设置手动编辑标志
isManualEditing = true
isManualEditing = true;
// 立即触发 emit,保证值实时同步
emit('update:modelValue', advancedExpression.value)
emit('update:modelValue', advancedExpression.value);
// 使用防抖延迟验证
if (debounceTimer) {
clearTimeout(debounceTimer)
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(async () => {
if (!advancedExpression.value.trim()) {
validationMessage.value = ''
nextExecutions.value = []
return
validationMessage.value = '';
nextExecutions.value = [];
return;
}
await validateAndPreview(advancedExpression.value)
}, 500) // 500ms 防抖延迟
await validateAndPreview(advancedExpression.value);
}, 500); // 500ms 防抖延迟
}
async function validateAndPreview(expr) {
if (!expr) {
validationMessage.value = ''
nextExecutions.value = []
return
validationMessage.value = '';
nextExecutions.value = [];
return;
}
try {
const response = await client.post('/api/tasks/validate-cron', {
cron_expression: expr
})
cron_expression: expr,
});
if (response.valid) {
validationStatus.value = 'success'
validationMessage.value = `有效: ${response.description}`
nextExecutions.value = response.next_times
validationStatus.value = 'success';
validationMessage.value = `有效: ${response.description}`;
nextExecutions.value = response.next_times;
}
} catch (error) {
validationStatus.value = 'error'
validationMessage.value = error.message || '无效的 crontab 表达式'
nextExecutions.value = []
validationStatus.value = 'error';
validationMessage.value = error.message || '无效的 crontab 表达式';
nextExecutions.value = [];
}
}
// 解析 cron 表达式并设置对应的模式
function parseCronExpression(cron) {
if (!cron) return
if (!cron) return;
advancedExpression.value = cron
advancedExpression.value = cron;
// 尝试匹配快速模式: 0 20 * * *
if (cron === '0 20 * * *') {
mode.value = 'quick'
selectedQuick.value = '20:00'
validateAndPreview(cron)
return
mode.value = 'quick';
selectedQuick.value = '20:00';
validateAndPreview(cron);
return;
}
// 尝试解析为自定义模式
const parts = cron.trim().split(/\s+/)
const parts = cron.trim().split(/\s+/);
if (parts.length === 5) {
const [minute, hour, day, month, dow] = parts
const [minute, hour, day, month, dow] = parts;
// 检查是否是简单的每天或工作日/周末模式
if (day === '*' && month === '*') {
const hourNum = parseInt(hour)
const minuteNum = parseInt(minute)
const hourNum = parseInt(hour);
const minuteNum = parseInt(minute);
if (!isNaN(hourNum) && !isNaN(minuteNum) && hourNum >= 0 && hourNum < 24 && minuteNum >= 0 && minuteNum < 60) {
mode.value = 'custom'
customTime.value = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`
customTimeValue.value = dayjs(customTime.value, 'HH:mm')
if (
!isNaN(hourNum) &&
!isNaN(minuteNum) &&
hourNum >= 0 &&
hourNum < 24 &&
minuteNum >= 0 &&
minuteNum < 60
) {
mode.value = 'custom';
customTime.value = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
customTimeValue.value = dayjs(customTime.value, 'HH:mm');
// 识别频率
if (dow === '*') {
customFrequency.value = 'daily'
customFrequency.value = 'daily';
} else if (dow === '1-5') {
customFrequency.value = 'weekday'
customFrequency.value = 'weekday';
} else if (dow === '0,6' || dow === '6,0') {
customFrequency.value = 'weekend'
customFrequency.value = 'weekend';
} else {
// 不支持的星期模式,使用高级模式
mode.value = 'advanced'
mode.value = 'advanced';
}
validateAndPreview(cron)
return
validateAndPreview(cron);
return;
}
}
}
// 其他情况使用高级模式
mode.value = 'advanced'
validateAndPreview(cron)
mode.value = 'advanced';
validateAndPreview(cron);
}
// 初始化 - 解析传入的 cron 表达式
watch(() => props.modelValue, (newVal) => {
watch(
() => props.modelValue,
newVal => {
// 如果正在手动编辑高级模式,跳过自动解析
if (isManualEditing) {
isManualEditing = false // 重置标志
return
isManualEditing = false; // 重置标志
return;
}
if (newVal) {
parseCronExpression(newVal)
parseCronExpression(newVal);
}
}, { immediate: true })
},
{ immediate: true }
);
// 组件卸载时清理防抖定时器,防止内存泄漏
onBeforeUnmount(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
clearTimeout(debounceTimer);
debounceTimer = null;
}
})
});
</script>
<style scoped>
@@ -391,7 +401,8 @@ onBeforeUnmount(() => {
.quick-option:hover {
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);
}
+65 -73
View File
@@ -5,9 +5,9 @@
<a-form-item label="显示名称" class="mb-0">
<a-input
:value="modelValue.display_name"
@change="e => updateField('display_name', e.target.value)"
placeholder="在表单中显示的名称"
allow-clear
@change="e => updateField('display_name', e.target.value)"
/>
<span class="text-xs text-on-surface-variant mt-1">显示名称</span>
</a-form-item>
@@ -15,9 +15,9 @@
<a-form-item label="字段类型" class="mb-0">
<a-select
:value="modelValue.field_type"
@change="handleFieldTypeChange"
placeholder="选择输入控件类型"
class="w-full"
@change="handleFieldTypeChange"
>
<a-select-option label="📝 单行文本" value="text" />
<a-select-option label="📄 多行文本" value="textarea" />
@@ -33,9 +33,9 @@
<a-form-item label="值类型" class="mb-0">
<a-select
:value="modelValue.value_type"
@change="value => updateField('value_type', value)"
placeholder="选择数据类型"
class="w-full"
@change="value => updateField('value_type', value)"
>
<a-select-option label="字符串 (string)" value="string">
<span class="text-xs text-on-surface-variant">字符串 (string)</span>
@@ -60,26 +60,24 @@
<a-input
v-if="modelValue.value_type !== 'json'"
:value="modelValue.default_value"
@change="e => updateField('default_value', e.target.value)"
placeholder="字段的默认值"
allow-clear
@change="e => updateField('default_value', e.target.value)"
/>
<a-textarea
v-else
:value="modelValue.default_value"
@change="e => updateField('default_value', e.target.value)"
placeholder="字段的默认值"
:rows="3"
allow-clear
@change="e => updateField('default_value', e.target.value)"
/>
<span class="text-xs text-on-surface-variant mt-1">
<template v-if="modelValue.value_type === 'json'">
<p>输入JSON对象,会自动序列化为字符串</p>
<p>:{"key1":value1,"key2":value2}</p>
</template>
<template v-else>
用户未填写时使用此值
</template>
<template v-else> 用户未填写时使用此值 </template>
</span>
</a-form-item>
</div>
@@ -88,15 +86,17 @@
<a-form-item label="占位符提示" class="mb-0">
<a-input
:value="modelValue.placeholder"
@change="e => updateField('placeholder', e.target.value)"
placeholder="输入框的灰色提示文本"
allow-clear
@change="e => updateField('placeholder', e.target.value)"
/>
<span class="text-xs text-on-surface-variant mt-1">占位符</span>
</a-form-item>
<!-- Row 4: Switches -->
<div class="grid grid-cols-2 gap-4 p-3 bg-surface-container-low rounded-md3 border border-outline-variant">
<div
class="grid grid-cols-2 gap-4 p-3 bg-surface-container-low rounded-md3 border border-outline-variant"
>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-on-surface">是否必填</label>
@@ -104,8 +104,8 @@
</div>
<a-switch
:checked="modelValue.required"
@change="handleRequiredChange"
:disabled="modelValue.hidden"
@change="handleRequiredChange"
/>
</div>
@@ -114,20 +114,11 @@
<label class="text-sm font-medium text-on-surface">是否隐藏</label>
<p class="text-xs text-on-surface-variant">直接使用默认值不在表单中显示</p>
</div>
<a-switch
:checked="modelValue.hidden"
@change="handleHiddenChange"
/>
<a-switch :checked="modelValue.hidden" @change="handleHiddenChange" />
</div>
</div>
<a-alert
v-if="modelValue.hidden"
message="💡 提示"
type="info"
:closable="false"
class="mt-3"
>
<a-alert v-if="modelValue.hidden" message="💡 提示" type="info" :closable="false" class="mt-3">
<template #description>
<p class="text-xs">
隐藏字段将自动使用默认值不会在创建任务表单中显示请确保设置了合适的默认值
@@ -147,30 +138,31 @@
<span class="text-xs text-on-surface-variant w-8">{{ index + 1 }}.</span>
<a-input
:value="option.label"
@change="e => updateOption(index, 'label', e.target.value)"
placeholder="显示文本(如:健康)"
size="small"
class="flex-1"
@change="e => updateOption(index, 'label', e.target.value)"
/>
<a-input
:value="option.value"
@change="e => updateOption(index, 'value', e.target.value)"
placeholder="选项值(如:healthy"
size="small"
class="flex-1"
@change="e => updateOption(index, 'value', e.target.value)"
/>
<a-button
size="small"
danger
@click="removeOption(index)"
>
<a-button size="small" danger @click="removeOption(index)">
<template #icon><DeleteOutlined /></template>
</a-button>
</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">
<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>
添加选项
</a-button>
@@ -185,99 +177,99 @@
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
import { DeleteOutlined } from '@ant-design/icons-vue'
import { defineProps, defineEmits } from 'vue';
import { DeleteOutlined } from '@ant-design/icons-vue';
const props = defineProps({
modelValue: {
type: Object,
required: true
required: true,
},
fieldKey: {
type: String,
default: ''
}
})
default: '',
},
});
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['update:modelValue']);
// Update single field
const updateField = (field, value) => {
emit('update:modelValue', {
...props.modelValue,
[field]: value
})
}
[field]: value,
});
};
// Handle required change
const handleRequiredChange = (value) => {
updateField('required', value)
}
const handleRequiredChange = value => {
updateField('required', value);
};
// Handle hidden change - 当隐藏时,自动设置 required 为 false
const handleHiddenChange = (value) => {
const handleHiddenChange = value => {
const updated = {
...props.modelValue,
hidden: value
}
hidden: value,
};
// 如果设置为隐藏,则取消必填
if (value) {
updated.required = false
updated.required = false;
}
emit('update:modelValue', updated)
}
emit('update:modelValue', updated);
};
// Handle field type change
const handleFieldTypeChange = (newType) => {
const handleFieldTypeChange = newType => {
const updated = {
...props.modelValue,
field_type: newType
}
field_type: newType,
};
if (newType === 'select' && !updated.options) {
updated.options = []
updated.options = [];
}
emit('update:modelValue', updated)
}
emit('update:modelValue', updated);
};
// Add option
const addOption = () => {
const options = [...(props.modelValue.options || [])]
options.push({ label: '', value: '' })
const options = [...(props.modelValue.options || [])];
options.push({ label: '', value: '' });
emit('update:modelValue', {
...props.modelValue,
options
})
}
options,
});
};
// Update option
const updateOption = (index, field, value) => {
const options = [...(props.modelValue.options || [])]
const options = [...(props.modelValue.options || [])];
options[index] = {
...options[index],
[field]: value
}
[field]: value,
};
emit('update:modelValue', {
...props.modelValue,
options
})
}
options,
});
};
// Remove option
const removeOption = (index) => {
const options = [...(props.modelValue.options || [])]
options.splice(index, 1)
const removeOption = index => {
const options = [...(props.modelValue.options || [])];
options.splice(index, 1);
emit('update:modelValue', {
...props.modelValue,
options
})
}
options,
});
};
</script>
<style scoped>
+264 -131
View File
@@ -1,13 +1,15 @@
<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 class="flex items-center justify-between mb-3 pb-2 border-b border-outline-variant">
<div class="flex items-center gap-3">
<button
type="button"
@click="isCollapsed = !isCollapsed"
class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
@click="isCollapsed = !isCollapsed"
>
<svg
class="w-4 h-4 text-on-surface-variant transition-transform"
@@ -16,29 +18,54 @@
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>
</button>
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<span class="font-mono text-base font-bold text-primary">{{ fieldKey }}</span>
<a-tag type="primary" size="small">普通字段</a-tag>
</div>
<div class="flex gap-2">
<a-button size="small" @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">
<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>
</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">
<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>
</a-button>
<a-button size="small" type="danger" plain @click="handleDelete">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
删除
</a-button>
@@ -56,8 +83,8 @@
<div class="flex items-center gap-3">
<button
type="button"
@click="isCollapsed = !isCollapsed"
class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
@click="isCollapsed = !isCollapsed"
>
<svg
class="w-4 h-4 text-on-surface-variant transition-transform"
@@ -66,35 +93,65 @@
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>
</button>
<svg class="w-5 h-5 text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
<span class="font-mono text-base font-bold text-secondary">{{ fieldKey }}</span>
<a-tag type="warning" size="small">数组字段</a-tag>
</div>
<div class="flex gap-2">
<a-button size="small" @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">
<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>
</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">
<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>
</a-button>
<a-button size="small" type="primary" @click="addArrayItem">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
添加元素
</a-button>
<a-button size="small" type="danger" plain @click="handleDelete">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
删除
</a-button>
@@ -102,7 +159,10 @@
</div>
<div v-show="!isCollapsed">
<div v-if="localFieldConfig.length === 0" class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline">
<div
v-if="localFieldConfig.length === 0"
class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline"
>
<p class="text-sm text-on-surface-variant mb-2">数组为空</p>
<a-button size="small" type="primary" @click="addArrayItem">添加第一个元素</a-button>
</div>
@@ -121,8 +181,15 @@
</div>
<!-- 如果数组元素是字段配置对象直接渲染为字段编辑器 -->
<div v-if="typeof item === 'object' && !Array.isArray(item) && 'display_name' in item" class="bg-surface rounded-md3 p-3">
<FieldConfigEditor :model-value="item" @update:model-value="updateArrayItemField(index, $event)" :field-key="`元素${index + 1}`" />
<div
v-if="typeof item === 'object' && !Array.isArray(item) && 'display_name' in item"
class="bg-surface rounded-md3 p-3"
>
<FieldConfigEditor
:model-value="item"
:field-key="`元素${index + 1}`"
@update:model-value="updateArrayItemField(index, $event)"
/>
</div>
<!-- 如果数组元素是对象但不是字段配置递归渲染其中的字段 -->
@@ -138,9 +205,20 @@
@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">
<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>
添加字段
</a-button>
@@ -168,8 +246,8 @@
<div class="flex items-center gap-3">
<button
type="button"
@click="isCollapsed = !isCollapsed"
class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
@click="isCollapsed = !isCollapsed"
>
<svg
class="w-4 h-4 text-on-surface-variant transition-transform"
@@ -178,35 +256,65 @@
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>
</button>
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span class="font-mono text-base font-bold text-accent">{{ fieldKey }}</span>
<a-tag type="success" size="small">对象字段</a-tag>
</div>
<div class="flex gap-2">
<a-button size="small" @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">
<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>
</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">
<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>
</a-button>
<a-button size="small" type="primary" @click="addFieldToObject">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
添加子字段
</a-button>
<a-button size="small" type="danger" plain @click="handleDelete">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
删除
</a-button>
@@ -214,9 +322,14 @@
</div>
<div v-show="!isCollapsed">
<div v-if="Object.keys(localFieldConfig).length === 0" class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline">
<div
v-if="Object.keys(localFieldConfig).length === 0"
class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline"
>
<p class="text-sm text-on-surface-variant mb-2">对象为空</p>
<a-button size="small" type="primary" @click="addFieldToObject">添加第一个子字段</a-button>
<a-button size="small" type="primary" @click="addFieldToObject"
>添加第一个子字段</a-button
>
</div>
<div v-else class="space-y-3 mt-3 pl-4 border-l-4 border-accent">
@@ -236,12 +349,20 @@
</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-item :label="currentArrayIndex === -1 ? '字段名(可选)' : '字段名'">
<a-input
v-model:value="newFieldName"
:placeholder="currentArrayIndex === -1 ? '留空则作为数组元素,填写则作为对象字段' : '例如: FieldId, Values, Texts'"
:placeholder="
currentArrayIndex === -1
? '留空则作为数组元素,填写则作为对象字段'
: '例如: FieldId, Values, Texts'
"
/>
</a-form-item>
<a-form-item label="元素类型">
@@ -262,119 +383,131 @@
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { message } from 'ant-design-vue'
import FieldConfigEditor from './FieldConfigEditor.vue'
import { ref, computed, watch, nextTick } from 'vue';
import { message } from 'ant-design-vue';
import FieldConfigEditor from './FieldConfigEditor.vue';
const props = defineProps({
fieldKey: {
type: String,
required: true
required: true,
},
fieldConfig: {
type: [Object, Array],
required: true
required: true,
},
path: {
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 addFieldDialogVisible = ref(false)
const newFieldName = ref('')
const newFieldType = ref('field')
const currentArrayIndex = ref(null)
const isAddingToObject = ref(false)
const isCollapsed = ref(false)
const localFieldConfig = ref(JSON.parse(JSON.stringify(props.fieldConfig)));
const addFieldDialogVisible = ref(false);
const newFieldName = ref('');
const newFieldType = ref('field');
const currentArrayIndex = ref(null);
const isAddingToObject = ref(false);
const isCollapsed = ref(false);
// 标志位,防止循环更新
let isUpdatingFromProps = false
let isUpdatingFromProps = false;
// 监听 props.fieldConfig 的变化,同步更新 localFieldConfig
watch(() => props.fieldConfig, (newVal) => {
isUpdatingFromProps = true
localFieldConfig.value = JSON.parse(JSON.stringify(newVal))
watch(
() => props.fieldConfig,
newVal => {
isUpdatingFromProps = true;
localFieldConfig.value = JSON.parse(JSON.stringify(newVal));
// 使用 nextTick 确保在下一个 tick 后重置标志
nextTick(() => {
isUpdatingFromProps = false
})
}, { deep: true })
isUpdatingFromProps = false;
});
},
{ deep: true }
);
// 判断字段类型
const isFieldConfig = computed(() => {
return typeof props.fieldConfig === 'object' &&
return (
typeof props.fieldConfig === 'object' &&
!Array.isArray(props.fieldConfig) &&
'display_name' in props.fieldConfig
})
);
});
const isArray = computed(() => {
return Array.isArray(props.fieldConfig)
})
return Array.isArray(props.fieldConfig);
});
const isObject = computed(() => {
return typeof props.fieldConfig === 'object' &&
return (
typeof props.fieldConfig === 'object' &&
!Array.isArray(props.fieldConfig) &&
!('display_name' in props.fieldConfig)
})
);
});
// 监听本地配置变化 - 只在非 props 更新时触发
watch(localFieldConfig, (newVal) => {
watch(
localFieldConfig,
newVal => {
if (!isUpdatingFromProps) {
emit('update', { path: props.path, value: newVal })
emit('update', { path: props.path, value: newVal });
}
}, { deep: true })
},
{ deep: true }
);
// 删除字段
const handleDelete = () => {
emit('delete', props.path)
}
emit('delete', props.path);
};
// 移动字段
const handleMove = (direction) => {
emit('move', { path: props.path, direction })
}
const handleMove = direction => {
emit('move', { path: props.path, direction });
};
// 添加数组元素
const addArrayItem = () => {
// 弹出对话框让用户选择添加元素类型
currentArrayIndex.value = -1 // 标记为添加数组元素
isAddingToObject.value = false
newFieldName.value = '' // 数组元素不需要字段名,但复用对话框
newFieldType.value = 'field'
addFieldDialogVisible.value = true
}
currentArrayIndex.value = -1; // 标记为添加数组元素
isAddingToObject.value = false;
newFieldName.value = ''; // 数组元素不需要字段名,但复用对话框
newFieldType.value = 'field';
addFieldDialogVisible.value = true;
};
// 删除数组元素
const removeArrayItem = (index) => {
localFieldConfig.value.splice(index, 1)
}
const removeArrayItem = index => {
localFieldConfig.value.splice(index, 1);
};
// 更新数组元素的字段配置
const updateArrayItemField = (index, newValue) => {
localFieldConfig.value[index] = newValue
}
localFieldConfig.value[index] = newValue;
};
// 为数组元素添加字段
const addFieldToArrayItem = (index) => {
currentArrayIndex.value = index
isAddingToObject.value = false
newFieldName.value = ''
newFieldType.value = 'field'
addFieldDialogVisible.value = true
}
const addFieldToArrayItem = index => {
currentArrayIndex.value = index;
isAddingToObject.value = false;
newFieldName.value = '';
newFieldType.value = 'field';
addFieldDialogVisible.value = true;
};
// 为对象添加字段
const addFieldToObject = () => {
currentArrayIndex.value = null
isAddingToObject.value = true
newFieldName.value = ''
newFieldType.value = 'field'
addFieldDialogVisible.value = true
}
currentArrayIndex.value = null;
isAddingToObject.value = true;
newFieldName.value = '';
newFieldType.value = 'field';
addFieldDialogVisible.value = true;
};
// 确认添加字段
const confirmAddField = () => {
@@ -391,20 +524,20 @@ const confirmAddField = () => {
required: false,
hidden: false,
value_type: 'string',
options: []
})
options: [],
});
} else if (newFieldType.value === 'array') {
localFieldConfig.value.push([])
localFieldConfig.value.push([]);
} else if (newFieldType.value === 'object') {
localFieldConfig.value.push({})
localFieldConfig.value.push({});
}
addFieldDialogVisible.value = false
message.success('数组元素添加成功')
return
addFieldDialogVisible.value = false;
message.success('数组元素添加成功');
return;
} else {
// 字段名不为空,添加为包含命名字段的对象
const newObject = {}
const newObject = {};
if (newFieldType.value === 'field') {
newObject[newFieldName.value] = {
display_name: '',
@@ -413,32 +546,32 @@ const confirmAddField = () => {
required: false,
hidden: false,
value_type: 'string',
options: []
}
options: [],
};
} else if (newFieldType.value === 'array') {
newObject[newFieldName.value] = []
newObject[newFieldName.value] = [];
} else if (newFieldType.value === 'object') {
newObject[newFieldName.value] = {}
newObject[newFieldName.value] = {};
}
localFieldConfig.value.push(newObject)
addFieldDialogVisible.value = false
message.success('带命名字段的对象添加成功')
return
localFieldConfig.value.push(newObject);
addFieldDialogVisible.value = false;
message.success('带命名字段的对象添加成功');
return;
}
}
// 其他情况需要字段名
if (!newFieldName.value) {
message.warning('请输入字段名')
return
message.warning('请输入字段名');
return;
}
if (isAddingToObject.value) {
// 添加到对象字段
if (localFieldConfig.value[newFieldName.value]) {
message.warning('该字段已存在')
return
message.warning('该字段已存在');
return;
}
if (newFieldType.value === 'field') {
@@ -449,19 +582,19 @@ const confirmAddField = () => {
required: false,
hidden: false,
value_type: 'string',
options: []
}
options: [],
};
} else if (newFieldType.value === 'array') {
localFieldConfig.value[newFieldName.value] = []
localFieldConfig.value[newFieldName.value] = [];
} else if (newFieldType.value === 'object') {
localFieldConfig.value[newFieldName.value] = {}
localFieldConfig.value[newFieldName.value] = {};
}
} else if (currentArrayIndex.value !== null) {
// 添加到数组元素
const arrayItem = localFieldConfig.value[currentArrayIndex.value]
const arrayItem = localFieldConfig.value[currentArrayIndex.value];
if (arrayItem[newFieldName.value]) {
message.warning('该字段已存在')
return
message.warning('该字段已存在');
return;
}
if (newFieldType.value === 'field') {
@@ -472,18 +605,18 @@ const confirmAddField = () => {
required: false,
hidden: false,
value_type: 'string',
options: []
}
options: [],
};
} else if (newFieldType.value === 'array') {
arrayItem[newFieldName.value] = []
arrayItem[newFieldName.value] = [];
} else if (newFieldType.value === 'object') {
arrayItem[newFieldName.value] = {}
arrayItem[newFieldName.value] = {};
}
}
addFieldDialogVisible.value = false
message.success('字段添加成功')
}
addFieldDialogVisible.value = false;
message.success('字段添加成功');
};
</script>
<style scoped>
+11 -7
View File
@@ -8,16 +8,16 @@
</template>
<script setup>
import { onMounted } from 'vue'
import Navbar from './Navbar.vue'
import { useTokenMonitor } from '@/composables/useTokenMonitor'
import { onMounted } from 'vue';
import Navbar from './Navbar.vue';
import { useTokenMonitor } from '@/composables/useTokenMonitor';
// 启动全局 Token 监控
const { startMonitoring } = useTokenMonitor()
const { startMonitoring } = useTokenMonitor();
onMounted(() => {
startMonitoring()
})
startMonitoring();
});
</script>
<style scoped>
@@ -26,7 +26,11 @@ onMounted(() => {
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, var(--md-sys-color-surface-container-lowest) 0%, var(--md-sys-color-surface-container-low) 100%);
background: linear-gradient(
135deg,
var(--md-sys-color-surface-container-lowest) 0%,
var(--md-sys-color-surface-container-low) 100%
);
}
.main-content {
+127 -136
View File
@@ -5,7 +5,9 @@
<!-- Logo and Brand -->
<div class="flex items-center space-x-8">
<router-link to="/" class="flex items-center space-x-3 group">
<div class="w-10 h-10 bg-gradient-to-br from-primary-500 to-secondary-500 rounded-md3 flex items-center justify-center transform group-hover:scale-110 transition-transform">
<div
class="w-10 h-10 bg-gradient-to-br from-primary-500 to-secondary-500 rounded-md3 flex items-center justify-center transform group-hover:scale-110 transition-transform"
>
<CheckCircleOutlined class="text-white text-xl" />
</div>
<span class="text-xl font-bold text-gradient">接龙自动打卡</span>
@@ -13,19 +15,15 @@
<!-- Desktop Navigation Links -->
<div v-if="!isMobile" class="hidden md:flex items-center space-x-2">
<router-link
to="/dashboard"
v-slot="{ isActive }"
custom
>
<router-link v-slot="{ isActive }" to="/dashboard" custom>
<a
@click="router.push('/dashboard')"
:class="[
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
: 'text-on-surface hover:bg-surface-container'
: 'text-on-surface hover:bg-surface-container',
]"
@click="router.push('/dashboard')"
>
<div class="flex items-center space-x-2">
<HomeOutlined />
@@ -34,19 +32,15 @@
</a>
</router-link>
<router-link
to="/tasks"
v-slot="{ isActive }"
custom
>
<router-link v-slot="{ isActive }" to="/tasks" custom>
<a
@click="router.push('/tasks')"
:class="[
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
: 'text-on-surface hover:bg-surface-container'
: 'text-on-surface hover:bg-surface-container',
]"
@click="router.push('/tasks')"
>
<div class="flex items-center space-x-2">
<FileTextOutlined />
@@ -55,19 +49,15 @@
</a>
</router-link>
<router-link
to="/records"
v-slot="{ isActive }"
custom
>
<router-link v-slot="{ isActive }" to="/records" custom>
<a
@click="router.push('/records')"
:class="[
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
: 'text-on-surface hover:bg-surface-container'
: 'text-on-surface hover:bg-surface-container',
]"
@click="router.push('/records')"
>
<div class="flex items-center space-x-2">
<UnorderedListOutlined />
@@ -81,7 +71,9 @@
<a
:class="[
'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 />
@@ -155,11 +147,15 @@
<!-- Desktop User Menu -->
<a-dropdown v-if="!isMobile" :trigger="['hover']">
<a class="flex items-center space-x-3 px-4 py-2 rounded-full hover:bg-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' }">
{{ userInitial }}
</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" />
</a>
<template #overlay>
@@ -167,7 +163,9 @@
<a-menu-item key="info" disabled>
<div class="px-2 py-1">
<p class="text-sm font-medium text-on-surface">{{ authStore.user?.alias }}</p>
<p class="text-xs text-on-surface-variant mt-1">{{ authStore.isAdmin ? '管理员' : '普通用户' }}</p>
<p class="text-xs text-on-surface-variant mt-1">
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
</p>
</div>
</a-menu-item>
<a-menu-divider />
@@ -175,7 +173,7 @@
<SettingOutlined />
<span class="ml-2">个人设置</span>
</a-menu-item>
<a-menu-item key="logout" @click="handleLogout" danger>
<a-menu-item key="logout" danger @click="handleLogout">
<LogoutOutlined />
<span class="ml-2">退出登录</span>
</a-menu-item>
@@ -197,12 +195,7 @@
</nav>
<!-- Mobile Drawer -->
<a-drawer
v-model:open="drawerVisible"
placement="left"
:width="280"
title="菜单"
>
<a-drawer v-model:open="drawerVisible" placement="left" :width="280" title="菜单">
<!-- User Info in Drawer -->
<div class="mb-6 pb-4 border-b border-outline-variant">
<div class="flex items-center space-x-3">
@@ -211,17 +204,15 @@
</a-avatar>
<div>
<p class="font-medium text-on-surface">{{ authStore.user?.alias || '用户' }}</p>
<p class="text-xs text-on-surface-variant">{{ authStore.isAdmin ? '管理员' : '普通用户' }}</p>
<p class="text-xs text-on-surface-variant">
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
</p>
</div>
</div>
</div>
<!-- Mobile Navigation Menu -->
<a-menu
mode="inline"
:selected-keys="[currentMenuKey]"
@click="handleMenuClick"
>
<a-menu mode="inline" :selected-keys="[currentMenuKey]" @click="handleMenuClick">
<a-menu-item key="dashboard">
<template #icon><HomeOutlined /></template>
仪表盘
@@ -285,15 +276,15 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { useTokenMonitor } from '@/composables/useTokenMonitor'
import { useBreakpoint } from '@/composables/useBreakpoint'
import { useTheme } from '@/composables/useTheme'
import { Modal, message } from 'ant-design-vue'
import QRCodeModal from './QRCodeModal.vue'
import { ref, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { useUserStore } from '@/stores/user';
import { useTokenMonitor } from '@/composables/useTokenMonitor';
import { useBreakpoint } from '@/composables/useBreakpoint';
import { useTheme } from '@/composables/useTheme';
import { Modal, message } from 'ant-design-vue';
import QRCodeModal from './QRCodeModal.vue';
import {
MenuOutlined,
HomeOutlined,
@@ -311,123 +302,123 @@ import {
BulbOutlined,
BulbFilled,
ReloadOutlined,
} from '@ant-design/icons-vue'
} from '@ant-design/icons-vue';
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const userStore = useUserStore()
const { isMobile } = useBreakpoint()
const { getRemainingMinutes, tokenStatus } = useTokenMonitor()
const { isDark, toggleTheme } = useTheme()
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const userStore = useUserStore();
const { isMobile } = useBreakpoint();
const { getRemainingMinutes, tokenStatus } = useTokenMonitor();
const { isDark, toggleTheme } = useTheme();
const drawerVisible = ref(false)
const qrcodeModalVisible = ref(false)
const drawerVisible = ref(false);
const qrcodeModalVisible = ref(false);
const isAdminPath = computed(() => route.path.startsWith('/admin'))
const isAdminPath = computed(() => route.path.startsWith('/admin'));
const userInitial = computed(() => {
const name = authStore.user?.alias || 'U'
return name.charAt(0).toUpperCase()
})
const name = authStore.user?.alias || 'U';
return name.charAt(0).toUpperCase();
});
// Token 状态计算
const remainingMinutes = computed(() => {
return getRemainingMinutes()
})
return getRemainingMinutes();
});
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分钟内)
if (mins === null) return false
return mins <= 60 || (mins < 0 && Math.abs(mins) <= 5)
})
if (mins === null) return false;
return mins <= 60 || (mins < 0 && Math.abs(mins) <= 5);
});
const tokenBadgeStatus = computed(() => {
const mins = remainingMinutes.value
if (mins === null) return 'default'
if (mins < 0) return 'error' // 已过期
if (mins <= 10) return 'error' // 10分钟内过期
if (mins <= 30) return 'warning' // 30分钟内过期
return 'processing' // 正常但快过期
})
const mins = remainingMinutes.value;
if (mins === null) return 'default';
if (mins < 0) return 'error'; // 已过期
if (mins <= 10) return 'error'; // 10分钟内过期
if (mins <= 30) return 'warning'; // 30分钟内过期
return 'processing'; // 正常但快过期
});
const tokenBadgeText = computed(() => {
const mins = remainingMinutes.value
if (mins === null) return ''
if (mins < 0) return 'Token 已过期'
if (mins < 60) return `Token 剩余:${mins}分钟`
return ''
})
const mins = remainingMinutes.value;
if (mins === null) return '';
if (mins < 0) return 'Token 已过期';
if (mins < 60) return `Token 剩余:${mins}分钟`;
return '';
});
const tokenIconClass = computed(() => {
const mins = remainingMinutes.value
if (mins === null) return 'text-on-surface-variant'
if (mins < 0) return 'text-red-500 dark:text-red-400' // 已过期
if (mins <= 10) return 'text-red-500 dark:text-red-400 animate-pulse' // 10分钟内,闪烁
if (mins <= 30) return 'text-orange-500 dark:text-orange-400' // 30分钟内
return 'text-blue-500 dark:text-blue-400' // 正常
})
const mins = remainingMinutes.value;
if (mins === null) return 'text-on-surface-variant';
if (mins < 0) return 'text-red-500 dark:text-red-400'; // 已过期
if (mins <= 10) return 'text-red-500 dark:text-red-400 animate-pulse'; // 10分钟内,闪烁
if (mins <= 30) return 'text-orange-500 dark:text-orange-400'; // 30分钟内
return 'text-blue-500 dark:text-blue-400'; // 正常
});
const tokenStatusTooltip = computed(() => {
const mins = remainingMinutes.value
if (mins === null) return 'Token 状态未知'
const mins = remainingMinutes.value;
if (mins === null) return 'Token 状态未知';
if (mins < 0) {
const expiredMins = Math.abs(mins)
return `登录凭证已过期 ${expiredMins} 分钟,点击右侧按钮刷新`
const expiredMins = Math.abs(mins);
return `登录凭证已过期 ${expiredMins} 分钟,点击右侧按钮刷新`;
}
if (mins < 60) {
return `Token 剩余时间:${mins} 分钟,过期后可刷新)`
return `Token 剩余时间:${mins} 分钟,过期后可刷新)`;
}
return 'Token 状态正常'
})
return 'Token 状态正常';
});
const handleTokenStatusClick = () => {
const mins = remainingMinutes.value
const mins = remainingMinutes.value;
// Token 已过期时提醒刷新
if (mins !== null && mins < 0) {
message.info('Token 已过期,请进行刷新')
message.info('Token 已过期,请进行刷新');
}
// Token 未过期时,点击无效果
}
};
const currentMenuKey = computed(() => {
const path = route.path
if (path.startsWith('/admin/users')) return 'admin-users'
if (path.startsWith('/admin/templates')) return 'admin-templates'
if (path.startsWith('/admin/records')) return 'admin-records'
if (path.startsWith('/admin/stats')) return 'admin-stats'
if (path.startsWith('/admin/logs')) return 'admin-logs'
if (path.startsWith('/dashboard')) return 'dashboard'
if (path.startsWith('/tasks')) return 'tasks'
if (path.startsWith('/records')) return 'records'
if (path.startsWith('/settings')) return 'settings'
return ''
})
const path = route.path;
if (path.startsWith('/admin/users')) return 'admin-users';
if (path.startsWith('/admin/templates')) return 'admin-templates';
if (path.startsWith('/admin/records')) return 'admin-records';
if (path.startsWith('/admin/stats')) return 'admin-stats';
if (path.startsWith('/admin/logs')) return 'admin-logs';
if (path.startsWith('/dashboard')) return 'dashboard';
if (path.startsWith('/tasks')) return 'tasks';
if (path.startsWith('/records')) return 'records';
if (path.startsWith('/settings')) return 'settings';
return '';
});
const handleMenuClick = ({ key }) => {
const routes = {
'dashboard': '/dashboard',
'tasks': '/tasks',
'records': '/records',
dashboard: '/dashboard',
tasks: '/tasks',
records: '/records',
'admin-users': '/admin/users',
'admin-templates': '/admin/templates',
'admin-records': '/admin/records',
'admin-stats': '/admin/stats',
'admin-logs': '/admin/logs',
'settings': '/settings',
}
settings: '/settings',
};
if (key === 'logout') {
handleLogout()
handleLogout();
} else if (routes[key]) {
router.push(routes[key])
drawerVisible.value = false
router.push(routes[key]);
drawerVisible.value = false;
}
}
};
const handleLogout = () => {
Modal.confirm({
@@ -436,36 +427,36 @@ const handleLogout = () => {
okText: '确定',
cancelText: '取消',
onOk() {
authStore.logout()
router.push('/login')
drawerVisible.value = false
authStore.logout();
router.push('/login');
drawerVisible.value = false;
},
})
}
});
};
// 处理 Token 刷新
const handleRefreshToken = () => {
qrcodeModalVisible.value = true
}
qrcodeModalVisible.value = true;
};
// 处理 QR 码扫码成功
const handleQRCodeSuccess = async () => {
message.success('Token 刷新成功')
qrcodeModalVisible.value = false
message.success('Token 刷新成功');
qrcodeModalVisible.value = false;
// 刷新用户信息和 Token 状态
try {
await authStore.fetchCurrentUser()
await userStore.fetchTokenStatus()
await authStore.fetchCurrentUser();
await userStore.fetchTokenStatus();
} catch (error) {
console.error('刷新用户信息失败:', error)
console.error('刷新用户信息失败:', error);
}
}
};
// 处理 QR 码扫码失败
const handleQRCodeError = (error) => {
message.error(error?.message || 'Token 刷新失败')
}
const handleQRCodeError = error => {
message.error(error?.message || 'Token 刷新失败');
};
</script>
<style scoped>
+84 -87
View File
@@ -4,9 +4,9 @@
title="QQ 扫码登录"
:width="isMobile ? '100%' : 400"
:style="isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : {}"
:maskClosable="false"
@cancel="handleClose"
:mask-closable="false"
:footer="null"
@cancel="handleClose"
>
<div class="qrcode-container">
<!-- 加载中 -->
@@ -33,30 +33,26 @@
<div v-else-if="status === 'expired'" class="status-container">
<WarningFilled class="status-icon warning-icon" />
<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 v-else-if="status === 'failed'" class="status-container">
<CloseCircleFilled class="status-icon error-icon" />
<p class="status-text error">{{ errorMessage }}</p>
<a-button type="primary" @click="refreshQRCode" class="mt-4">重试</a-button>
<a-button type="primary" class="mt-4" @click="refreshQRCode">重试</a-button>
</div>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useBreakpoint } from '@/composables/useBreakpoint'
import { usePollStatus } from '@/composables/usePollStatus'
import { message } from 'ant-design-vue'
import {
CheckCircleFilled,
WarningFilled,
CloseCircleFilled,
} from '@ant-design/icons-vue'
import { ref, computed, watch, onBeforeUnmount } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useBreakpoint } from '@/composables/useBreakpoint';
import { usePollStatus } from '@/composables/usePollStatus';
import { message } from 'ant-design-vue';
import { CheckCircleFilled, WarningFilled, CloseCircleFilled } from '@ant-design/icons-vue';
const props = defineProps({
visible: {
@@ -67,161 +63,162 @@ const props = defineProps({
type: String,
required: true,
},
})
});
const emit = defineEmits(['update:visible', 'success', 'error'])
const emit = defineEmits(['update:visible', 'success', 'error']);
const authStore = useAuthStore()
const { isMobile } = useBreakpoint()
const authStore = useAuthStore();
const { isMobile } = useBreakpoint();
// 使用轮询 composable
const { startPolling: startQRPolling, stopPolling } = usePollStatus({
interval: 2000,
maxRetries: 90, // 3分钟 = 180秒 / 2秒间隔 = 90次
backoff: false
})
backoff: false,
});
const dialogVisible = computed({
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 qrcodeUrl = ref('')
const sessionId = ref('')
const errorMessage = ref('')
const countdown = ref(180) // 倒计时 3 分钟
const progress = ref(100)
const status = ref('loading'); // loading, pending, success, expired, failed
const qrcodeUrl = ref('');
const sessionId = ref('');
const errorMessage = ref('');
const countdown = ref(180); // 倒计时 3 分钟
const progress = ref(100);
let countdownTimer = null
let countdownTimer = null;
// 获取二维码
const fetchQRCode = async () => {
status.value = 'loading'
status.value = 'loading';
try {
const result = await authStore.loginWithQRCode(props.alias)
sessionId.value = result.session_id
qrcodeUrl.value = `data:image/png;base64,${result.qrcode_base64}`
status.value = 'pending'
const result = await authStore.loginWithQRCode(props.alias);
sessionId.value = result.session_id;
qrcodeUrl.value = `data:image/png;base64,${result.qrcode_base64}`;
status.value = 'pending';
// 开始轮询扫码状态(使用 composable
startQRPolling(
async () => {
const result = await authStore.checkQRCodeStatus(sessionId.value)
const 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 {
completed,
success: result.success === true,
data: result
}
data: result,
};
},
{
onSuccess: (result) => {
status.value = 'success'
stopCountdown()
message.success('登录成功!')
onSuccess: result => {
status.value = 'success';
stopCountdown();
message.success('登录成功!');
// 延迟关闭对话框
setTimeout(() => {
emit('success', result.user)
handleClose()
}, 1500)
emit('success', result.user);
handleClose();
}, 1500);
},
onFailure: (result) => {
onFailure: result => {
if (result.status === 'expired') {
status.value = 'expired'
status.value = 'expired';
} else {
status.value = 'failed'
errorMessage.value = result.message || '扫码失败'
status.value = 'failed';
errorMessage.value = result.message || '扫码失败';
}
stopCountdown()
stopCountdown();
},
onTimeout: () => {
status.value = 'expired'
stopCountdown()
status.value = 'expired';
stopCountdown();
},
}
}
)
);
startCountdown()
startCountdown();
} catch (error) {
status.value = 'failed'
errorMessage.value = error.message || '获取二维码失败'
emit('error', error)
status.value = 'failed';
errorMessage.value = error.message || '获取二维码失败';
emit('error', error);
}
}
};
// 开始倒计时
const startCountdown = () => {
countdown.value = 180
countdown.value = 180;
if (countdownTimer) {
clearInterval(countdownTimer)
clearInterval(countdownTimer);
}
countdownTimer = setInterval(() => {
countdown.value--
progress.value = (countdown.value / 180) * 100
countdown.value--;
progress.value = (countdown.value / 180) * 100;
if (countdown.value <= 0) {
status.value = 'expired'
stopPolling() // 停止轮询
stopCountdown()
status.value = 'expired';
stopPolling(); // 停止轮询
stopCountdown();
}
}, 1000)
}
}, 1000);
};
// 停止倒计时
const stopCountdown = () => {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
clearInterval(countdownTimer);
countdownTimer = null;
}
}
};
// 刷新二维码
const refreshQRCode = () => {
fetchQRCode()
}
fetchQRCode();
};
// 关闭对话框
const handleClose = () => {
stopPolling() // 停止轮询
stopCountdown()
stopPolling(); // 停止轮询
stopCountdown();
// 如果有未完成的会话,取消它
if (sessionId.value && status.value !== 'success') {
try {
authStore.cancelQRCodeSession(sessionId.value)
authStore.cancelQRCodeSession(sessionId.value);
} catch (error) {
console.error('取消会话失败:', error)
console.error('取消会话失败:', error);
}
}
dialogVisible.value = false
}
dialogVisible.value = false;
};
// 监听对话框显示状态
watch(
() => props.visible,
(visible) => {
visible => {
if (visible) {
fetchQRCode()
fetchQRCode();
} else {
stopPolling()
stopCountdown()
stopPolling();
stopCountdown();
}
}
)
);
// 组件卸载时清理定时器,防止内存泄漏
onBeforeUnmount(() => {
stopPolling()
stopCountdown()
})
stopPolling();
stopCountdown();
});
</script>
<style scoped>
+20 -29
View File
@@ -1,12 +1,8 @@
<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">
<component
:is="icon"
class="text-8xl mx-auto"
:class="iconColorClass"
/>
<component :is="icon" class="text-8xl mx-auto" :class="iconColorClass" />
</div>
<!-- 标题 -->
@@ -22,12 +18,7 @@
<!-- 操作按钮可选 -->
<div v-if="$slots.action || actionText">
<slot name="action">
<a-button
v-if="actionText"
type="primary"
@click="handleAction"
:loading="loading"
>
<a-button v-if="actionText" type="primary" :loading="loading" @click="handleAction">
<template v-if="actionIcon" #icon>
<component :is="actionIcon" />
</template>
@@ -39,7 +30,7 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed } from 'vue';
const props = defineProps({
/**
@@ -47,7 +38,7 @@ const props = defineProps({
*/
icon: {
type: Object,
default: null
default: null,
},
/**
@@ -55,7 +46,7 @@ const props = defineProps({
*/
title: {
type: String,
default: ''
default: '',
},
/**
@@ -63,7 +54,7 @@ const props = defineProps({
*/
description: {
type: String,
default: ''
default: '',
},
/**
@@ -71,7 +62,7 @@ const props = defineProps({
*/
actionText: {
type: String,
default: ''
default: '',
},
/**
@@ -79,7 +70,7 @@ const props = defineProps({
*/
actionIcon: {
type: Object,
default: null
default: null,
},
/**
@@ -87,7 +78,7 @@ const props = defineProps({
*/
loading: {
type: Boolean,
default: false
default: false,
},
/**
@@ -96,15 +87,15 @@ const props = defineProps({
iconColor: {
type: String,
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 = () => {
emit('action')
}
emit('action');
};
const iconColorClass = computed(() => {
const colors = {
@@ -112,8 +103,8 @@ const iconColorClass = computed(() => {
neutral: 'text-on-surface-variant',
success: 'text-green-500',
warning: 'text-orange-500',
error: 'text-error'
}
return colors[props.iconColor]
})
error: 'text-error',
};
return colors[props.iconColor];
});
</script>
+20 -39
View File
@@ -2,61 +2,38 @@
<div v-if="loading" class="loading-state">
<!-- 卡片骨架屏 -->
<div v-if="type === 'card'" class="grid grid-cols-1 gap-4">
<a-card
v-for="i in count"
:key="i"
class="md3-card"
>
<a-skeleton
:active="true"
:paragraph="{ rows: paragraphRows }"
:avatar="showAvatar"
/>
<a-card v-for="i in count" :key="i" class="md3-card">
<a-skeleton :active="true" :paragraph="{ rows: paragraphRows }" :avatar="showAvatar" />
</a-card>
</div>
<!-- 列表骨架屏 -->
<div v-else-if="type === 'list'" class="space-y-4">
<a-card
v-for="i in count"
:key="i"
class="md3-card"
>
<a-skeleton
:active="true"
:paragraph="{ rows: 1 }"
:avatar="showAvatar"
/>
<a-card v-for="i in count" :key="i" class="md3-card">
<a-skeleton :active="true" :paragraph="{ rows: 1 }" :avatar="showAvatar" />
</a-card>
</div>
<!-- 表格骨架屏 -->
<a-card v-else-if="type === 'table'" class="md3-card">
<a-skeleton
:active="true"
:paragraph="{ rows: count * 2 }"
/>
<a-skeleton :active="true" :paragraph="{ rows: count * 2 }" />
</a-card>
<!-- 默认骨架屏 -->
<a-card v-else class="md3-card">
<a-skeleton
:active="true"
:paragraph="{ rows: paragraphRows }"
:avatar="showAvatar"
/>
<a-skeleton :active="true" :paragraph="{ rows: paragraphRows }" :avatar="showAvatar" />
</a-card>
</div>
</template>
<script setup>
const props = defineProps({
defineProps({
/**
* 是否显示加载状态
*/
loading: {
type: Boolean,
default: true
default: true,
},
/**
@@ -65,7 +42,7 @@ const props = defineProps({
type: {
type: String,
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: {
type: Number,
default: 3
default: 3,
},
/**
@@ -81,7 +58,7 @@ const props = defineProps({
*/
paragraphRows: {
type: Number,
default: 4
default: 4,
},
/**
@@ -89,9 +66,9 @@ const props = defineProps({
*/
showAvatar: {
type: Boolean,
default: false
}
})
default: false,
},
});
</script>
<style scoped>
@@ -100,7 +77,11 @@ const props = defineProps({
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</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 class="flex items-center text-sm">
<component
:is="trendIcon"
:class="trendColorClass"
class="mr-1"
/>
<component :is="trendIcon" :class="trendColorClass" class="mr-1" />
<span :class="trendColorClass" class="md3-label-small">
{{ trendText }}
</span>
@@ -42,12 +38,8 @@
</template>
<script setup>
import { computed } from 'vue'
import {
ArrowUpOutlined,
ArrowDownOutlined,
MinusOutlined
} from '@ant-design/icons-vue'
import { computed } from 'vue';
import { ArrowUpOutlined, ArrowDownOutlined, MinusOutlined } from '@ant-design/icons-vue';
const props = defineProps({
/**
@@ -55,7 +47,7 @@ const props = defineProps({
*/
label: {
type: String,
required: true
required: true,
},
/**
@@ -63,7 +55,7 @@ const props = defineProps({
*/
value: {
type: [String, Number],
required: true
required: true,
},
/**
@@ -71,7 +63,7 @@ const props = defineProps({
*/
subtitle: {
type: String,
default: ''
default: '',
},
/**
@@ -79,7 +71,7 @@ const props = defineProps({
*/
icon: {
type: Object,
default: null
default: null,
},
/**
@@ -88,7 +80,7 @@ const props = defineProps({
color: {
type: String,
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: {
type: Number,
default: 0
default: 0,
},
/**
@@ -104,7 +96,7 @@ const props = defineProps({
*/
formatter: {
type: Function,
default: null
default: null,
},
/**
@@ -112,7 +104,7 @@ const props = defineProps({
*/
trend: {
type: Number,
default: undefined
default: undefined,
},
/**
@@ -120,73 +112,73 @@ const props = defineProps({
*/
trendText: {
type: String,
default: ''
}
})
default: '',
},
});
// 动画延迟
const animationDelay = computed(() => `${props.delay}s`)
const animationDelay = computed(() => `${props.delay}s`);
// 格式化数值
const formattedValue = computed(() => {
if (props.formatter) {
return props.formatter(props.value)
return props.formatter(props.value);
}
return props.value
})
return props.value;
});
// 颜色映射
const colorClasses = {
primary: {
value: 'text-primary',
iconBg: 'bg-primary-100 dark:bg-primary-900/30',
icon: 'text-primary'
icon: 'text-primary',
},
success: {
value: 'text-green-600 dark:text-green-400',
iconBg: 'bg-green-100 dark:bg-green-900/30',
icon: 'text-green-600 dark:text-green-400'
icon: 'text-green-600 dark:text-green-400',
},
warning: {
value: 'text-orange-600 dark:text-orange-400',
iconBg: 'bg-orange-100 dark:bg-orange-900/30',
icon: 'text-orange-600 dark:text-orange-400'
icon: 'text-orange-600 dark:text-orange-400',
},
error: {
value: 'text-error',
iconBg: 'bg-red-100 dark:bg-red-900/30',
icon: 'text-error'
icon: 'text-error',
},
info: {
value: 'text-secondary',
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
icon: 'text-secondary'
icon: 'text-secondary',
},
neutral: {
value: 'text-on-surface',
iconBg: 'bg-surface-container',
icon: 'text-on-surface-variant'
}
}
icon: 'text-on-surface-variant',
},
};
const valueColorClass = computed(() => colorClasses[props.color].value)
const iconBgClass = computed(() => colorClasses[props.color].iconBg)
const iconColorClass = computed(() => colorClasses[props.color].icon)
const valueColorClass = computed(() => colorClasses[props.color].value);
const iconBgClass = computed(() => colorClasses[props.color].iconBg);
const iconColorClass = computed(() => colorClasses[props.color].icon);
// 趋势图标和颜色
const trendIcon = computed(() => {
if (props.trend === undefined) return null
if (props.trend > 0) return ArrowUpOutlined
if (props.trend < 0) return ArrowDownOutlined
return MinusOutlined
})
if (props.trend === undefined) return null;
if (props.trend > 0) return ArrowUpOutlined;
if (props.trend < 0) return ArrowDownOutlined;
return MinusOutlined;
});
const trendColorClass = computed(() => {
if (props.trend === undefined) return ''
if (props.trend > 0) return 'text-green-600 dark:text-green-400'
if (props.trend < 0) return 'text-red-600 dark:text-red-400'
return 'text-on-surface-variant'
})
if (props.trend === undefined) return '';
if (props.trend > 0) return 'text-green-600 dark:text-green-400';
if (props.trend < 0) return 'text-red-600 dark:text-red-400';
return 'text-on-surface-variant';
});
</script>
<style scoped>
+23 -23
View File
@@ -13,12 +13,12 @@
* }
*/
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { ref } from 'vue';
import { message } from 'ant-design-vue';
export function useAsyncAction(options = {}) {
const loading = ref(false)
const error = ref(null)
const loading = ref(false);
const error = ref(null);
/**
* 执行异步操作
@@ -35,50 +35,50 @@ export function useAsyncAction(options = {}) {
successMsg = options.successMsg,
errorMsg = options.errorMsg,
throwOnError = false,
silent = false
} = config
silent = false,
} = config;
loading.value = true
error.value = null
loading.value = true;
error.value = null;
try {
const result = await asyncFn()
const result = await asyncFn();
if (!silent && successMsg) {
message.success(successMsg)
message.success(successMsg);
}
return result
return result;
} catch (err) {
error.value = err
error.value = err;
if (!silent) {
const msg = err.message || err.detail || errorMsg || '操作失败'
message.error(msg)
const msg = err.message || err.detail || errorMsg || '操作失败';
message.error(msg);
}
if (throwOnError) {
throw err
throw err;
}
return null
return null;
} finally {
loading.value = false
}
loading.value = false;
}
};
/**
* 重置状态
*/
const reset = () => {
loading.value = false
error.value = null
}
loading.value = false;
error.value = null;
};
return {
loading,
error,
execute,
reset
}
reset,
};
}
+26 -26
View File
@@ -1,4 +1,4 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue';
/**
* 响应式断点检测 Composable
@@ -11,42 +11,42 @@ import { ref, onMounted, onUnmounted } from 'vue'
* - xxl: ≥1600px (超大屏)
*/
export function useBreakpoint() {
const isMobile = ref(window.innerWidth < 768)
const isTablet = ref(window.innerWidth >= 768 && window.innerWidth < 992)
const isDesktop = ref(window.innerWidth >= 992)
const isMobile = ref(window.innerWidth < 768);
const isTablet = ref(window.innerWidth >= 768 && window.innerWidth < 992);
const isDesktop = ref(window.innerWidth >= 992);
// Ant Design 断点
const isXs = ref(window.innerWidth < 576)
const isSm = ref(window.innerWidth >= 576 && window.innerWidth < 768)
const isMd = ref(window.innerWidth >= 768 && window.innerWidth < 992)
const isLg = ref(window.innerWidth >= 992 && window.innerWidth < 1200)
const isXl = ref(window.innerWidth >= 1200 && window.innerWidth < 1600)
const isXxl = ref(window.innerWidth >= 1600)
const isXs = ref(window.innerWidth < 576);
const isSm = ref(window.innerWidth >= 576 && window.innerWidth < 768);
const isMd = ref(window.innerWidth >= 768 && window.innerWidth < 992);
const isLg = ref(window.innerWidth >= 992 && window.innerWidth < 1200);
const isXl = ref(window.innerWidth >= 1200 && window.innerWidth < 1600);
const isXxl = ref(window.innerWidth >= 1600);
const updateBreakpoints = () => {
const width = window.innerWidth
const width = window.innerWidth;
// 简化断点
isMobile.value = width < 768
isTablet.value = width >= 768 && width < 992
isDesktop.value = width >= 992
isMobile.value = width < 768;
isTablet.value = width >= 768 && width < 992;
isDesktop.value = width >= 992;
// Ant Design 断点
isXs.value = width < 576
isSm.value = width >= 576 && width < 768
isMd.value = width >= 768 && width < 992
isLg.value = width >= 992 && width < 1200
isXl.value = width >= 1200 && width < 1600
isXxl.value = width >= 1600
}
isXs.value = width < 576;
isSm.value = width >= 576 && width < 768;
isMd.value = width >= 768 && width < 992;
isLg.value = width >= 992 && width < 1200;
isXl.value = width >= 1200 && width < 1600;
isXxl.value = width >= 1600;
};
onMounted(() => {
window.addEventListener('resize', updateBreakpoints)
})
window.addEventListener('resize', updateBreakpoints);
});
onUnmounted(() => {
window.removeEventListener('resize', updateBreakpoints)
})
window.removeEventListener('resize', updateBreakpoints);
});
return {
// 简化断点(常用)
@@ -61,5 +61,5 @@ export function useBreakpoint() {
isLg,
isXl,
isXxl,
}
};
}
+36 -40
View File
@@ -26,19 +26,19 @@
* )
*/
import { ref, onUnmounted } from 'vue'
import { ref, onUnmounted } from 'vue';
export function usePollStatus(options = {}) {
const {
interval = 2000, // 初始轮询间隔(毫秒)
maxRetries = 15, // 最大重试次数
backoff = false, // 是否使用指数退避
maxBackoffInterval = 10000 // 最大退避间隔(毫秒)
} = options
maxBackoffInterval = 10000, // 最大退避间隔(毫秒)
} = options;
const polling = ref(false)
let pollTimer = null
let retryCount = 0
const polling = ref(false);
let pollTimer = null;
let retryCount = 0;
/**
* 开始轮询
@@ -49,80 +49,76 @@ export function usePollStatus(options = {}) {
* @param {Function} callbacks.onTimeout - 超时回调
*/
const startPolling = async (checkFn, callbacks = {}) => {
const { onSuccess, onFailure, onTimeout } = callbacks
const { onSuccess, onFailure, onTimeout } = callbacks;
// 重置状态
stopPolling()
polling.value = true
retryCount = 0
stopPolling();
polling.value = true;
retryCount = 0;
const poll = async () => {
try {
const result = await checkFn()
const result = await checkFn();
// 检查是否完成
if (result.completed) {
stopPolling()
stopPolling();
if (result.success) {
onSuccess?.(result.data || result)
onSuccess?.(result.data || result);
} else {
onFailure?.(result.data || result)
onFailure?.(result.data || result);
}
return
return;
}
// 检查是否超时
retryCount++
retryCount++;
if (retryCount >= maxRetries) {
stopPolling()
onTimeout?.()
return
stopPolling();
onTimeout?.();
return;
}
// 计算下次轮询间隔(支持指数退避)
let nextInterval = interval
let nextInterval = interval;
if (backoff) {
// 指数退避:2s -> 4s -> 8s -> 最大10s
nextInterval = Math.min(
interval * Math.pow(2, retryCount - 1),
maxBackoffInterval
)
nextInterval = Math.min(interval * Math.pow(2, retryCount - 1), maxBackoffInterval);
}
// 继续轮询
pollTimer = setTimeout(poll, nextInterval)
pollTimer = setTimeout(poll, nextInterval);
} catch (error) {
stopPolling()
onFailure?.(error)
}
stopPolling();
onFailure?.(error);
}
};
// 立即执行第一次检查
poll()
}
poll();
};
/**
* 停止轮询
*/
const stopPolling = () => {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
polling.value = false
retryCount = 0
clearTimeout(pollTimer);
pollTimer = null;
}
polling.value = false;
retryCount = 0;
};
// 组件卸载时自动清理
onUnmounted(() => {
stopPolling()
})
stopPolling();
});
return {
polling,
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
*/
const applyTheme = (newTheme) => {
const html = document.documentElement
const applyTheme = newTheme => {
const html = document.documentElement;
if (newTheme === 'dark') {
html.classList.add('dark')
html.classList.add('dark');
} else {
html.classList.remove('dark')
html.classList.remove('dark');
}
}
};
/**
* 初始化主题
@@ -24,48 +24,48 @@ const applyTheme = (newTheme) => {
*/
export const initTheme = () => {
// 1. 尝试从 localStorage 读取
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY)
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (savedTheme === 'light' || savedTheme === 'dark') {
theme.value = savedTheme
applyTheme(savedTheme)
return
theme.value = savedTheme;
applyTheme(savedTheme);
return;
}
// 2. 检测系统偏好
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
theme.value = 'dark'
applyTheme('dark')
return
theme.value = 'dark';
applyTheme('dark');
return;
}
// 3. 默认亮色
theme.value = 'light'
applyTheme('light')
}
theme.value = 'light';
applyTheme('light');
};
/**
* 监听系统主题变化
*/
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) {
const systemTheme = e.matches ? 'dark' : 'light'
theme.value = systemTheme
applyTheme(systemTheme)
}
const systemTheme = e.matches ? 'dark' : 'light';
theme.value = systemTheme;
applyTheme(systemTheme);
}
};
mediaQuery.addEventListener('change', handleChange)
mediaQuery.addEventListener('change', handleChange);
// 返回清理函数
return () => mediaQuery.removeEventListener('change', handleChange)
}
return () => mediaQuery.removeEventListener('change', handleChange);
};
/**
* 主题管理 Composable
@@ -76,31 +76,31 @@ export function useTheme() {
* 切换主题
*/
const toggleTheme = () => {
const newTheme = theme.value === 'light' ? 'dark' : 'light'
theme.value = newTheme
applyTheme(newTheme)
localStorage.setItem(THEME_STORAGE_KEY, newTheme)
}
const newTheme = theme.value === 'light' ? 'dark' : 'light';
theme.value = newTheme;
applyTheme(newTheme);
localStorage.setItem(THEME_STORAGE_KEY, newTheme);
};
/**
* 设置指定主题
*/
const setTheme = (newTheme) => {
const setTheme = newTheme => {
if (newTheme !== 'light' && newTheme !== 'dark') {
console.warn(`Invalid theme: ${newTheme}. Using 'light' instead.`)
newTheme = 'light'
console.warn(`Invalid theme: ${newTheme}. Using 'light' instead.`);
newTheme = 'light';
}
theme.value = newTheme
applyTheme(newTheme)
localStorage.setItem(THEME_STORAGE_KEY, newTheme)
}
theme.value = newTheme;
applyTheme(newTheme);
localStorage.setItem(THEME_STORAGE_KEY, newTheme);
};
return {
theme,
toggleTheme,
setTheme,
isDark: computed(() => theme.value === 'dark'),
isLight: computed(() => theme.value === 'light')
}
isLight: computed(() => theme.value === 'light'),
};
}
+62 -63
View File
@@ -1,8 +1,8 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { message } from 'ant-design-vue'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
import { computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { useAuthStore } from '@/stores/auth';
import { useUserStore } from '@/stores/user';
import { useRouter } from 'vue-router';
/**
* Token 过期监控 Composable
@@ -16,49 +16,49 @@ import { useRouter } from 'vue-router'
*/
// 全局单例:确保整个应用只有一个监控实例
let monitorTimer = null
let warningShown = false
let isMonitoring = false // 新增:防止重复启动
let monitorTimer = null;
let warningShown = false;
let isMonitoring = false; // 新增:防止重复启动
// 检查间隔(毫秒)
const NORMAL_CHECK_INTERVAL = 15 * 60 * 1000 // 正常情况:15 分钟
const URGENT_CHECK_INTERVAL = 5 * 60 * 1000 // Token 即将过期:5 分钟
const NORMAL_CHECK_INTERVAL = 15 * 60 * 1000; // 正常情况:15 分钟
const URGENT_CHECK_INTERVAL = 5 * 60 * 1000; // Token 即将过期:5 分钟
export function useTokenMonitor() {
const authStore = useAuthStore()
const userStore = useUserStore()
const router = useRouter()
const authStore = useAuthStore();
const userStore = useUserStore();
const router = useRouter();
const tokenStatus = computed(() => userStore.tokenStatus)
const hasPassword = computed(() => authStore.user?.has_password || false)
const tokenStatus = computed(() => userStore.tokenStatus);
const hasPassword = computed(() => authStore.user?.has_password || false);
// 计算 Token 剩余分钟数
const getRemainingMinutes = () => {
if (!tokenStatus.value?.expires_at) return null
if (!tokenStatus.value?.expires_at) return null;
const now = Math.floor(Date.now() / 1000)
const expiresAt = tokenStatus.value.expires_at
const diffSeconds = expiresAt - now
const now = Math.floor(Date.now() / 1000);
const expiresAt = tokenStatus.value.expires_at;
const diffSeconds = expiresAt - now;
return Math.floor(diffSeconds / 60)
}
return Math.floor(diffSeconds / 60);
};
// 检查 Token 状态并显示提醒
const checkTokenStatus = async () => {
// 如果未登录,不检查
if (!authStore.isAuthenticated) {
return
return;
}
try {
// 获取最新的 Token 状态
await userStore.fetchTokenStatus()
await userStore.fetchTokenStatus();
const remainingMinutes = getRemainingMinutes()
const remainingMinutes = getRemainingMinutes();
// Token 已过期(负数分钟)
if (remainingMinutes !== null && remainingMinutes < 0) {
const expiredMinutes = Math.abs(remainingMinutes)
const expiredMinutes = Math.abs(remainingMinutes);
// Token 过期后 5 分钟内提醒
if (expiredMinutes <= 5) {
@@ -69,8 +69,8 @@ export function useTokenMonitor() {
content: `您的登录凭证已过期 ${expiredMinutes} 分钟,部分功能可能受限。建议您扫码刷新凭证。`,
duration: 8,
key: 'token-expired-warning',
})
warningShown = true
});
warningShown = true;
}
} else {
// 没有密码的用户:必须重新登录
@@ -78,18 +78,18 @@ export function useTokenMonitor() {
content: '您的登录凭证已过期,请重新扫码登录',
duration: 5,
key: 'token-expired-error',
})
});
// 清除登录状态并跳转
authStore.logout()
router.push('/login')
authStore.logout();
router.push('/login');
}
} else if (expiredMinutes > 5) {
// 过期超过 5 分钟
if (!hasPassword.value) {
// 没有密码的用户:强制退出
authStore.logout()
router.push('/login')
authStore.logout();
router.push('/login');
}
}
}
@@ -100,82 +100,81 @@ export function useTokenMonitor() {
content: `您的登录凭证将在 ${remainingMinutes} 分钟后过期,建议您提前刷新`,
duration: 6,
key: 'token-expiring-warning',
})
warningShown = true
});
warningShown = true;
}
// Token 即将过期时,切换到更频繁的检查(5 分钟)
adjustCheckInterval(URGENT_CHECK_INTERVAL)
adjustCheckInterval(URGENT_CHECK_INTERVAL);
}
// Token 状态正常
else if (remainingMinutes !== null && remainingMinutes > 60) {
// 重置警告标志
warningShown = false
warningShown = false;
// 恢复正常检查频率(15 分钟)
adjustCheckInterval(NORMAL_CHECK_INTERVAL)
adjustCheckInterval(NORMAL_CHECK_INTERVAL);
}
} catch (error) {
console.error('检查 Token 状态失败:', error)
}
console.error('检查 Token 状态失败:', error);
}
};
// 调整检查间隔
const adjustCheckInterval = (newInterval) => {
const adjustCheckInterval = newInterval => {
if (monitorTimer) {
const currentInterval = monitorTimer._idleTimeout || 0
const currentInterval = monitorTimer._idleTimeout || 0;
// 只有当新间隔与当前间隔不同时才重启定时器
if (currentInterval !== newInterval) {
clearInterval(monitorTimer)
clearInterval(monitorTimer);
monitorTimer = setInterval(() => {
checkTokenStatus()
}, newInterval)
}
checkTokenStatus();
}, newInterval);
}
}
};
// 启动监控
const startMonitoring = () => {
// 避免重复启动(单例模式)
if (isMonitoring || monitorTimer) {
return
return;
}
isMonitoring = true
isMonitoring = true;
// 立即检查一次
checkTokenStatus()
checkTokenStatus();
// 默认使用正常检查频率(15 分钟)
monitorTimer = setInterval(() => {
checkTokenStatus()
}, NORMAL_CHECK_INTERVAL)
}
checkTokenStatus();
}, NORMAL_CHECK_INTERVAL);
};
// 停止监控
const stopMonitoring = () => {
if (monitorTimer) {
clearInterval(monitorTimer)
monitorTimer = null
}
isMonitoring = false
warningShown = false
clearInterval(monitorTimer);
monitorTimer = null;
}
isMonitoring = false;
warningShown = false;
};
// 手动触发检查
const checkNow = () => {
warningShown = false // 重置警告标志,允许再次显示
checkTokenStatus()
}
warningShown = false; // 重置警告标志,允许再次显示
checkTokenStatus();
};
// 组件挂载时启动监控
onMounted(() => {
if (authStore.isAuthenticated) {
startMonitoring()
startMonitoring();
}
})
});
// 组件卸载时不停止监控(因为是全局单例)
// onUnmounted 中不调用 stopMonitoring(),让监控持续运行
@@ -187,5 +186,5 @@ export function useTokenMonitor() {
stopMonitoring,
checkNow,
getRemainingMinutes,
}
};
}
+13 -13
View File
@@ -1,21 +1,21 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createApp } from 'vue';
import { createPinia } from 'pinia';
// Ant Design Vue
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import Antd from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import App from './App.vue'
import router from './router'
import './style.css'
import App from './App.vue';
import router from './router';
import './style.css';
const app = createApp(App)
const pinia = createPinia()
const app = createApp(App);
const pinia = createPinia();
app.use(pinia)
app.use(router)
app.use(pinia);
app.use(router);
// 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 { useAuthStore } from '@/stores/auth'
import { userAPI } from '@/api'
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { userAPI } from '@/api';
const routes = [
{
@@ -91,72 +91,72 @@ const routes = [
component: () => import('@/views/NotFoundView.vue'),
meta: { requiresAuth: false, title: '页面未找到' },
},
]
];
const router = createRouter({
history: createWebHistory(),
routes,
})
});
// 全局前置守卫
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 (!authStore.isAuthenticated) {
// 未登录,重定向到登录页
next({ name: 'Login', query: { redirect: to.fullPath } })
return
next({ name: 'Login', query: { redirect: to.fullPath } });
return;
}
// 检查用户审批状态(除了待审批页面本身)
if (to.name !== 'PendingApproval') {
try {
const status = await userAPI.getUserStatus()
const status = await userAPI.getUserStatus();
if (!status.is_approved) {
// 未审批用户只能访问待审批页面
next({ name: 'PendingApproval' })
return
next({ name: 'PendingApproval' });
return;
}
} catch (error) {
console.error('检查审批状态失败:', error)
console.error('检查审批状态失败:', error);
// 如果检查失败,允许继续访问(避免阻塞正常用户)
}
} else {
// 访问待审批页面时,检查是否已审批
try {
const status = await userAPI.getUserStatus()
const status = await userAPI.getUserStatus();
if (status.is_approved) {
// 已审批用户不能访问待审批页面
next({ name: 'Dashboard' })
return
next({ name: 'Dashboard' });
return;
}
} catch (error) {
console.error('检查审批状态失败:', error)
console.error('检查审批状态失败:', error);
}
}
// 检查是否需要管理员权限
if (to.meta.requiresAdmin && !authStore.isAdmin) {
// 非管理员,重定向到仪表盘
next({ name: 'Dashboard' })
return
next({ name: 'Dashboard' });
return;
}
} else {
// 不需要认证的页面,如果已登录则重定向到仪表盘
if (to.name === 'Login' && authStore.isAuthenticated) {
next({ name: 'Dashboard' })
return
next({ name: 'Dashboard' });
return;
}
}
next()
})
next();
});
export default router
export default router;
+24 -24
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { adminAPI } from '@/api'
import { defineStore } from 'pinia';
import { adminAPI } from '@/api';
export const useAdminStore = defineStore('admin', {
state: () => ({
@@ -10,53 +10,53 @@ export const useAdminStore = defineStore('admin', {
}),
getters: {
totalUsers: (state) => state.stats?.users?.total || 0,
activeUsers: (state) => {
totalUsers: state => state.stats?.users?.total || 0,
activeUsers: state => {
// 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,
todayRecords: (state) => state.stats?.check_in_records?.today || 0,
totalRecords: state => state.stats?.check_in_records?.total || 0,
todayRecords: state => state.stats?.check_in_records?.today || 0,
},
actions: {
// 获取系统统计信息
async fetchStats() {
this.loading = true
this.loading = true;
try {
const stats = await adminAPI.getStats()
this.stats = stats
return stats
const stats = await adminAPI.getStats();
this.stats = stats;
return stats;
} catch (error) {
throw new Error(error.message || '获取统计信息失败')
throw new Error(error.message || '获取统计信息失败');
} finally {
this.loading = false
this.loading = false;
}
},
// 批量触发打卡
async batchCheckIn(userIds) {
try {
const result = await adminAPI.batchCheckIn(userIds)
return result
const result = await adminAPI.batchCheckIn(userIds);
return result;
} catch (error) {
throw new Error(error.message || '批量打卡失败')
throw new Error(error.message || '批量打卡失败');
}
},
// 获取系统日志
async fetchLogs(params = {}) {
this.loading = true
this.loading = true;
try {
const data = await adminAPI.getLogs(params)
this.logs = data.logs || data
this.logsTotal = data.total || this.logs.length
return data
const data = await adminAPI.getLogs(params);
this.logs = data.logs || data;
this.logsTotal = data.total || this.logs.length;
return data;
} catch (error) {
throw new Error(error.message || '获取日志失败')
throw new Error(error.message || '获取日志失败');
} finally {
this.loading = false
this.loading = false;
}
},
},
})
});
+45 -45
View File
@@ -1,133 +1,133 @@
import { defineStore } from 'pinia'
import { authAPI, userAPI } from '@/api'
import { defineStore } from 'pinia';
import { authAPI, userAPI } from '@/api';
export const useAuthStore = defineStore('auth', {
state: () => {
// 安全地解析 localStorage 中的用户数据
let user = null
let user = null;
try {
const userStr = localStorage.getItem('user')
const userStr = localStorage.getItem('user');
if (userStr && userStr !== 'undefined' && userStr !== 'null') {
user = JSON.parse(userStr)
user = JSON.parse(userStr);
}
} catch (e) {
console.warn('Failed to parse user from localStorage:', e)
localStorage.removeItem('user')
console.warn('Failed to parse user from localStorage:', e);
localStorage.removeItem('user');
}
return {
token: localStorage.getItem('token') || null,
user,
}
};
},
getters: {
// 将 isAuthenticated 改为 getter,这样它会实时反应 state.token 的变化
isAuthenticated: (state) => !!state.token,
isAdmin: (state) => state.user?.role === 'admin',
isAuthenticated: state => !!state.token,
isAdmin: state => state.user?.role === 'admin',
},
actions: {
// 设置认证信息
setAuth(token, user) {
// 清理 token:移除 URL 编码的 Bearer 前缀
let cleanToken = token
let cleanToken = token;
if (cleanToken) {
// URL 解码
cleanToken = decodeURIComponent(cleanToken)
cleanToken = decodeURIComponent(cleanToken);
// 移除 Bearer 前缀(如果存在)
if (cleanToken.toLowerCase().startsWith('bearer ')) {
cleanToken = cleanToken.substring(7)
cleanToken = cleanToken.substring(7);
}
}
this.token = cleanToken
this.user = user
this.token = cleanToken;
this.user = user;
localStorage.setItem('token', cleanToken)
localStorage.setItem('user', JSON.stringify(user))
localStorage.setItem('token', cleanToken);
localStorage.setItem('user', JSON.stringify(user));
},
// 清除认证信息
clearAuth() {
this.token = null
this.user = null
this.token = null;
this.user = null;
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('token');
localStorage.removeItem('user');
},
// QR 码登录流程
async loginWithQRCode(alias) {
try {
// 1. 请求 QR 码
const qrData = await authAPI.requestQRCode(alias)
const { session_id, qrcode_base64 } = qrData
const qrData = await authAPI.requestQRCode(alias);
const { session_id, qrcode_base64 } = qrData;
// 2. 返回 session_id 和 qrcode,由组件处理轮询
return { session_id, qrcode_base64 }
return { session_id, qrcode_base64 };
} catch (error) {
throw new Error(error.message || '请求二维码失败')
throw new Error(error.message || '请求二维码失败');
}
},
// 检查扫码状态
async checkQRCodeStatus(sessionId) {
try {
const result = await authAPI.getQRCodeStatus(sessionId)
const result = await authAPI.getQRCodeStatus(sessionId);
if (result.status === 'success') {
// 扫码成功,保存 Token 和用户信息
this.setAuth(result.token, result.user)
return { success: true, user: result.user }
this.setAuth(result.token, result.user);
return { success: true, user: result.user };
} else if (result.status === 'failed') {
return { success: false, message: result.message }
return { success: false, message: result.message };
} else {
// pending 或 expired
return { success: false, status: result.status }
return { success: false, status: result.status };
}
} catch (error) {
throw new Error(error.message || '检查扫码状态失败')
throw new Error(error.message || '检查扫码状态失败');
}
},
// 取消扫码会话
async cancelQRCodeSession(sessionId) {
try {
await authAPI.cancelQRCodeSession(sessionId)
await authAPI.cancelQRCodeSession(sessionId);
} catch (error) {
console.error('取消会话失败:', error)
console.error('取消会话失败:', error);
}
},
// 验证 Token
async verifyToken(token) {
try {
const userData = await authAPI.verifyToken(token)
this.setAuth(token, userData)
return userData
const userData = await authAPI.verifyToken(token);
this.setAuth(token, userData);
return userData;
} catch (error) {
this.clearAuth()
throw new Error(error.message || 'Token 验证失败')
this.clearAuth();
throw new Error(error.message || 'Token 验证失败');
}
},
// 获取当前用户信息
async fetchCurrentUser() {
try {
const userData = await userAPI.getCurrentUser()
const userData = await userAPI.getCurrentUser();
// 更新本地用户信息
this.user = userData
localStorage.setItem('user', JSON.stringify(userData))
return userData
this.user = userData;
localStorage.setItem('user', JSON.stringify(userData));
return userData;
} catch (error) {
throw new Error(error.message || '获取用户信息失败')
throw new Error(error.message || '获取用户信息失败');
}
},
// 登出
logout() {
this.clearAuth()
this.clearAuth();
},
},
})
});
+36 -36
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { checkInAPI } from '@/api'
import { defineStore } from 'pinia';
import { checkInAPI } from '@/api';
export const useCheckInStore = defineStore('checkIn', {
state: () => ({
@@ -12,83 +12,83 @@ export const useCheckInStore = defineStore('checkIn', {
}),
getters: {
todayRecords: (state) => {
const today = new Date().toISOString().split('T')[0]
return state.myRecords.filter((record) => {
const recordDate = new Date(record.check_in_time).toISOString().split('T')[0]
return recordDate === today
})
todayRecords: state => {
const today = new Date().toISOString().split('T')[0];
return state.myRecords.filter(record => {
const recordDate = new Date(record.check_in_time).toISOString().split('T')[0];
return recordDate === today;
});
},
successRate: (state) => {
if (state.myRecords.length === 0) return 0
const successCount = state.myRecords.filter((r) => r.status === 'success').length
return ((successCount / state.myRecords.length) * 100).toFixed(2)
successRate: state => {
if (state.myRecords.length === 0) return 0;
const successCount = state.myRecords.filter(r => r.status === 'success').length;
return ((successCount / state.myRecords.length) * 100).toFixed(2);
},
},
actions: {
// 手动打卡
async manualCheckIn() {
this.loading = true
this.loading = true;
try {
const result = await checkInAPI.manualCheckIn()
const result = await checkInAPI.manualCheckIn();
// 刷新打卡记录
await this.fetchMyRecords()
return result
await this.fetchMyRecords();
return result;
} catch (error) {
throw new Error(error.message || '打卡失败')
throw new Error(error.message || '打卡失败');
} finally {
this.loading = false
this.loading = false;
}
},
// 获取我的打卡记录
async fetchMyRecords(params = {}) {
this.loading = true
this.loading = true;
try {
const data = await checkInAPI.getMyRecords({
skip: (this.currentPage - 1) * this.pageSize,
limit: this.pageSize,
...params,
})
this.myRecords = data.records || data
this.total = data.total || this.myRecords.length
return data
});
this.myRecords = data.records || data;
this.total = data.total || this.myRecords.length;
return data;
} catch (error) {
throw new Error(error.message || '获取打卡记录失败')
throw new Error(error.message || '获取打卡记录失败');
} finally {
this.loading = false
this.loading = false;
}
},
// 获取所有打卡记录(管理员)
async fetchAllRecords(params = {}) {
this.loading = true
this.loading = true;
try {
const data = await checkInAPI.getAllRecords({
skip: (this.currentPage - 1) * this.pageSize,
limit: this.pageSize,
...params,
})
this.allRecords = data.records || data
this.total = data.total || this.allRecords.length
return data
});
this.allRecords = data.records || data;
this.total = data.total || this.allRecords.length;
return data;
} catch (error) {
throw new Error(error.message || '获取打卡记录失败')
throw new Error(error.message || '获取打卡记录失败');
} finally {
this.loading = false
this.loading = false;
}
},
// 统计打卡记录
async getRecordsCount(params = {}) {
try {
const count = await checkInAPI.getRecordsCount(params)
return count
const count = await checkInAPI.getRecordsCount(params);
return count;
} 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 api from '@/api'
import { defineStore } from 'pinia';
import api from '@/api';
export const useTaskStore = defineStore('task', {
state: () => ({
@@ -11,176 +11,173 @@ export const useTaskStore = defineStore('task', {
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,
active: state.tasks.filter(t => t.is_active).length,
inactive: state.tasks.filter(t => !t.is_active).length,
}),
// 根据 ID 获取任务
getTaskById: (state) => (taskId) => {
return state.tasks.find(t => t.id === taskId)
getTaskById: state => taskId => {
return state.tasks.find(t => t.id === taskId);
},
},
actions: {
// 获取当前用户的所有任务
async fetchMyTasks(includeInactive = true) {
this.loading = true
this.error = null
this.loading = true;
this.error = null;
try {
const tasks = await api.task.getMyTasks({ include_inactive: includeInactive })
this.tasks = tasks
return tasks
const tasks = await api.task.getMyTasks({ include_inactive: includeInactive });
this.tasks = tasks;
return tasks;
} catch (error) {
this.error = error.message || '获取任务列表失败'
throw error
this.error = error.message || '获取任务列表失败';
throw error;
} finally {
this.loading = false
this.loading = false;
}
},
// 创建新任务
async createTask(taskData) {
this.loading = true
this.error = null
this.loading = true;
this.error = null;
try {
const newTask = await api.task.createTask(taskData)
this.tasks.unshift(newTask) // 添加到列表开头
return newTask
const newTask = await api.task.createTask(taskData);
this.tasks.unshift(newTask); // 添加到列表开头
return newTask;
} catch (error) {
// 解析后端错误信息
let errorMsg = error.message || '创建任务失败'
let errorMsg = error.message || '创建任务失败';
this.error = errorMsg
throw new Error(errorMsg)
this.error = errorMsg;
throw new Error(errorMsg);
} finally {
this.loading = false
this.loading = false;
}
},
// 更新任务
async updateTask(taskId, taskData) {
this.loading = true
this.error = null
this.loading = true;
this.error = null;
try {
const updatedTask = await api.task.updateTask(taskId, taskData)
const index = this.tasks.findIndex(t => t.id === taskId)
const updatedTask = await api.task.updateTask(taskId, taskData);
const index = this.tasks.findIndex(t => t.id === taskId);
if (index !== -1) {
this.tasks[index] = updatedTask
this.tasks[index] = updatedTask;
}
return updatedTask
return updatedTask;
} catch (error) {
this.error = error.message || '更新任务失败'
throw error
this.error = error.message || '更新任务失败';
throw error;
} finally {
this.loading = false
this.loading = false;
}
},
// 删除任务
async deleteTask(taskId) {
this.loading = true
this.error = null
this.loading = true;
this.error = null;
try {
await api.task.deleteTask(taskId)
this.tasks = this.tasks.filter(t => t.id !== taskId)
await api.task.deleteTask(taskId);
this.tasks = this.tasks.filter(t => t.id !== taskId);
} catch (error) {
this.error = error.message || '删除任务失败'
throw error
this.error = error.message || '删除任务失败';
throw error;
} finally {
this.loading = false
this.loading = false;
}
},
// 切换任务启用状态
async toggleTask(taskId) {
this.loading = true
this.error = null
this.loading = true;
this.error = null;
try {
const updatedTask = await api.task.toggleTask(taskId)
const index = this.tasks.findIndex(t => t.id === taskId)
const updatedTask = await api.task.toggleTask(taskId);
const index = this.tasks.findIndex(t => t.id === taskId);
if (index !== -1) {
// 保留原任务的 last_check_in_time 和 last_check_in_status
const originalTask = this.tasks[index]
const originalTask = this.tasks[index];
this.tasks[index] = {
...updatedTask,
last_check_in_time: updatedTask.last_check_in_time || originalTask.last_check_in_time,
last_check_in_status: updatedTask.last_check_in_status || originalTask.last_check_in_status,
last_check_in_status:
updatedTask.last_check_in_status || originalTask.last_check_in_status,
};
}
}
return updatedTask
return updatedTask;
} catch (error) {
this.error = error.message || '切换任务状态失败'
throw error
this.error = error.message || '切换任务状态失败';
throw error;
} finally {
this.loading = false
this.loading = false;
}
},
// 获取任务详情
async fetchTask(taskId) {
this.loading = true
this.error = null
this.loading = true;
this.error = null;
try {
const task = await api.task.getTask(taskId)
this.currentTask = task
return task
const task = await api.task.getTask(taskId);
this.currentTask = task;
return task;
} catch (error) {
this.error = error.message || '获取任务详情失败'
throw error
this.error = error.message || '获取任务详情失败';
throw error;
} finally {
this.loading = false
this.loading = false;
}
},
// 手动触发任务打卡(异步方式,立即返回 record_id)
async checkInTask(taskId) {
// Don't set global loading state to avoid blocking UI during long check-in operations
this.error = null
this.error = null;
try {
const result = await api.task.checkInTask(taskId)
return result
const result = await api.task.checkInTask(taskId);
return result;
} catch (error) {
this.error = error.message || '打卡失败'
throw error
this.error = error.message || '打卡失败';
throw error;
}
},
// 查询打卡记录状态
async getCheckInRecordStatus(recordId) {
try {
const result = await api.task.getCheckInRecordStatus(recordId)
return result
} catch (error) {
throw error
}
const result = await api.task.getCheckInRecordStatus(recordId);
return result;
},
// 获取任务的打卡记录
async fetchTaskRecords(taskId, params = {}) {
this.loading = true
this.error = null
this.loading = true;
this.error = null;
try {
const records = await api.task.getTaskRecords(taskId, params)
return records
const records = await api.task.getTaskRecords(taskId, params);
return records;
} catch (error) {
this.error = error.message || '获取打卡记录失败'
throw error
this.error = error.message || '获取打卡记录失败';
throw error;
} finally {
this.loading = false
this.loading = false;
}
},
// 清空当前任务
clearCurrentTask() {
this.currentTask = null
this.currentTask = null;
},
},
})
});
+72 -72
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { templateAPI } from '@/api'
import { defineStore } from 'pinia';
import { templateAPI } from '@/api';
export const useTemplateStore = defineStore('template', {
state: () => ({
@@ -10,153 +10,153 @@ export const useTemplateStore = defineStore('template', {
}),
getters: {
activeTemplates: (state) => state.templates.filter((t) => t.is_active),
activeTemplates: state => state.templates.filter(t => t.is_active),
getTemplateById: (state) => (id) => {
return state.templates.find((t) => t.id === id)
getTemplateById: state => id => {
return state.templates.find(t => t.id === id);
},
},
actions: {
async fetchTemplates(isActive = null) {
this.loading = true
this.error = null
this.loading = true;
this.error = null;
try {
const params = {}
const params = {};
if (isActive !== null) {
params.is_active = isActive
params.is_active = isActive;
}
this.templates = await templateAPI.getTemplates(params)
return this.templates
this.templates = await templateAPI.getTemplates(params);
return this.templates;
} catch (error) {
this.error = error.message || '获取模板列表失败'
throw error
this.error = error.message || '获取模板列表失败';
throw error;
} finally {
this.loading = false
this.loading = false;
}
},
async fetchActiveTemplates() {
this.loading = true
this.error = null
this.loading = true;
this.error = null;
try {
this.templates = await templateAPI.getActiveTemplates()
return this.templates
this.templates = await templateAPI.getActiveTemplates();
return this.templates;
} catch (error) {
this.error = error.message || '获取启用模板失败'
throw error
this.error = error.message || '获取启用模板失败';
throw error;
} finally {
this.loading = false
this.loading = false;
}
},
async fetchTemplate(id) {
this.loading = true
this.error = null
this.loading = true;
this.error = null;
try {
this.currentTemplate = await templateAPI.getTemplate(id)
return this.currentTemplate
this.currentTemplate = await templateAPI.getTemplate(id);
return this.currentTemplate;
} catch (error) {
this.error = error.message || '获取模板详情失败'
throw error
this.error = error.message || '获取模板详情失败';
throw error;
} finally {
this.loading = false
this.loading = false;
}
},
async previewTemplate(id) {
this.loading = true
this.error = null
this.loading = true;
this.error = null;
try {
const preview = await templateAPI.previewTemplate(id)
return preview
const preview = await templateAPI.previewTemplate(id);
return preview;
} catch (error) {
this.error = error.message || '预览模板失败'
throw error
this.error = error.message || '预览模板失败';
throw error;
} finally {
this.loading = false
this.loading = false;
}
},
async createTemplate(templateData) {
this.loading = true
this.error = null
this.loading = true;
this.error = null;
try {
const newTemplate = await templateAPI.createTemplate(templateData)
this.templates.unshift(newTemplate)
return newTemplate
const newTemplate = await templateAPI.createTemplate(templateData);
this.templates.unshift(newTemplate);
return newTemplate;
} catch (error) {
this.error = error.message || '创建模板失败'
throw error
this.error = error.message || '创建模板失败';
throw error;
} finally {
this.loading = false
this.loading = false;
}
},
async updateTemplate(id, templateData) {
this.loading = true
this.error = null
this.loading = true;
this.error = null;
try {
const updatedTemplate = await templateAPI.updateTemplate(id, templateData)
const index = this.templates.findIndex((t) => t.id === id)
const updatedTemplate = await templateAPI.updateTemplate(id, templateData);
const index = this.templates.findIndex(t => t.id === id);
if (index !== -1) {
this.templates[index] = updatedTemplate
this.templates[index] = updatedTemplate;
}
if (this.currentTemplate && this.currentTemplate.id === id) {
this.currentTemplate = updatedTemplate
this.currentTemplate = updatedTemplate;
}
return updatedTemplate
return updatedTemplate;
} catch (error) {
this.error = error.message || '更新模板失败'
throw error
this.error = error.message || '更新模板失败';
throw error;
} finally {
this.loading = false
this.loading = false;
}
},
async deleteTemplate(id) {
this.loading = true
this.error = null
this.loading = true;
this.error = null;
try {
await templateAPI.deleteTemplate(id)
this.templates = this.templates.filter((t) => t.id !== id)
await templateAPI.deleteTemplate(id);
this.templates = this.templates.filter(t => t.id !== id);
if (this.currentTemplate && this.currentTemplate.id === id) {
this.currentTemplate = null
this.currentTemplate = null;
}
return true
return true;
} catch (error) {
this.error = error.message || '删除模板失败'
throw error
this.error = error.message || '删除模板失败';
throw error;
} finally {
this.loading = false
this.loading = false;
}
},
async createTaskFromTemplate(templateId, threadId, fieldValues, taskName = null) {
this.loading = true
this.error = null
this.loading = true;
this.error = null;
try {
const task = await templateAPI.createTaskFromTemplate({
template_id: templateId,
thread_id: threadId,
field_values: fieldValues,
task_name: taskName,
})
return task
});
return task;
} catch (error) {
this.error = error.message || '从模板创建任务失败'
throw error
this.error = error.message || '从模板创建任务失败';
throw error;
} finally {
this.loading = false
this.loading = false;
}
},
clearCurrentTemplate() {
this.currentTemplate = null
this.currentTemplate = null;
},
clearError() {
this.error = null
this.error = null;
},
},
})
});
+36 -32
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { userAPI } from '@/api'
import { defineStore } from 'pinia';
import { userAPI } from '@/api';
export const useUserStore = defineStore('user', {
state: () => ({
@@ -11,14 +11,14 @@ export const useUserStore = defineStore('user', {
}),
getters: {
isTokenExpiring: (state) => {
if (!state.tokenStatus) return false
return state.tokenStatus.expiring_soon || false
isTokenExpiring: state => {
if (!state.tokenStatus) return false;
return state.tokenStatus.expiring_soon || false;
},
tokenExpireTime: (state) => {
if (!state.tokenStatus || !state.tokenStatus.expires_at) return null
return new Date(state.tokenStatus.expires_at * 1000)
tokenExpireTime: state => {
if (!state.tokenStatus || !state.tokenStatus.expires_at) return null;
return new Date(state.tokenStatus.expires_at * 1000);
},
},
@@ -26,35 +26,35 @@ export const useUserStore = defineStore('user', {
// 获取 Token 状态
async fetchTokenStatus() {
try {
const status = await userAPI.getTokenStatus()
this.tokenStatus = status
return status
const status = await userAPI.getTokenStatus();
this.tokenStatus = status;
return status;
} catch (error) {
throw new Error(error.message || '获取 Token 状态失败')
throw new Error(error.message || '获取 Token 状态失败');
}
},
// 获取用户列表(管理员)
async fetchUsers(params = {}) {
try {
const data = await userAPI.getUsers(params)
this.users = data.users || data
this.total = data.total || this.users.length
return data
const data = await userAPI.getUsers(params);
this.users = data.users || data;
this.total = data.total || this.users.length;
return data;
} catch (error) {
throw new Error(error.message || '获取用户列表失败')
throw new Error(error.message || '获取用户列表失败');
}
},
// 创建用户(管理员)
async createUser(userData) {
try {
const newUser = await userAPI.createUser(userData)
const newUser = await userAPI.createUser(userData);
// 刷新用户列表
await this.fetchUsers()
return newUser
await this.fetchUsers();
return newUser;
} 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) {
try {
// 过滤空密码字段
const cleanedData = { ...userData }
if (cleanedData.password === '' || cleanedData.password === null || cleanedData.password === undefined) {
delete cleanedData.password
const cleanedData = { ...userData };
if (
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()
return updatedUser
await this.fetchUsers();
return updatedUser;
} catch (error) {
throw new Error(error.message || '更新用户失败')
throw new Error(error.message || '更新用户失败');
}
},
// 删除用户
async deleteUser(userId) {
try {
await userAPI.deleteUser(userId)
await userAPI.deleteUser(userId);
// 刷新用户列表
await this.fetchUsers()
await this.fetchUsers();
} catch (error) {
throw new Error(error.message || '删除用户失败')
throw new Error(error.message || '删除用户失败');
}
},
},
})
});
+82 -26
View File
@@ -15,7 +15,12 @@
@layer base {
:root {
/* === 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;
font-weight: 400;
color-scheme: light;
@@ -130,7 +135,8 @@
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-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);
}
@@ -140,7 +146,12 @@
}
/* === Typography Styles === */
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 400;
line-height: 1.2;
color: var(--md-sys-color-on-surface);
@@ -270,14 +281,16 @@
background-color: var(--md-sys-color-surface-container-low);
color: var(--md-sys-color-on-surface);
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);
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.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);
transform: translateY(-1px);
}
@@ -307,18 +320,21 @@
@apply md3-button;
background-color: var(--md-sys-color-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);
}
.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);
transform: translateY(-1px);
}
.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);
transform: translateY(0);
}
@@ -495,13 +511,15 @@
/* === Card === */
.ant-card {
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);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.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);
transform: translateY(-1px);
}
@@ -537,12 +555,14 @@
}
.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);
}
.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);
transform: translateY(-1px);
}
@@ -672,8 +692,9 @@
/* === Dropdown === */
.ant-dropdown-menu {
border-radius: 12px;
box-shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.15),
0px 6px 10px 4px rgba(0, 0, 0, 0.10);
box-shadow:
0px 2px 3px 0px rgba(0, 0, 0, 0.15),
0px 6px 10px 4px rgba(0, 0, 0, 0.1);
}
.ant-dropdown-menu-item {
@@ -715,12 +736,22 @@
}
/* 只对包含 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;
}
/* 对包含 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;
}
@@ -736,22 +767,42 @@
}
/* 圆角 - 第一行第一个 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;
}
/* 圆角 - 第一行最后一个 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;
}
/* 圆角 - 最后一行第一个 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;
}
/* 圆角 - 最后一行最后一个 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;
}
}
@@ -944,27 +995,32 @@
}
.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);
}
.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);
}
.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);
}
.elevation-4 {
box-shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.15),
0px 6px 10px 4px rgba(0, 0, 0, 0.10);
box-shadow:
0px 2px 3px 0px rgba(0, 0, 0, 0.15),
0px 6px 10px 4px rgba(0, 0, 0, 0.1);
}
.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);
}
+62 -62
View File
@@ -5,24 +5,24 @@
* @returns {string}
*/
export function formatDateTime(date, includeTime = true) {
if (!date) return '-'
if (!date) return '-';
const d = new Date(date)
if (isNaN(d.getTime())) return '-'
const d = new Date(date);
if (isNaN(d.getTime())) return '-';
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
if (!includeTime) {
return `${year}-${month}-${day}`
return `${year}-${month}-${day}`;
}
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
const seconds = String(d.getSeconds()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
const seconds = String(d.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
@@ -31,25 +31,25 @@ export function formatDateTime(date, includeTime = true) {
* @returns {string}
*/
export function formatRelativeTime(date) {
if (!date) return '-'
if (!date) return '-';
const d = new Date(date)
if (isNaN(d.getTime())) return '-'
const d = new Date(date);
if (isNaN(d.getTime())) return '-';
const now = new Date()
const diff = now - d // 毫秒差
const now = new Date();
const diff = now - d; // 毫秒差
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return '刚刚'
if (minutes < 60) return `${minutes} 分钟前`
if (hours < 24) return `${hours} 小时前`
if (days < 7) return `${days} 天前`
if (seconds < 60) return '刚刚';
if (minutes < 60) return `${minutes} 分钟前`;
if (hours < 24) return `${hours} 小时前`;
if (days < 7) return `${days} 天前`;
return formatDateTime(date, false)
return formatDateTime(date, false);
}
/**
@@ -58,13 +58,13 @@ export function formatRelativeTime(date) {
* @returns {string}
*/
export function formatFileSize(bytes) {
if (!bytes || bytes === 0) return '0 B'
if (!bytes || bytes === 0) return '0 B';
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
}
/**
@@ -74,13 +74,13 @@ export function formatFileSize(bytes) {
* @returns {Function}
*/
export function debounce(fn, delay = 300) {
let timer = null
let timer = null;
return function (...args) {
if (timer) clearTimeout(timer)
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
fn.apply(this, args);
}, delay);
};
}
/**
@@ -90,23 +90,23 @@ export function debounce(fn, delay = 300) {
* @returns {Function}
*/
export function throttle(fn, delay = 300) {
let timer = null
let lastTime = 0
let timer = null;
let lastTime = 0;
return function (...args) {
const now = Date.now()
const now = Date.now();
if (now - lastTime < delay) {
if (timer) clearTimeout(timer)
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
lastTime = now
fn.apply(this, args)
}, delay)
lastTime = now;
fn.apply(this, args);
}, delay);
} else {
lastTime = now
fn.apply(this, args)
}
lastTime = now;
fn.apply(this, args);
}
};
}
/**
@@ -117,29 +117,29 @@ export function throttle(fn, delay = 300) {
export async function copyToClipboard(text) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
return true
await navigator.clipboard.writeText(text);
return true;
} else {
// 降级方案
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy')
textArea.remove()
return true
document.execCommand('copy');
textArea.remove();
return true;
} catch (error) {
console.error('复制失败', error)
textArea.remove()
return false
console.error('复制失败', error);
textArea.remove();
return false;
}
}
} catch (error) {
console.error('复制失败', error)
return false
console.error('复制失败', error);
return false;
}
}
+104 -98
View File
@@ -29,16 +29,17 @@
</a-descriptions-item>
<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 }}
</a-tag>
<a-tag v-else color="error">已过期</a-tag>
</a-descriptions-item>
<a-descriptions-item label="即将过期">
<a-tag v-if="!tokenStatus.is_valid" color="error">
已过期
</a-tag>
<a-tag v-if="!tokenStatus.is_valid" color="error"> 已过期 </a-tag>
<a-tag v-else :color="tokenStatus.expiring_soon ? 'warning' : 'success'">
{{ tokenStatus.expiring_soon ? '是' : '否' }}
</a-tag>
@@ -78,11 +79,7 @@
:loading="taskStore.loading"
style="width: 100%; max-width: 400px; margin-bottom: 20px"
>
<a-select-option
v-for="task in taskStore.tasks"
:key="task.id"
:value="task.id"
>
<a-select-option v-for="task in taskStore.tasks" :key="task.id" :value="task.id">
<div style="display: flex; justify-content: space-between; align-items: center">
<span>{{ task.name }}</span>
<a-tag size="small" :color="task.is_active ? 'success' : 'default'">
@@ -112,14 +109,24 @@
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag
:color="lastCheckIn.status === 'success' ? 'success' :
lastCheckIn.status === 'out_of_time' ? 'default' :
lastCheckIn.status === 'unknown' ? 'warning' : 'error'"
:color="
lastCheckIn.status === 'success'
? 'success'
: lastCheckIn.status === 'out_of_time'
? 'default'
: lastCheckIn.status === 'unknown'
? 'warning'
: 'error'
"
>
{{
lastCheckIn.status === 'success' ? '成功' :
lastCheckIn.status === 'out_of_time' ? '时间范围外' :
lastCheckIn.status === 'unknown' ? '异常' : '失败'
lastCheckIn.status === 'success'
? '成功'
: lastCheckIn.status === 'out_of_time'
? '时间范围外'
: lastCheckIn.status === 'unknown'
? '异常'
: '失败'
}}
</a-tag>
</a-descriptions-item>
@@ -166,161 +173,160 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { CalendarOutlined, KeyOutlined, UserOutlined } from '@ant-design/icons-vue'
import Layout from '@/components/Layout.vue'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { useTaskStore } from '@/stores/task'
import { useCheckInStore } from '@/stores/checkIn'
import { formatDateTime } from '@/utils/helpers'
import { usePollStatus } from '@/composables/usePollStatus'
import { ref, computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { CalendarOutlined, KeyOutlined, UserOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import { useAuthStore } from '@/stores/auth';
import { useUserStore } from '@/stores/user';
import { useTaskStore } from '@/stores/task';
import { useCheckInStore } from '@/stores/checkIn';
import { formatDateTime } from '@/utils/helpers';
import { usePollStatus } from '@/composables/usePollStatus';
const authStore = useAuthStore()
const userStore = useUserStore()
const taskStore = useTaskStore()
const checkInStore = useCheckInStore()
const authStore = useAuthStore();
const userStore = useUserStore();
const taskStore = useTaskStore();
const checkInStore = useCheckInStore();
// 使用轮询 composable
const { startPolling } = usePollStatus({
interval: 2000, // 每 2 秒轮询一次
maxRetries: 15, // 最多 15 次 (30 秒)
backoff: false // 不使用指数退避
})
backoff: false, // 不使用指数退避
});
const tokenStatusLoading = ref(false)
const checkInLoading = ref(false)
const selectedTaskId = ref(null)
const tokenStatusLoading = ref(false);
const checkInLoading = ref(false);
const selectedTaskId = ref(null);
const tokenStatus = computed(() => userStore.tokenStatus)
const tokenStatus = computed(() => userStore.tokenStatus);
const lastCheckIn = computed(() => {
if (checkInStore.myRecords.length > 0) {
return checkInStore.myRecords[0]
return checkInStore.myRecords[0];
}
return null
})
return null;
});
const formatExpireTime = computed(() => {
if (!tokenStatus.value || !tokenStatus.value.expires_at) return '-'
return formatDateTime(tokenStatus.value.expires_at * 1000)
})
if (!tokenStatus.value || !tokenStatus.value.expires_at) return '-';
return formatDateTime(tokenStatus.value.expires_at * 1000);
});
const formatRemainTime = computed(() => {
if (!tokenStatus.value || !tokenStatus.value.expires_at) return '-'
if (!tokenStatus.value || !tokenStatus.value.expires_at) return '-';
const now = Date.now()
const expireTime = tokenStatus.value.expires_at * 1000
const diff = expireTime - now
const now = Date.now();
const expireTime = tokenStatus.value.expires_at * 1000;
const diff = expireTime - now;
if (diff <= 0) return '已过期'
if (diff <= 0) return '已过期';
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (days > 0) return `${days}${hours} 小时`
if (hours > 0) return `${hours} 小时 ${minutes} 分钟`
return `${minutes} 分钟`
})
if (days > 0) return `${days}${hours} 小时`;
if (hours > 0) return `${hours} 小时 ${minutes} 分钟`;
return `${minutes} 分钟`;
});
// 获取 Token 状态
const fetchTokenStatus = async () => {
tokenStatusLoading.value = true
tokenStatusLoading.value = true;
try {
await userStore.fetchTokenStatus()
await userStore.fetchTokenStatus();
} catch (error) {
message.error(error.message || '获取 Token 状态失败')
message.error(error.message || '获取 Token 状态失败');
} finally {
tokenStatusLoading.value = false
tokenStatusLoading.value = false;
}
}
};
// 手动打卡
const handleCheckIn = async () => {
if (!selectedTaskId.value) {
message.warning('请先选择要打卡的任务')
return
message.warning('请先选择要打卡的任务');
return;
}
checkInLoading.value = true
checkInLoading.value = true;
try {
// 调用异步打卡接口,立即返回 record_id
const result = await taskStore.checkInTask(selectedTaskId.value)
const result = await taskStore.checkInTask(selectedTaskId.value);
// 获取 record_id
const recordId = result.record_id
const recordId = result.record_id;
if (!recordId) {
message.error('打卡请求失败:未获取到记录ID')
checkInLoading.value = false
return
message.error('打卡请求失败:未获取到记录ID');
checkInLoading.value = false;
return;
}
// 如果初始状态就是失败,显示错误并刷新记录
if (result.status === 'failure') {
message.error(result.message || '打卡失败')
checkInLoading.value = false
checkInStore.fetchMyRecords({ limit: 1 })
return
message.error(result.message || '打卡失败');
checkInLoading.value = false;
checkInStore.fetchMyRecords({ limit: 1 });
return;
}
// 显示提示消息
message.info('打卡任务已启动,正在后台处理...')
message.info('打卡任务已启动,正在后台处理...');
// 使用轮询 composable 检查打卡状态
startPolling(
async () => {
const status = await taskStore.getCheckInRecordStatus(recordId)
const status = await taskStore.getCheckInRecordStatus(recordId);
return {
completed: status.status !== 'pending',
success: status.status === 'success',
data: status
}
data: status,
};
},
{
onSuccess: () => {
checkInLoading.value = false
message.success('打卡成功!')
checkInStore.fetchMyRecords({ limit: 1 })
checkInLoading.value = false;
message.success('打卡成功!');
checkInStore.fetchMyRecords({ limit: 1 });
},
onFailure: (statusData) => {
checkInLoading.value = false
const errorMsg = statusData.error_message || statusData.response_text || '打卡失败'
message.error(errorMsg)
checkInStore.fetchMyRecords({ limit: 1 })
onFailure: statusData => {
checkInLoading.value = false;
const errorMsg = statusData.error_message || statusData.response_text || '打卡失败';
message.error(errorMsg);
checkInStore.fetchMyRecords({ limit: 1 });
},
onTimeout: () => {
checkInLoading.value = false
message.warning('打卡处理时间较长,请稍后查看打卡记录')
checkInLoading.value = false;
message.warning('打卡处理时间较长,请稍后查看打卡记录');
},
}
}
)
);
} catch (error) {
console.error('启动打卡失败:', error)
checkInLoading.value = false
message.error(error.message || '启动打卡任务失败')
console.error('启动打卡失败:', error);
checkInLoading.value = false;
message.error(error.message || '启动打卡任务失败');
}
}
};
onMounted(async () => {
fetchTokenStatus()
checkInStore.fetchMyRecords({ limit: 1 })
fetchTokenStatus();
checkInStore.fetchMyRecords({ limit: 1 });
// 加载任务列表
try {
await taskStore.fetchMyTasks()
await taskStore.fetchMyTasks();
// 如果只有一个任务,自动选中(优先选择启用的任务)
if (taskStore.activeTasks.length === 1) {
selectedTaskId.value = taskStore.activeTasks[0].id
selectedTaskId.value = taskStore.activeTasks[0].id;
} else if (taskStore.tasks.length === 1) {
selectedTaskId.value = taskStore.tasks[0].id
selectedTaskId.value = taskStore.tasks[0].id;
}
} catch (error) {
message.error(error.message || '加载任务列表失败')
message.error(error.message || '加载任务列表失败');
}
})
});
</script>
<style scoped>
+75 -75
View File
@@ -6,7 +6,9 @@
<template #title>
<div class="card-header">
<h2>接龙自动打卡系统</h2>
<p class="subtitle">{{ loginMode === 'qrcode' ? 'QQ 扫码登录/注册' : '用户名密码登录' }}</p>
<p class="subtitle">
{{ loginMode === 'qrcode' ? 'QQ 扫码登录/注册' : '用户名密码登录' }}
</p>
</div>
</template>
@@ -18,9 +20,9 @@
<!-- QR码登录表单 -->
<a-form
v-if="loginMode === 'qrcode'"
ref="qrcodeFormRef"
:model="qrcodeForm"
:rules="qrcodeRules"
ref="qrcodeFormRef"
layout="vertical"
@submit.prevent="handleQRCodeLogin"
>
@@ -54,9 +56,9 @@
<!-- 别名+密码登录表单 -->
<a-form
v-else
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
ref="passwordFormRef"
layout="vertical"
>
<a-form-item name="alias">
@@ -98,9 +100,7 @@
</a-form-item>
<div class="tips-link">
<a @click="loginMode = 'qrcode'" class="link-text">
没有密码使用扫码登录
</a>
<a class="link-text" @click="loginMode = 'qrcode'"> 没有密码使用扫码登录 </a>
</div>
</a-form>
@@ -142,59 +142,59 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import { UserOutlined, KeyOutlined } from '@ant-design/icons-vue'
import { authAPI } from '@/api'
import { useAuthStore } from '@/stores/auth'
import QRCodeModal from '@/components/QRCodeModal.vue'
import { ref, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import { UserOutlined, KeyOutlined } from '@ant-design/icons-vue';
import { authAPI } from '@/api';
import { useAuthStore } from '@/stores/auth';
import QRCodeModal from '@/components/QRCodeModal.vue';
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const qrcodeFormRef = ref(null)
const passwordFormRef = ref(null)
const loading = ref(false)
const qrcodeVisible = ref(false)
const qrcodeFormRef = ref(null);
const passwordFormRef = ref(null);
const loading = ref(false);
const qrcodeVisible = ref(false);
// 登录模式
const loginMode = ref('qrcode')
const loginMode = ref('qrcode');
const loginModeOptions = [
{ label: '扫码登录', value: 'qrcode' },
{ label: '密码登录', value: 'password' }
]
{ label: '密码登录', value: 'password' },
];
// 监听登录模式切换,同步用户名
watch(loginMode, () => {
// 从密码登录切换到扫码登录
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) {
passwordForm.value.alias = qrcodeForm.value.alias
passwordForm.value.alias = qrcodeForm.value.alias;
}
})
});
// QR码登录表单
const qrcodeForm = ref({
alias: '',
})
});
const qrcodeRules = {
alias: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
],
}
};
// 密码登录表单
const passwordForm = ref({
alias: '',
password: '',
})
});
const passwordRules = {
alias: [
@@ -205,34 +205,34 @@ const passwordRules = {
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6个字符', trigger: 'blur' },
],
}
};
// QR码登录
const handleQRCodeLogin = async () => {
if (!qrcodeFormRef.value) return
if (!qrcodeFormRef.value) return;
try {
await qrcodeFormRef.value.validate()
await qrcodeFormRef.value.validate();
// 显示 QR 码弹窗
qrcodeVisible.value = true
} catch (error) {
qrcodeVisible.value = true;
} catch {
// 表单验证失败,不需要打印错误(由 Ant Design 自动显示错误提示)
}
}
};
// 密码登录
const handlePasswordLogin = async () => {
if (!passwordFormRef.value) return
if (!passwordFormRef.value) return;
try {
await passwordFormRef.value.validate()
await passwordFormRef.value.validate();
loading.value = true
loading.value = true;
const response = await authAPI.aliasLogin(
passwordForm.value.alias,
passwordForm.value.password
)
);
if (response.success) {
// 使用 authStore 保存认证信息
@@ -241,18 +241,18 @@ const handlePasswordLogin = async () => {
alias: response.alias,
role: response.role || 'user',
is_approved: response.is_approved !== false,
}
};
// 如果没有 authorization(测试账号),使用 user_id 作为认证凭据
const authToken = response.authorization || `user_id:${response.user_id}`
authStore.setAuth(authToken, user)
const authToken = response.authorization || `user_id:${response.user_id}`;
authStore.setAuth(authToken, user);
// 只有当有真实 authorization 时才获取完整用户信息
if (response.authorization) {
try {
await authStore.fetchCurrentUser()
await authStore.fetchCurrentUser();
} catch (err) {
console.warn('获取完整用户信息失败,使用基本信息:', err)
console.warn('获取完整用户信息失败,使用基本信息:', err);
// 即使失败也继续登录流程
}
} else {
@@ -260,7 +260,7 @@ const handlePasswordLogin = async () => {
message.info({
content: '您正在使用密码登录模式。如需使用打卡功能,请先扫码绑定 QQ。',
duration: 5,
})
});
}
// 如果有 Token 警告,显示提示
@@ -268,71 +268,71 @@ const handlePasswordLogin = async () => {
message.warning({
content: response.warning_message,
duration: 5,
})
});
} else if (response.authorization) {
// 只有有 token 的用户才显示"欢迎回来"
message.success(`欢迎回来,${response.alias}`)
message.success(`欢迎回来,${response.alias}`);
} else {
// 测试账号登录成功提示
message.success(`登录成功,${response.alias}`)
message.success(`登录成功,${response.alias}`);
}
// 跳转到重定向页面或仪表盘
const redirect = route.query.redirect || '/dashboard'
router.push(redirect)
const redirect = route.query.redirect || '/dashboard';
router.push(redirect);
} else {
// 根据不同错误类型提供友好提示
handlePasswordLoginError(response.message)
handlePasswordLoginError(response.message);
}
} catch (error) {
console.error('密码登录失败:', error)
const errorMsg = error.response?.data?.detail || error.message || '登录失败,请稍后重试'
handlePasswordLoginError(errorMsg)
console.error('密码登录失败:', error);
const errorMsg = error.response?.data?.detail || error.message || '登录失败,请稍后重试';
handlePasswordLoginError(errorMsg);
} finally {
loading.value = false
loading.value = false;
}
}
};
// 处理密码登录错误
const handlePasswordLoginError = (msg) => {
const handlePasswordLoginError = msg => {
if (!msg) {
message.error('登录失败,请稍后重试')
return
message.error('登录失败,请稍后重试');
return;
}
// 用户不存在或密码错误
if (msg.includes('用户名或密码错误')) {
message.error('用户名或密码错误')
return
message.error('用户名或密码错误');
return;
}
// 未设置密码
if (msg.includes('未设置密码')) {
message.warning('该账户未设置密码,请使用扫码登录')
return
message.warning('该账户未设置密码,请使用扫码登录');
return;
}
// 用户不存在
if (msg.includes('用户不存在')) {
message.error('用户不存在,请检查用户名或使用扫码登录注册')
return
message.error('用户不存在,请检查用户名或使用扫码登录注册');
return;
}
// 其他错误
message.error(msg || '登录失败,请稍后重试')
}
message.error(msg || '登录失败,请稍后重试');
};
const handleLoginSuccess = (user) => {
message.success(`欢迎回来,${user.alias}`)
const handleLoginSuccess = user => {
message.success(`欢迎回来,${user.alias}`);
// 跳转到重定向页面或仪表盘
const redirect = route.query.redirect || '/dashboard'
router.push(redirect)
}
const redirect = route.query.redirect || '/dashboard';
router.push(redirect);
};
const handleLoginError = (error) => {
message.error(error.message || '登录失败')
}
const handleLoginError = error => {
message.error(error.message || '登录失败');
};
</script>
<style scoped>
+4 -4
View File
@@ -9,13 +9,13 @@
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useRouter } from 'vue-router';
const router = useRouter()
const router = useRouter();
const goHome = () => {
router.push('/')
}
router.push('/');
};
</script>
<style scoped>
+73 -83
View File
@@ -44,13 +44,7 @@
</a-descriptions-item>
</a-descriptions>
<a-alert
message="⚠️ 审批说明"
type="info"
:closable="false"
show-icon
class="mb-6"
>
<a-alert message="⚠️ 审批说明" type="info" :closable="false" show-icon class="mb-6">
<template #description>
<ul class="tips-list">
<li>管理员将在 <strong>24 小时内</strong> 审核您的注册申请</li>
@@ -84,17 +78,13 @@
v-model:open="showProfileModal"
title="完善个人信息"
:confirm-loading="profileLoading"
width="500px"
@ok="handleUpdateProfile"
@cancel="resetProfileForm"
width="500px"
>
<a-form :model="profileForm" layout="vertical">
<a-form-item label="邮箱地址(可选)" name="email">
<a-input
v-model:value="profileForm.email"
placeholder="用于接收审批通知"
type="email"
/>
<a-input v-model:value="profileForm.email" placeholder="用于接收审批通知" type="email" />
<div class="form-hint">建议设置邮箱方便接收审批结果通知</div>
</a-form-item>
@@ -110,11 +100,7 @@
/>
</a-form-item>
<a-form-item
v-if="profileForm.new_password"
label="确认密码"
name="confirm_password"
>
<a-form-item v-if="profileForm.new_password" label="确认密码" name="confirm_password">
<a-input-password
v-model:value="profileForm.confirm_password"
placeholder="再次输入新密码"
@@ -139,118 +125,122 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { ReloadOutlined, LogoutOutlined, SettingOutlined } from '@ant-design/icons-vue'
import { userAPI } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { ReloadOutlined, LogoutOutlined, SettingOutlined } from '@ant-design/icons-vue';
import { userAPI } from '@/api';
import { useAuthStore } from '@/stores/auth';
const router = useRouter()
const authStore = useAuthStore()
const user = ref(null)
const showProfileModal = ref(false)
const profileLoading = ref(false)
const router = useRouter();
const authStore = useAuthStore();
const user = ref(null);
const showProfileModal = ref(false);
const profileLoading = ref(false);
const profileForm = ref({
email: '',
new_password: '',
confirm_password: '',
current_password: '',
})
});
const checkStatus = async () => {
try {
const response = await userAPI.getUserStatus()
user.value = response
const response = await userAPI.getUserStatus();
user.value = response;
if (response.is_approved) {
message.success('恭喜!您的账户已通过审批')
router.push('/dashboard')
message.success('恭喜!您的账户已通过审批');
router.push('/dashboard');
} else {
message.info('仍在等待审批中')
message.info('仍在等待审批中');
}
} catch (error) {
console.error('获取状态失败:', error)
message.error('获取状态失败:' + (error.message || '未知错误'))
console.error('获取状态失败:', error);
message.error('获取状态失败:' + (error.message || '未知错误'));
}
}
};
const loadUserInfo = async () => {
try {
const response = await userAPI.getCurrentUser()
user.value = response
const response = await userAPI.getCurrentUser();
user.value = response;
// 初始化表单
profileForm.value.email = response.email || ''
profileForm.value.email = response.email || '';
} catch (error) {
console.error('加载用户信息失败:', error)
console.error('加载用户信息失败:', error);
}
}
};
const handleUpdateProfile = async () => {
// 验证
if (profileForm.value.new_password && profileForm.value.new_password.length < 6) {
message.error('密码至少需要 6 位字符')
return
message.error('密码至少需要 6 位字符');
return;
}
if (profileForm.value.new_password !== profileForm.value.confirm_password) {
message.error('两次输入的密码不一致')
return
message.error('两次输入的密码不一致');
return;
}
if (user.value?.has_password && profileForm.value.new_password && !profileForm.value.current_password) {
message.error('修改密码时需要提供当前密码')
return
if (
user.value?.has_password &&
profileForm.value.new_password &&
!profileForm.value.current_password
) {
message.error('修改密码时需要提供当前密码');
return;
}
profileLoading.value = true
profileLoading.value = true;
try {
const updateData = {}
const updateData = {};
// 只提交有变化的字段
if (profileForm.value.email !== (user.value?.email || '')) {
updateData.email = profileForm.value.email || null
updateData.email = profileForm.value.email || null;
}
if (profileForm.value.new_password) {
updateData.new_password = profileForm.value.new_password
updateData.new_password = profileForm.value.new_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) {
message.info('没有需要更新的信息')
showProfileModal.value = false
return
message.info('没有需要更新的信息');
showProfileModal.value = false;
return;
}
await userAPI.updateProfile(updateData)
message.success('个人信息更新成功')
showProfileModal.value = false
resetProfileForm()
await userAPI.updateProfile(updateData);
message.success('个人信息更新成功');
showProfileModal.value = false;
resetProfileForm();
// 重新加载用户信息
await loadUserInfo()
await loadUserInfo();
// 如果设置了密码,更新本地存储的用户信息
if (updateData.new_password) {
const currentUser = authStore.user
const currentUser = authStore.user;
if (currentUser) {
currentUser.has_password = true
localStorage.setItem('user', JSON.stringify(currentUser))
currentUser.has_password = true;
localStorage.setItem('user', JSON.stringify(currentUser));
}
}
} catch (error) {
console.error('更新个人信息失败:', error)
message.error(error.message || '更新失败,请重试')
console.error('更新个人信息失败:', error);
message.error(error.message || '更新失败,请重试');
} finally {
profileLoading.value = false
profileLoading.value = false;
}
}
};
const resetProfileForm = () => {
profileForm.value = {
@@ -258,24 +248,24 @@ const resetProfileForm = () => {
new_password: '',
confirm_password: '',
current_password: '',
}
}
};
};
const logout = () => {
authStore.logout()
router.push('/login')
}
authStore.logout();
router.push('/login');
};
const formatDate = (dateStr) => {
if (!dateStr) return '未知'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN')
}
const formatDate = dateStr => {
if (!dateStr) return '未知';
const date = new Date(dateStr);
return date.toLocaleString('zh-CN');
};
onMounted(() => {
loadUserInfo()
checkStatus()
})
loadUserInfo();
checkStatus();
});
</script>
<style scoped>
+34 -30
View File
@@ -44,7 +44,7 @@
<!-- 桌面端表格 -->
<a-table
v-if="!isMobile"
:dataSource="checkInStore.myRecords"
:data-source="checkInStore.myRecords"
:columns="columns"
:loading="checkInStore.loading"
:pagination="false"
@@ -58,7 +58,9 @@
</template>
<template v-else-if="column.key === 'status'">
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag>
<a-tag v-else-if="record.status === 'out_of_time'" color="default">🕐 时间范围外</a-tag>
<a-tag v-else-if="record.status === 'out_of_time'" color="default"
>🕐 时间范围外</a-tag
>
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
<a-tag v-else color="error">❌ 打卡失败</a-tag>
</template>
@@ -86,7 +88,9 @@
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag>
<a-tag v-else-if="record.status === 'out_of_time'" color="default">🕐 时间范围外</a-tag>
<a-tag v-else-if="record.status === 'out_of_time'" color="default"
>🕐 时间范围外</a-tag
>
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
<a-tag v-else color="error">❌ 打卡失败</a-tag>
</a-descriptions-item>
@@ -107,14 +111,14 @@
<div class="pagination-container">
<a-pagination
v-model:current="checkInStore.currentPage"
v-model:pageSize="checkInStore.pageSize"
v-model:page-size="checkInStore.pageSize"
:total="total"
:pageSizeOptions="['10', '20', '50', '100']"
:page-size-options="['10', '20', '50', '100']"
show-size-changer
show-quick-jumper
:show-total="total => `${total} 条记录`"
@change="handlePageChange"
@showSizeChange="handleSizeChange"
@show-size-change="handleSizeChange"
/>
</div>
</a-card>
@@ -123,22 +127,22 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { UnorderedListOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import Layout from '@/components/Layout.vue'
import { useBreakpoint } from '@/composables/useBreakpoint'
import { useCheckInStore } from '@/stores/checkIn'
import { formatDateTime } from '@/utils/helpers'
import { computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { UnorderedListOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import { useBreakpoint } from '@/composables/useBreakpoint';
import { useCheckInStore } from '@/stores/checkIn';
import { formatDateTime } from '@/utils/helpers';
const checkInStore = useCheckInStore()
const { isMobile } = useBreakpoint()
const checkInStore = useCheckInStore();
const { isMobile } = useBreakpoint();
const total = computed(() => checkInStore.total)
const total = computed(() => checkInStore.total);
const successCount = computed(() => {
return checkInStore.myRecords.filter((r) => r.status === 'success').length
})
return checkInStore.myRecords.filter(r => r.status === 'success').length;
});
// 表格列配置
const columns = [
@@ -172,32 +176,32 @@ const columns = [
key: 'response_text',
ellipsis: true,
},
]
];
// 刷新数据
const handleRefresh = async () => {
try {
await checkInStore.fetchMyRecords()
message.success('刷新成功')
await checkInStore.fetchMyRecords();
message.success('刷新成功');
} catch (error) {
message.error(error.message || '刷新失败')
message.error(error.message || '刷新失败');
}
}
};
// 页码改变
const handlePageChange = () => {
checkInStore.fetchMyRecords()
}
checkInStore.fetchMyRecords();
};
// 每页数量改变
const handleSizeChange = () => {
checkInStore.currentPage = 1
checkInStore.fetchMyRecords()
}
checkInStore.currentPage = 1;
checkInStore.fetchMyRecords();
};
onMounted(() => {
checkInStore.fetchMyRecords()
})
checkInStore.fetchMyRecords();
});
</script>
<style scoped>
+68 -89
View File
@@ -37,12 +37,7 @@
修改个人信息
</h2>
<a-form
:model="profileForm"
:rules="profileRules"
ref="profileFormRef"
layout="vertical"
>
<a-form ref="profileFormRef" :model="profileForm" :rules="profileRules" layout="vertical">
<a-form-item label="邮箱" name="email">
<a-input
v-model:value="profileForm.email"
@@ -62,11 +57,7 @@
<a-form-item style="margin-top: 8px">
<a-space>
<a-button
type="primary"
:loading="profileLoading"
@click="handleUpdateProfile"
>
<a-button type="primary" :loading="profileLoading" @click="handleUpdateProfile">
保存
</a-button>
<a-button @click="resetProfileForm">重置</a-button>
@@ -92,14 +83,8 @@
:closable="false"
/>
<a-form
:model="passwordForm"
layout="vertical"
>
<a-form-item
v-if="hasPassword"
label="当前密码"
>
<a-form :model="passwordForm" layout="vertical">
<a-form-item v-if="hasPassword" label="当前密码">
<a-input-password
v-model:value="passwordForm.currentPassword"
placeholder="请输入当前密码"
@@ -125,11 +110,7 @@
<a-form-item style="margin-top: 8px">
<a-space>
<a-button
type="primary"
:loading="passwordLoading"
@click="handleUpdatePassword"
>
<a-button type="primary" :loading="passwordLoading" @click="handleUpdatePassword">
{{ hasPassword ? '修改密码' : '设置密码' }}
</a-button>
<a-button @click="resetPasswordForm">重置</a-button>
@@ -143,130 +124,128 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { UserOutlined, EditOutlined, KeyOutlined } from '@ant-design/icons-vue'
import { userAPI } from '@/api'
import Layout from '@/components/Layout.vue'
import { ref, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { UserOutlined, EditOutlined, KeyOutlined } from '@ant-design/icons-vue';
import { userAPI } from '@/api';
import Layout from '@/components/Layout.vue';
const profileFormRef = ref(null)
const profileLoading = ref(false)
const passwordLoading = ref(false)
const profileFormRef = ref(null);
const profileLoading = ref(false);
const passwordLoading = ref(false);
const user = ref(null)
const hasPassword = ref(false)
const user = ref(null);
const hasPassword = ref(false);
// 个人信息表单
const profileForm = ref({
email: '',
})
});
const profileRules = {
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
],
}
email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
};
// 密码表单
const passwordForm = ref({
currentPassword: '',
newPassword: '',
confirmPassword: '',
})
});
// 加载用户信息
const loadUserInfo = async () => {
try {
user.value = await userAPI.getCurrentUser()
profileForm.value.email = user.value.email || ''
user.value = await userAPI.getCurrentUser();
profileForm.value.email = user.value.email || '';
// 从后端返回的数据中获取密码状态
hasPassword.value = user.value.has_password || false
hasPassword.value = user.value.has_password || false;
} catch (error) {
message.error(error.message || '加载用户信息失败')
message.error(error.message || '加载用户信息失败');
}
}
};
// 更新个人信息
const handleUpdateProfile = async () => {
if (!profileFormRef.value) return
if (!profileFormRef.value) return;
try {
await profileFormRef.value.validate()
profileLoading.value = true
await profileFormRef.value.validate();
profileLoading.value = true;
await userAPI.updateProfile({
email: profileForm.value.email || null,
})
});
message.success('个人信息修改成功')
await loadUserInfo()
message.success('个人信息修改成功');
await loadUserInfo();
} catch (error) {
if (error.errorFields) return // 验证错误
const errorMsg = error.response?.data?.detail || error.message || '修改失败'
message.error(errorMsg)
if (error.errorFields) return; // 验证错误
const errorMsg = error.response?.data?.detail || error.message || '修改失败';
message.error(errorMsg);
} finally {
profileLoading.value = false
profileLoading.value = false;
}
}
};
// 重置个人信息表单
const resetProfileForm = () => {
profileForm.value.email = user.value?.email || ''
profileFormRef.value?.clearValidate()
}
profileForm.value.email = user.value?.email || '';
profileFormRef.value?.clearValidate();
};
// 更新密码
const handleUpdatePassword = async () => {
try {
// 手动验证
if (hasPassword.value && !passwordForm.value.currentPassword) {
message.error('请输入当前密码')
return
message.error('请输入当前密码');
return;
}
if (!passwordForm.value.newPassword) {
message.error('请输入新密码')
return
message.error('请输入新密码');
return;
}
if (passwordForm.value.newPassword.length < 6) {
message.error('密码至少需要6个字符')
return
message.error('密码至少需要6个字符');
return;
}
if (!passwordForm.value.confirmPassword) {
message.error('请再次输入新密码')
return
message.error('请再次输入新密码');
return;
}
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
message.error('两次输入的密码不一致')
return
message.error('两次输入的密码不一致');
return;
}
passwordLoading.value = true
passwordLoading.value = true;
const updateData = {
new_password: passwordForm.value.newPassword,
}
};
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 ? '密码修改成功' : '密码设置成功')
hasPassword.value = true
resetPasswordForm()
message.success(hasPassword.value ? '密码修改成功' : '密码设置成功');
hasPassword.value = true;
resetPasswordForm();
} catch (error) {
const errorMsg = error.response?.data?.detail || error.message || '操作失败'
message.error(errorMsg)
const errorMsg = error.response?.data?.detail || error.message || '操作失败';
message.error(errorMsg);
} finally {
passwordLoading.value = false
passwordLoading.value = false;
}
}
};
// 重置密码表单
const resetPasswordForm = () => {
@@ -274,25 +253,25 @@ const resetPasswordForm = () => {
currentPassword: '',
newPassword: '',
confirmPassword: '',
}
}
};
};
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
const date = new Date(dateString)
const formatDate = dateString => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
});
};
onMounted(() => {
loadUserInfo()
})
loadUserInfo();
});
</script>
<style scoped>
+125 -131
View File
@@ -4,11 +4,7 @@
<div class="max-w-7xl mx-auto">
<!-- Header -->
<div class="mb-8">
<a-button
@click="router.back()"
type="link"
class="mb-4 flex items-center"
>
<a-button type="link" class="mb-4 flex items-center" @click="router.back()">
<template #icon><LeftOutlined /></template>
返回任务列表
</a-button>
@@ -16,7 +12,9 @@
<a-card v-if="currentTask" class="md3-card">
<div class="flex items-start justify-between">
<div class="flex-1">
<h1 class="text-3xl font-bold text-gradient mb-2">{{ currentTask.name || '未命名任务' }}</h1>
<h1 class="text-3xl font-bold text-gradient mb-2">
{{ currentTask.name || '未命名任务' }}
</h1>
<div class="flex items-center gap-4 text-sm text-on-surface-variant">
<span class="flex items-center">
<NumberOutlined class="mr-1" />
@@ -27,11 +25,7 @@
</a-tag>
</div>
</div>
<a-button
type="primary"
:loading="checkInLoading"
@click="handleManualCheckIn"
>
<a-button type="primary" :loading="checkInLoading" @click="handleManualCheckIn">
{{ checkInLoading ? '打卡中...' : '立即打卡' }}
</a-button>
</div>
@@ -49,31 +43,41 @@
<a-col :xs="12" :sm="8" :md="4">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.05s">
<p class="text-sm text-on-surface-variant mb-1">成功次数</p>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">{{ recordStats.success }}</p>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">
{{ recordStats.success }}
</p>
</a-card>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.1s">
<p class="text-sm text-on-surface-variant mb-1">时间范围外</p>
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">{{ recordStats.outOfTime }}</p>
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">
{{ recordStats.outOfTime }}
</p>
</a-card>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.15s">
<p class="text-sm text-on-surface-variant mb-1">失败次数</p>
<p class="text-2xl font-bold text-red-600 dark:text-red-400">{{ recordStats.failure }}</p>
<p class="text-2xl font-bold text-red-600 dark:text-red-400">
{{ recordStats.failure }}
</p>
</a-card>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.2s">
<p class="text-sm text-on-surface-variant mb-1">异常次数</p>
<p class="text-2xl font-bold text-orange-600 dark:text-orange-400">{{ recordStats.unknown }}</p>
<p class="text-2xl font-bold text-orange-600 dark:text-orange-400">
{{ recordStats.unknown }}
</p>
</a-card>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.25s">
<p class="text-sm text-on-surface-variant mb-1">成功率</p>
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400">{{ recordStats.successRate }}%</p>
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400">
{{ recordStats.successRate }}%
</p>
</a-card>
</a-col>
</a-row>
@@ -83,7 +87,12 @@
<a-space wrap :size="[16, 16]">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-on-surface">状态筛选:</span>
<a-radio-group v-model:value="filterStatus" button-style="solid" size="small" @change="handleFilterChange">
<a-radio-group
v-model:value="filterStatus"
button-style="solid"
size="small"
@change="handleFilterChange"
>
<a-radio-button value="">全部</a-radio-button>
<a-radio-button value="success">成功</a-radio-button>
<a-radio-button value="out_of_time">时间范围外</a-radio-button>
@@ -94,7 +103,12 @@
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-on-surface">触发方式:</span>
<a-radio-group v-model:value="filterTrigger" button-style="solid" size="small" @change="handleFilterChange">
<a-radio-group
v-model:value="filterTrigger"
button-style="solid"
size="small"
@change="handleFilterChange"
>
<a-radio-button value="">全部</a-radio-button>
<a-radio-button value="scheduler">自动</a-radio-button>
<a-radio-button value="manual">手动</a-radio-button>
@@ -115,7 +129,11 @@
</a-card>
</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" />
<h3 class="text-xl font-semibold text-on-surface mb-2">暂无打卡记录</h3>
<p class="text-on-surface-variant">当前筛选条件下没有找到任何打卡记录</p>
@@ -130,25 +148,13 @@
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2 flex-wrap">
<h3 class="text-lg font-semibold text-on-surface">
打卡记录 #{{ record.id }}
</h3>
<a-tag
v-if="record.status === 'success'"
color="success"
> 打卡成功</a-tag>
<a-tag
v-else-if="record.status === 'out_of_time'"
color="default"
>🕐 时间范围外</a-tag>
<a-tag
v-else-if="record.status === 'unknown'"
color="warning"
> 打卡异常</a-tag>
<a-tag
v-else
color="error"
> 打卡失败</a-tag>
<h3 class="text-lg font-semibold text-on-surface">打卡记录 #{{ record.id }}</h3>
<a-tag v-if="record.status === 'success'" color="success"> 打卡成功</a-tag>
<a-tag v-else-if="record.status === 'out_of_time'" color="default"
>🕐 时间范围外</a-tag
>
<a-tag v-else-if="record.status === 'unknown'" color="warning"> 打卡异常</a-tag>
<a-tag v-else color="error"> 打卡失败</a-tag>
<a-tag :color="record.trigger_type === 'scheduled' ? 'blue' : 'orange'">
{{ record.trigger_type === 'scheduled' ? '自动触发' : '手动触发' }}
</a-tag>
@@ -161,7 +167,9 @@
</div>
<!-- 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">
<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>
@@ -179,14 +187,14 @@
<div v-if="!loading && records.length > 0" class="mt-6 flex justify-center">
<a-pagination
v-model:current="currentPage"
v-model:pageSize="pageSize"
v-model:page-size="pageSize"
:total="total"
:pageSizeOptions="['10', '20', '50', '100']"
:page-size-options="['10', '20', '50', '100']"
show-size-changer
show-quick-jumper
:show-total="total => `${total} 条记录`"
@change="handlePageChange"
@showSizeChange="handleSizeChange"
@show-size-change="handleSizeChange"
/>
</div>
</div>
@@ -195,47 +203,47 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
LeftOutlined,
NumberOutlined,
FileTextOutlined,
ClockCircleOutlined,
ReloadOutlined,
} from '@ant-design/icons-vue'
import Layout from '@/components/Layout.vue'
import { useTaskStore } from '@/stores/task'
import { formatDateTime } from '@/utils/helpers'
} from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import { useTaskStore } from '@/stores/task';
import { formatDateTime } from '@/utils/helpers';
const route = useRoute()
const router = useRouter()
const taskStore = useTaskStore()
const route = useRoute();
const router = useRouter();
const taskStore = useTaskStore();
const taskId = computed(() => parseInt(route.params.taskId))
const currentTask = ref(null)
const records = ref([])
const loading = ref(false)
const checkInLoading = ref(false)
const taskId = computed(() => parseInt(route.params.taskId));
const currentTask = ref(null);
const records = ref([]);
const loading = ref(false);
const checkInLoading = ref(false);
// Pagination
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const currentPage = ref(1);
const pageSize = ref(20);
const total = ref(0);
// Filters
const filterStatus = ref('')
const filterTrigger = ref('')
const filterStatus = ref('');
const filterTrigger = ref('');
// Stats
const recordStats = computed(() => {
const success = records.value.filter(r => r.status === 'success').length
const outOfTime = records.value.filter(r => r.status === 'out_of_time').length
const failure = records.value.filter(r => r.status === 'failure').length
const unknown = records.value.filter(r => r.status === 'unknown').length
const totalRecords = records.value.length
const successRate = totalRecords > 0 ? Math.round((success / totalRecords) * 100) : 0
const success = records.value.filter(r => r.status === 'success').length;
const outOfTime = records.value.filter(r => r.status === 'out_of_time').length;
const failure = records.value.filter(r => r.status === 'failure').length;
const unknown = records.value.filter(r => r.status === 'unknown').length;
const totalRecords = records.value.length;
const successRate = totalRecords > 0 ? Math.round((success / totalRecords) * 100) : 0;
return {
total: totalRecords,
@@ -244,129 +252,115 @@ const recordStats = computed(() => {
failure,
unknown,
successRate,
}
})
};
});
// 从 payload_config 中提取 ThreadId
const getThreadId = (task) => {
if (!task || !task.payload_config) return '未知'
const getThreadId = task => {
if (!task || !task.payload_config) return '未知';
try {
const payload = JSON.parse(task.payload_config)
return payload.ThreadId || '未知'
const payload = JSON.parse(task.payload_config);
return payload.ThreadId || '未知';
} catch (e) {
console.error('解析 payload_config 失败:', e)
return '未知'
console.error('解析 payload_config 失败:', e);
return '未知';
}
}
};
// 获取任务详情
const fetchTaskDetail = async () => {
try {
currentTask.value = await taskStore.fetchTask(taskId.value)
currentTask.value = await taskStore.fetchTask(taskId.value);
} catch (error) {
message.error(error.message || '获取任务详情失败')
router.push('/tasks')
message.error(error.message || '获取任务详情失败');
router.push('/tasks');
}
}
};
// 获取打卡记录
const fetchRecords = async () => {
loading.value = true
loading.value = true;
try {
const params = {
skip: (currentPage.value - 1) * pageSize.value,
limit: pageSize.value,
}
};
if (filterStatus.value) {
params.status = filterStatus.value
params.status = filterStatus.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 可能返回数组或对象
if (Array.isArray(response)) {
records.value = response
total.value = response.length
records.value = response;
total.value = response.length;
} else if (response.items) {
records.value = response.items
total.value = response.total || response.items.length
records.value = response.items;
total.value = response.total || response.items.length;
} else {
records.value = []
total.value = 0
records.value = [];
total.value = 0;
}
} catch (error) {
message.error(error.message || '获取打卡记录失败')
message.error(error.message || '获取打卡记录失败');
} finally {
loading.value = false
loading.value = false;
}
}
};
// 手动打卡
const handleManualCheckIn = async () => {
checkInLoading.value = true
checkInLoading.value = true;
// 显示持久化通知
const hide = message.loading('正在打卡中,请稍候... 您可以继续浏览其他页面', 0)
const hide = message.loading('正在打卡中,请稍候... 您可以继续浏览其他页面', 0);
try {
const result = await taskStore.checkInTask(taskId.value)
hide()
const result = await taskStore.checkInTask(taskId.value);
hide();
if (result.success) {
message.success('打卡成功')
message.success('打卡成功');
// 刷新记录列表
await fetchRecords()
await fetchRecords();
} else {
message.warning(result.message || '打卡失败')
message.warning(result.message || '打卡失败');
}
} catch (error) {
hide()
message.error(error.message || '打卡失败')
hide();
message.error(error.message || '打卡失败');
} finally {
checkInLoading.value = false
checkInLoading.value = false;
}
}
};
// 筛选变化
const handleFilterChange = () => {
currentPage.value = 1
fetchRecords()
}
currentPage.value = 1;
fetchRecords();
};
// 分页变化
const handlePageChange = () => {
fetchRecords()
}
fetchRecords();
};
const handleSizeChange = () => {
currentPage.value = 1
fetchRecords()
}
// 格式化响应数据
const formatResponse = (data) => {
if (!data) return '-'
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data)
return JSON.stringify(parsed, null, 2).substring(0, 200) + (data.length > 200 ? '...' : '')
} catch {
return data.substring(0, 200) + (data.length > 200 ? '...' : '')
}
}
return JSON.stringify(data, null, 2).substring(0, 200)
}
currentPage.value = 1;
fetchRecords();
};
onMounted(async () => {
await fetchTaskDetail()
await fetchRecords()
})
await fetchTaskDetail();
await fetchRecords();
});
</script>
<style scoped>
+276 -259
View File
@@ -12,8 +12,8 @@
<a-button
type="primary"
size="large"
@click="showCreateDialog = true"
class="shadow-md3-3"
@click="showCreateDialog = true"
>
<template #icon>
<PlusOutlined />
@@ -31,7 +31,9 @@
<p class="text-sm text-on-surface-variant mb-1">总任务数</p>
<p class="text-3xl font-bold text-primary">{{ taskStore.taskStats.total }}</p>
</div>
<div class="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-md3 flex items-center justify-center">
<div
class="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-md3 flex items-center justify-center"
>
<FileTextOutlined class="text-2xl text-primary" />
</div>
</div>
@@ -43,9 +45,13 @@
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-on-surface-variant mb-1">启用中</p>
<p class="text-3xl font-bold text-green-600 dark:text-green-400">{{ taskStore.taskStats.active }}</p>
<p class="text-3xl font-bold text-green-600 dark:text-green-400">
{{ taskStore.taskStats.active }}
</p>
</div>
<div class="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-md3 flex items-center justify-center">
<div
class="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-md3 flex items-center justify-center"
>
<CheckCircleOutlined class="text-2xl text-green-600 dark:text-green-400" />
</div>
</div>
@@ -57,9 +63,13 @@
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-on-surface-variant mb-1">已禁用</p>
<p class="text-3xl font-bold text-on-surface-variant">{{ taskStore.taskStats.inactive }}</p>
<p class="text-3xl font-bold text-on-surface-variant">
{{ taskStore.taskStats.inactive }}
</p>
</div>
<div class="w-12 h-12 bg-surface-container-high rounded-md3 flex items-center justify-center">
<div
class="w-12 h-12 bg-surface-container-high rounded-md3 flex items-center justify-center"
>
<StopOutlined class="text-2xl text-on-surface-variant" />
</div>
</div>
@@ -71,7 +81,7 @@
<!-- Tasks List -->
<div v-if="loading">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12" :lg="8" v-for="i in 6" :key="i">
<a-col v-for="i in 6" :key="i" :xs="24" :sm="12" :lg="8">
<a-card>
<a-skeleton :active="true" :paragraph="{ rows: 4 }" />
</a-card>
@@ -79,21 +89,21 @@
</a-row>
</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" />
<h3 class="text-xl font-semibold text-on-surface mb-2">暂无任务</h3>
<p class="text-on-surface-variant mb-6">点击右上角的"创建任务"按钮开始添加您的第一个打卡任务</p>
<a-button type="primary" @click="showCreateDialog = true">
创建第一个任务
</a-button>
<p class="text-on-surface-variant mb-6">
点击右上角的"创建任务"按钮开始添加您的第一个打卡任务
</p>
<a-button type="primary" @click="showCreateDialog = true"> 创建第一个任务 </a-button>
</a-card>
<a-row v-else :gutter="[16, 16]">
<a-col
:xs="24" :sm="12" :lg="8"
v-for="task in taskStore.tasks"
:key="task.id"
>
<a-col v-for="task in taskStore.tasks" :key="task.id" :xs="24" :sm="12" :lg="8">
<a-card
class="md3-card hover:scale-105 transform transition-all cursor-pointer animate-slide-up"
@click="viewTask(task)"
@@ -101,8 +111,10 @@
<!-- Task Header -->
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-on-surface mb-1">{{ task.name || '未命名任务' }}</h3>
<a-divider style="margin: 8px 0;" />
<h3 class="text-lg font-semibold text-on-surface mb-1">
{{ task.name || '未命名任务' }}
</h3>
<a-divider style="margin: 8px 0" />
<p class="text-sm text-on-surface-variant">任务 ID: {{ task.id }}</p>
</div>
<a-tag :color="task.is_active ? 'success' : 'default'">
@@ -118,21 +130,32 @@
</div>
<div class="flex items-center text-sm text-on-surface-variant">
<ClockCircleOutlined class="mr-2" />
最后打卡: {{ task.last_check_in_time ? formatDateTime(task.last_check_in_time) : '未打卡' }}
最后打卡:
{{ task.last_check_in_time ? formatDateTime(task.last_check_in_time) : '未打卡' }}
</div>
<div class="flex items-center text-sm">
<CheckCircleOutlined class="mr-2 text-on-surface-variant" />
<span v-if="task.last_check_in_status" :class="{
'text-green-600 dark:text-green-400 font-medium': task.last_check_in_status === 'success',
'text-blue-600 dark:text-blue-400 font-medium': task.last_check_in_status === 'out_of_time',
'text-red-600 dark:text-red-400 font-medium': task.last_check_in_status === 'failure',
'text-yellow-600 dark:text-yellow-400 font-medium': task.last_check_in_status === 'unknown'
}">
<span
v-if="task.last_check_in_status"
:class="{
'text-green-600 dark:text-green-400 font-medium':
task.last_check_in_status === 'success',
'text-blue-600 dark:text-blue-400 font-medium':
task.last_check_in_status === 'out_of_time',
'text-red-600 dark:text-red-400 font-medium':
task.last_check_in_status === 'failure',
'text-yellow-600 dark:text-yellow-400 font-medium':
task.last_check_in_status === 'unknown',
}"
>
{{
task.last_check_in_status === 'success' ? '✅ 打卡成功' :
task.last_check_in_status === 'out_of_time' ? '🕐 时间范围外' :
task.last_check_in_status === 'failure' ? '❌ 打卡失败' :
'❗ 打卡异常'
task.last_check_in_status === 'success'
? '✅ 打卡成功'
: task.last_check_in_status === 'out_of_time'
? '🕐 时间范围外'
: task.last_check_in_status === 'failure'
? '❌ 打卡失败'
: '❗ 打卡异常'
}}
</span>
<span v-else class="text-on-surface-variant">暂无打卡记录</span>
@@ -145,33 +168,24 @@
type="primary"
size="small"
:loading="checkInLoading[task.id]"
@click.stop="handleCheckIn(task.id)"
class="flex-1"
@click.stop="handleCheckIn(task.id)"
>
{{ checkInLoading[task.id] ? '打卡中...' : '立即打卡' }}
</a-button>
<a-button
size="small"
@click.stop="toggleTaskStatus(task)"
class="flex-1"
>
<a-button size="small" class="flex-1" @click.stop="toggleTaskStatus(task)">
{{ task.is_active ? '禁用' : '启用' }}
</a-button>
<a-button
type="primary"
size="small"
ghost
@click.stop="editTask(task)"
class="icon-button"
@click.stop="editTask(task)"
>
<template #icon><EditOutlined /></template>
</a-button>
<a-button
danger
size="small"
@click.stop="deleteTask(task)"
class="icon-button"
>
<a-button danger size="small" class="icon-button" @click.stop="deleteTask(task)">
<template #icon><DeleteOutlined /></template>
</a-button>
</div>
@@ -187,7 +201,7 @@
:title="editingTask ? '编辑任务' : '从模板创建任务'"
:width="isMobile ? '100%' : 700"
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
:maskClosable="false"
:mask-closable="false"
>
<!-- 只显示从模板创建 -->
<div v-if="!editingTask">
@@ -203,32 +217,46 @@
<div v-else>
<!-- 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
v-for="template in activeTemplates"
: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"
@click="selectTemplate(template)"
>
<h4 class="font-semibold text-on-surface mb-1">{{ template.name }}</h4>
<p class="text-sm text-on-surface-variant">{{ template.description || '无描述' }}</p>
<p class="text-sm text-on-surface-variant">
{{ template.description || '无描述' }}
</p>
</div>
</div>
</a-form-item>
<!-- 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="flex items-center">
<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>
<a-button size="small" type="link" @click="selectedTemplate = null">更换模板</a-button>
<a-button size="small" type="link" @click="selectedTemplate = null"
>更换模板</a-button
>
</div>
<a-form-item label="任务名称" name="task_name">
<a-input v-model:value="templateTaskForm.task_name" placeholder="可选,留空则自动生成" />
<a-input
v-model:value="templateTaskForm.task_name"
placeholder="可选,留空则自动生成"
/>
</a-form-item>
<a-form-item label="接龙 ID" name="thread_id" required>
@@ -239,10 +267,7 @@
<!-- Dynamic Fields -->
<div v-for="(fieldConfig, key) in visibleFields" :key="key">
<a-form-item
:label="fieldConfig.display_name"
:required="fieldConfig.required"
>
<a-form-item :label="fieldConfig.display_name" :required="fieldConfig.required">
<!-- Text Input -->
<a-input
v-if="fieldConfig.field_type === 'text'"
@@ -292,7 +317,13 @@
</div>
<!-- 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-input v-model:value="taskForm.name" placeholder="请输入任务名称(例如:公司打卡)" />
</a-form-item>
@@ -314,12 +345,7 @@
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-on-surface-variant">完整的打卡请求配置</span>
<a-button
size="small"
type="primary"
ghost
@click="copyPayload"
>
<a-button size="small" type="primary" ghost @click="copyPayload">
<template #icon><CopyOutlined /></template>
复制
</a-button>
@@ -329,7 +355,7 @@
:rows="12"
readonly
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">
💡 此配置由模板自动生成如需修改请删除任务后从模板重新创建
@@ -341,7 +367,7 @@
<div class="flex gap-3 justify-end">
<a-button @click="showCreateDialog = false">取消</a-button>
<a-button type="primary" :loading="submitting" @click="handleSubmit">
{{ submitting ? '提交中...' : (editingTask ? '保存修改' : '创建任务') }}
{{ submitting ? '提交中...' : editingTask ? '保存修改' : '创建任务' }}
</a-button>
</div>
</template>
@@ -350,9 +376,9 @@
</template>
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { ref, reactive, onMounted, computed, watch } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { useRouter } from 'vue-router';
import {
PlusOutlined,
FileTextOutlined,
@@ -363,41 +389,41 @@ import {
EditOutlined,
DeleteOutlined,
CopyOutlined,
} from '@ant-design/icons-vue'
import Layout from '@/components/Layout.vue'
import CrontabEditor from '@/components/CrontabEditor.vue'
import { useBreakpoint } from '@/composables/useBreakpoint'
import { useTaskStore } from '@/stores/task'
import { useTemplateStore } from '@/stores/template'
import { copyToClipboard, formatDateTime } from '@/utils/helpers'
import { usePollStatus } from '@/composables/usePollStatus'
} from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import CrontabEditor from '@/components/CrontabEditor.vue';
import { useBreakpoint } from '@/composables/useBreakpoint';
import { useTaskStore } from '@/stores/task';
import { useTemplateStore } from '@/stores/template';
import { copyToClipboard, formatDateTime } from '@/utils/helpers';
import { usePollStatus } from '@/composables/usePollStatus';
const router = useRouter()
const taskStore = useTaskStore()
const templateStore = useTemplateStore()
const { isMobile } = useBreakpoint()
const router = useRouter();
const taskStore = useTaskStore();
const templateStore = useTemplateStore();
const { isMobile } = useBreakpoint();
// 使用轮询 composable
const { startPolling } = usePollStatus({
interval: 2000,
maxRetries: 15,
backoff: false
})
backoff: false,
});
const loading = ref(false)
const showCreateDialog = ref(false)
const submitting = ref(false)
const editingTask = ref(null)
const taskFormRef = ref(null)
const templateFormRef = ref(null)
const checkInLoading = ref({})
const loading = ref(false);
const showCreateDialog = ref(false);
const submitting = ref(false);
const editingTask = ref(null);
const taskFormRef = ref(null);
const templateFormRef = ref(null);
const checkInLoading = ref({});
// Template mode
const createMode = ref('template') // 'template' or 'manual'
const loadingTemplates = ref(false)
const activeTemplates = ref([])
const selectedTemplate = ref(null)
const templatePreview = ref(null) // 存储从 preview 接口获取的合并后配置
const createMode = ref('template'); // 'template' or 'manual'
const loadingTemplates = ref(false);
const activeTemplates = ref([]);
const selectedTemplate = ref(null);
const templatePreview = ref(null); // 存储从 preview 接口获取的合并后配置
// Manual create form
const taskForm = reactive({
@@ -406,214 +432,206 @@ const taskForm = reactive({
is_active: true,
payload_config: '',
cron_expression: '0 20 * * *', // 新增:Crontab 表达式,默认每天 20:00
})
});
// Template create form
const templateTaskForm = reactive({
task_name: '',
thread_id: '',
field_values: {}
})
field_values: {},
});
const taskRules = {
name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
thread_id: [{ required: true, message: '请输入接龙 ID', trigger: 'blur' }],
}
};
// Compute visible fields from selected template (using merged config)
const visibleFields = computed(() => {
if (!templatePreview.value) return {}
if (!templatePreview.value) return {};
// 使用合并后的完整字段配置(包含从父模板继承的字段)
const fieldConfig = templatePreview.value.field_config
const visible = {}
const fieldConfig = templatePreview.value.field_config;
const visible = {};
// 递归函数:提取所有可见的普通字段
const extractVisibleFields = (config, parentPath = '') => {
for (const [key, value] of Object.entries(config)) {
const currentPath = parentPath ? `${parentPath}.${key}` : key
const currentPath = parentPath ? `${parentPath}.${key}` : key;
// 判断是否为字段配置对象(包含 display_name
if (value && typeof value === 'object' && 'display_name' in value) {
// 这是一个普通字段配置
if (!value.hidden) {
visible[currentPath] = value
visible[currentPath] = value;
}
}
// 判断是否为数组字段
else if (Array.isArray(value)) {
// 数组字段:遍历每个元素
if (value.length > 0) {
const firstElement = value[0]
const firstElement = value[0];
// 如果数组元素是字段配置对象,直接提取
if (firstElement && typeof firstElement === 'object' && 'display_name' in firstElement) {
if (!firstElement.hidden) {
visible[`${currentPath}[0]`] = firstElement
visible[`${currentPath}[0]`] = firstElement;
}
}
// 如果数组元素是对象(但不是字段配置),递归处理
else if (firstElement && typeof firstElement === 'object') {
extractVisibleFields(firstElement, `${currentPath}[0]`)
extractVisibleFields(firstElement, `${currentPath}[0]`);
}
}
}
// 判断是否为对象字段(不包含 display_name 的对象)
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
const formattedPayload = computed(() => {
if (!taskForm.payload_config) return '{}'
if (!taskForm.payload_config) return '{}';
try {
const payload = JSON.parse(taskForm.payload_config)
return JSON.stringify(payload, null, 2)
} catch (e) {
return taskForm.payload_config
const payload = JSON.parse(taskForm.payload_config);
return JSON.stringify(payload, null, 2);
} catch {
return taskForm.payload_config;
}
})
});
// Copy payload to clipboard
const copyPayload = async () => {
const success = await copyToClipboard(formattedPayload.value)
const success = await copyToClipboard(formattedPayload.value);
if (success) {
message.success('Payload 已复制到剪贴板')
message.success('Payload 已复制到剪贴板');
} else {
message.error('复制失败')
message.error('复制失败');
}
}
};
// Initialize field values with defaults when template is selected
watch(selectedTemplate, async (newTemplate) => {
watch(selectedTemplate, async newTemplate => {
if (!newTemplate) {
templatePreview.value = null
return
templatePreview.value = null;
return;
}
// 获取模板的合并后配置(包含父模板的字段)
try {
templatePreview.value = await templateStore.previewTemplate(newTemplate.id)
} catch (error) {
message.error('获取模板配置失败')
templatePreview.value = null
return
templatePreview.value = await templateStore.previewTemplate(newTemplate.id);
} catch {
message.error('获取模板配置失败');
templatePreview.value = null;
return;
}
const fieldConfig = templatePreview.value.field_config
const fieldValues = {}
const fieldConfig = templatePreview.value.field_config;
const fieldValues = {};
// 递归函数:提取所有字段的默认值
const extractDefaultValues = (config, parentPath = '') => {
for (const [key, value] of Object.entries(config)) {
const currentPath = parentPath ? `${parentPath}.${key}` : key
const currentPath = parentPath ? `${parentPath}.${key}` : key;
// 判断是否为字段配置对象(包含 display_name
if (value && typeof value === 'object' && 'display_name' in value) {
fieldValues[currentPath] = value.default_value || ''
fieldValues[currentPath] = value.default_value || '';
}
// 判断是否为数组字段
else if (Array.isArray(value)) {
// 数组字段:处理第一个元素的默认值
if (value.length > 0) {
const firstElement = value[0]
const firstElement = value[0];
// 如果数组元素是字段配置对象,直接提取默认值
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') {
extractDefaultValues(firstElement, `${currentPath}[0]`)
extractDefaultValues(firstElement, `${currentPath}[0]`);
}
}
}
// 判断是否为对象字段(不包含 display_name 的对象)
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
const loadTemplates = async () => {
loadingTemplates.value = true
loadingTemplates.value = true;
try {
activeTemplates.value = await templateStore.fetchActiveTemplates()
activeTemplates.value = await templateStore.fetchActiveTemplates();
} catch (error) {
message.error(error.message || '加载模板失败')
message.error(error.message || '加载模板失败');
} finally {
loadingTemplates.value = false
loadingTemplates.value = false;
}
}
};
// Select template
const selectTemplate = (template) => {
selectedTemplate.value = template
}
// Handle mode change
const handleModeChange = (mode) => {
selectedTemplate.value = null
templateTaskForm.task_name = ''
templateTaskForm.thread_id = ''
templateTaskForm.field_values = {}
}
const selectTemplate = template => {
selectedTemplate.value = template;
};
// 从 payload_config 中提取 ThreadId
const getThreadId = (task) => {
if (!task.payload_config) return '未知'
const getThreadId = task => {
if (!task.payload_config) return '未知';
try {
const payload = JSON.parse(task.payload_config)
return payload.ThreadId || '未知'
const payload = JSON.parse(task.payload_config);
return payload.ThreadId || '未知';
} catch (e) {
console.error('解析 payload_config 失败:', e)
return '未知'
console.error('解析 payload_config 失败:', e);
return '未知';
}
}
};
// 加载任务列表
const fetchTasks = async () => {
loading.value = true
loading.value = true;
try {
await taskStore.fetchMyTasks()
await taskStore.fetchMyTasks();
} catch (error) {
message.error(error.message || '加载任务列表失败')
message.error(error.message || '加载任务列表失败');
} finally {
loading.value = false
loading.value = false;
}
}
};
// 查看任务详情
const viewTask = (task) => {
router.push(`/tasks/${task.id}/records`)
}
const viewTask = task => {
router.push(`/tasks/${task.id}/records`);
};
// 编辑任务
const editTask = (task) => {
editingTask.value = task
const editTask = task => {
editingTask.value = task;
// 从 payload_config 中提取 thread_id
let threadId = ''
let threadId = '';
try {
const payload = JSON.parse(task.payload_config || '{}')
threadId = payload.ThreadId || ''
const payload = JSON.parse(task.payload_config || '{}');
threadId = payload.ThreadId || '';
} catch (e) {
console.error('解析 payload_config 失败:', e)
console.error('解析 payload_config 失败:', e);
}
Object.assign(taskForm, {
@@ -622,12 +640,12 @@ const editTask = (task) => {
is_active: task.is_active,
payload_config: task.payload_config || '{}',
cron_expression: task.cron_expression || '0 20 * * *',
})
showCreateDialog.value = true
}
});
showCreateDialog.value = true;
};
// 删除任务
const deleteTask = (task) => {
const deleteTask = task => {
Modal.confirm({
title: '删除确认',
content: `确定要删除任务"${task.name || task.id}"吗?此操作不可恢复。`,
@@ -636,112 +654,111 @@ const deleteTask = (task) => {
okType: 'danger',
onOk: async () => {
try {
await taskStore.deleteTask(task.id)
message.success('任务删除成功')
await fetchTasks()
await taskStore.deleteTask(task.id);
message.success('任务删除成功');
await fetchTasks();
} catch (error) {
message.error(error.message || '删除任务失败')
message.error(error.message || '删除任务失败');
}
},
})
}
});
};
// 切换任务状态
const toggleTaskStatus = async (task) => {
const toggleTaskStatus = async task => {
try {
await taskStore.toggleTask(task.id)
message.success(task.is_active ? '任务已禁用' : '任务已启用')
await taskStore.toggleTask(task.id);
message.success(task.is_active ? '任务已禁用' : '任务已启用');
} catch (error) {
message.error(error.message || '切换任务状态失败')
message.error(error.message || '切换任务状态失败');
}
}
};
// 手动打卡 (异步轮询方式)
const handleCheckIn = async (taskId) => {
checkInLoading.value[taskId] = true
const handleCheckIn = async taskId => {
checkInLoading.value[taskId] = true;
try {
// 调用异步打卡接口,立即返回 record_id
const result = await taskStore.checkInTask(taskId)
const result = await taskStore.checkInTask(taskId);
// 获取 record_id
const recordId = result.record_id
const recordId = result.record_id;
if (!recordId) {
message.error('打卡请求失败:未获取到记录ID')
checkInLoading.value[taskId] = false
return
message.error('打卡请求失败:未获取到记录ID');
checkInLoading.value[taskId] = false;
return;
}
// 如果初始状态就是失败,显示错误并刷新任务列表
if (result.status === 'failure') {
message.error(result.message || '打卡失败')
checkInLoading.value[taskId] = false
await fetchTasks()
return
message.error(result.message || '打卡失败');
checkInLoading.value[taskId] = false;
await fetchTasks();
return;
}
// 显示提示消息
message.info('打卡任务已启动,正在后台处理...')
message.info('打卡任务已启动,正在后台处理...');
// 使用轮询 composable 检查打卡状态
startPolling(
async () => {
const status = await taskStore.getCheckInRecordStatus(recordId)
const status = await taskStore.getCheckInRecordStatus(recordId);
return {
completed: status.status !== 'pending',
success: status.status === 'success',
data: status
}
data: status,
};
},
{
onSuccess: async () => {
checkInLoading.value[taskId] = false
message.success('打卡成功!')
await fetchTasks()
checkInLoading.value[taskId] = false;
message.success('打卡成功!');
await fetchTasks();
},
onFailure: async (statusData) => {
checkInLoading.value[taskId] = false
const errorMsg = statusData.error_message || statusData.response_text || '打卡失败'
message.error(errorMsg)
await fetchTasks()
onFailure: async statusData => {
checkInLoading.value[taskId] = false;
const errorMsg = statusData.error_message || statusData.response_text || '打卡失败';
message.error(errorMsg);
await fetchTasks();
},
onTimeout: () => {
checkInLoading.value[taskId] = false
message.warning('打卡处理时间较长,请稍后查看打卡记录')
checkInLoading.value[taskId] = false;
message.warning('打卡处理时间较长,请稍后查看打卡记录');
},
}
}
)
);
} catch (error) {
console.error('启动打卡失败:', error)
checkInLoading.value[taskId] = false
message.error(error.message || '启动打卡任务失败')
console.error('启动打卡失败:', error);
checkInLoading.value[taskId] = false;
message.error(error.message || '启动打卡任务失败');
}
}
};
// 提交表单
const handleSubmit = async () => {
submitting.value = true
submitting.value = true;
try {
// Edit mode
if (editingTask.value) {
if (!taskFormRef.value) return
await taskFormRef.value.validate()
if (!taskFormRef.value) return;
await taskFormRef.value.validate();
await taskStore.updateTask(editingTask.value.id, taskForm)
message.success('任务更新成功')
await taskStore.updateTask(editingTask.value.id, taskForm);
message.success('任务更新成功');
}
// Create from template
else if (createMode.value === 'template') {
if (!selectedTemplate.value) {
message.warning('请选择一个模板')
return
message.warning('请选择一个模板');
return;
}
if (!templateTaskForm.thread_id) {
message.warning('请输入接龙 ID')
return
message.warning('请输入接龙 ID');
return;
}
await templateStore.createTaskFromTemplate(
@@ -749,59 +766,59 @@ const handleSubmit = async () => {
templateTaskForm.thread_id,
templateTaskForm.field_values,
templateTaskForm.task_name || null
)
);
message.success('任务创建成功')
message.success('任务创建成功');
}
// Create manually
else {
if (!taskFormRef.value) return
await taskFormRef.value.validate()
if (!taskFormRef.value) return;
await taskFormRef.value.validate();
await taskStore.createTask(taskForm)
message.success('任务创建成功')
await taskStore.createTask(taskForm);
message.success('任务创建成功');
}
showCreateDialog.value = false
resetForm()
await fetchTasks()
showCreateDialog.value = false;
resetForm();
await fetchTasks();
} catch (error) {
message.error(error.message || '操作失败')
message.error(error.message || '操作失败');
} finally {
submitting.value = false
submitting.value = false;
}
}
};
// 重置表单
const resetForm = () => {
editingTask.value = null
selectedTemplate.value = null
createMode.value = 'template'
editingTask.value = null;
selectedTemplate.value = null;
createMode.value = 'template';
Object.assign(taskForm, {
name: '',
thread_id: '',
is_active: true,
payload_config: '',
})
});
templateTaskForm.task_name = ''
templateTaskForm.thread_id = ''
templateTaskForm.field_values = {}
templateTaskForm.task_name = '';
templateTaskForm.thread_id = '';
templateTaskForm.field_values = {};
taskFormRef.value?.resetFields()
}
taskFormRef.value?.resetFields();
};
// Watch dialog open to load templates
watch(showCreateDialog, (isOpen) => {
watch(showCreateDialog, isOpen => {
if (isOpen && !editingTask.value) {
loadTemplates()
loadTemplates();
}
})
});
onMounted(() => {
fetchTasks()
})
fetchTasks();
});
</script>
<style scoped>
+23 -22
View File
@@ -48,43 +48,44 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { FileTextOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import Layout from '@/components/Layout.vue'
import { useAdminStore } from '@/stores/admin'
import { formatDateTime } from '@/utils/helpers'
import { ref, computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { FileTextOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import { useAdminStore } from '@/stores/admin';
import { formatDateTime } from '@/utils/helpers';
const adminStore = useAdminStore()
const adminStore = useAdminStore();
const logContent = ref('')
const lastUpdate = ref('')
const logContent = ref('');
const lastUpdate = ref('');
const logLines = computed(() => {
if (!logContent.value) return 0
const content = typeof logContent.value === 'string' ? logContent.value : String(logContent.value)
return content.split('\n').length
})
if (!logContent.value) return 0;
const content =
typeof logContent.value === 'string' ? logContent.value : String(logContent.value);
return content.split('\n').length;
});
const handleRefresh = async () => {
try {
const data = await adminStore.fetchLogs({ lines: 200 })
const data = await adminStore.fetchLogs({ lines: 200 });
if (data.logs) {
// 确保是字符串
logContent.value = typeof data.logs === 'string' ? data.logs : String(data.logs)
lastUpdate.value = formatDateTime(new Date())
message.success('刷新成功')
logContent.value = typeof data.logs === 'string' ? data.logs : String(data.logs);
lastUpdate.value = formatDateTime(new Date());
message.success('刷新成功');
} else {
logContent.value = '无日志内容'
logContent.value = '无日志内容';
}
} catch (error) {
message.error(error.message || '刷新失败')
message.error(error.message || '刷新失败');
}
}
};
onMounted(() => {
handleRefresh()
})
handleRefresh();
});
</script>
<style scoped>
+57 -35
View File
@@ -18,7 +18,7 @@
<!-- Desktop table -->
<a-table
v-if="!isMobile"
:dataSource="checkInStore.allRecords"
:data-source="checkInStore.allRecords"
:columns="columns"
:loading="checkInStore.loading"
:pagination="false"
@@ -32,7 +32,9 @@
</template>
<template v-else-if="column.key === 'status'">
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag>
<a-tag v-else-if="record.status === 'out_of_time'" color="default">🕐 时间范围外</a-tag>
<a-tag v-else-if="record.status === 'out_of_time'" color="default"
>🕐 时间范围外</a-tag
>
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
<a-tag v-else color="error">❌ 打卡失败</a-tag>
</template>
@@ -47,17 +49,32 @@
<!-- Mobile card view -->
<a-space v-else direction="vertical" :size="16" style="width: 100%">
<a-card v-for="record in checkInStore.allRecords" :key="record.id" size="small" :loading="checkInStore.loading">
<a-card
v-for="record in checkInStore.allRecords"
:key="record.id"
size="small"
:loading="checkInStore.loading"
>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="ID">{{ record.id }}</a-descriptions-item>
<a-descriptions-item label="用户ID">{{ record.user_id }}</a-descriptions-item>
<a-descriptions-item label="用户邮箱">{{ record.user_email || '-' }}</a-descriptions-item>
<a-descriptions-item label="任务名称">{{ record.task_name || '-' }}</a-descriptions-item>
<a-descriptions-item label="接龙ID">{{ record.thread_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="打卡时间">{{ formatDateTime(record.check_in_time) }}</a-descriptions-item>
<a-descriptions-item label="用户邮箱">{{
record.user_email || '-'
}}</a-descriptions-item>
<a-descriptions-item label="任务名称">{{
record.task_name || '-'
}}</a-descriptions-item>
<a-descriptions-item label="接龙ID">{{
record.thread_id || '-'
}}</a-descriptions-item>
<a-descriptions-item label="打卡时间">{{
formatDateTime(record.check_in_time)
}}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag>
<a-tag v-else-if="record.status === 'out_of_time'" color="default">🕐 时间范围外</a-tag>
<a-tag v-else-if="record.status === 'out_of_time'" color="default"
>🕐 时间范围外</a-tag
>
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
<a-tag v-else color="error">❌ 打卡失败</a-tag>
</a-descriptions-item>
@@ -67,26 +84,31 @@
<a-tag v-else-if="record.trigger_type === 'admin'" color="orange">管理员</a-tag>
<a-tag v-else>{{ record.trigger_type }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="消息">{{ record.response_text || '-' }}</a-descriptions-item>
<a-descriptions-item label="消息">{{
record.response_text || '-'
}}</a-descriptions-item>
</a-descriptions>
</a-card>
</a-space>
<!-- Empty state -->
<a-empty v-if="!checkInStore.loading && checkInStore.allRecords.length === 0" description="暂无打卡记录" />
<a-empty
v-if="!checkInStore.loading && checkInStore.allRecords.length === 0"
description="暂无打卡记录"
/>
<!-- Pagination -->
<div class="pagination-container" v-if="checkInStore.total > 0">
<div v-if="checkInStore.total > 0" class="pagination-container">
<a-pagination
v-model:current="checkInStore.currentPage"
v-model:pageSize="checkInStore.pageSize"
v-model:page-size="checkInStore.pageSize"
:total="checkInStore.total"
:pageSizeOptions="['10', '20', '50', '100']"
:page-size-options="['10', '20', '50', '100']"
show-size-changer
show-quick-jumper
:show-total="total => `${total} 条记录`"
@change="handlePageChange"
@showSizeChange="handleSizeChange"
@show-size-change="handleSizeChange"
/>
</div>
</a-card>
@@ -95,16 +117,16 @@
</template>
<script setup>
import { onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { UnorderedListOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import Layout from '@/components/Layout.vue'
import { useCheckInStore } from '@/stores/checkIn'
import { useBreakpoint } from '@/composables/useBreakpoint'
import { formatDateTime } from '@/utils/helpers'
import { onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { UnorderedListOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import { useCheckInStore } from '@/stores/checkIn';
import { useBreakpoint } from '@/composables/useBreakpoint';
import { formatDateTime } from '@/utils/helpers';
const checkInStore = useCheckInStore()
const { isMobile } = useBreakpoint()
const checkInStore = useCheckInStore();
const { isMobile } = useBreakpoint();
// Table columns configuration
const columns = [
@@ -117,29 +139,29 @@ const columns = [
{ title: '状态', dataIndex: 'status', key: 'status', width: 120 },
{ title: '触发方式', dataIndex: 'trigger_type', key: 'trigger_type', width: 120 },
{ title: '消息', dataIndex: 'response_text', key: 'response_text', ellipsis: true },
]
];
const handleRefresh = async () => {
try {
await checkInStore.fetchAllRecords()
message.success('刷新成功')
await checkInStore.fetchAllRecords();
message.success('刷新成功');
} catch (error) {
message.error(error.message || '刷新失败')
message.error(error.message || '刷新失败');
}
}
};
const handlePageChange = () => {
checkInStore.fetchAllRecords()
}
checkInStore.fetchAllRecords();
};
const handleSizeChange = () => {
checkInStore.currentPage = 1
checkInStore.fetchAllRecords()
}
checkInStore.currentPage = 1;
checkInStore.fetchAllRecords();
};
onMounted(() => {
checkInStore.fetchAllRecords()
})
checkInStore.fetchAllRecords();
});
</script>
<style scoped>
+38 -36
View File
@@ -22,10 +22,7 @@
<div v-else-if="adminStore.stats" class="stats-content">
<a-row :gutter="[20, 20]">
<a-col :xs="24" :sm="12" :md="6">
<a-statistic
title="总用户数"
:value="adminStore.totalUsers"
>
<a-statistic title="总用户数" :value="adminStore.totalUsers">
<template #prefix>
<UserOutlined />
</template>
@@ -43,10 +40,7 @@
</a-statistic>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-statistic
title="总打卡次数"
:value="adminStore.totalRecords"
>
<a-statistic title="总打卡次数" :value="adminStore.totalRecords">
<template #prefix>
<UnorderedListOutlined />
</template>
@@ -75,16 +69,24 @@
{{ adminStore.stats?.users?.regular || 0 }}
</a-descriptions-item>
<a-descriptions-item label="今日成功打卡">
<a-tag color="success">{{ adminStore.stats?.check_in_records?.today_success || 0 }}</a-tag>
<a-tag color="success">{{
adminStore.stats?.check_in_records?.today_success || 0
}}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="今日失败打卡">
<a-tag color="error">{{ adminStore.stats?.check_in_records?.today_failure || 0 }}</a-tag>
<a-tag color="error">{{
adminStore.stats?.check_in_records?.today_failure || 0
}}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="今日时间范围外">
<a-tag color="default">{{ adminStore.stats?.check_in_records?.today_out_of_time || 0 }}</a-tag>
<a-tag color="default">{{
adminStore.stats?.check_in_records?.today_out_of_time || 0
}}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="今日异常打卡">
<a-tag color="warning">{{ adminStore.stats?.check_in_records?.today_unknown || 0 }}</a-tag>
<a-tag color="warning">{{
adminStore.stats?.check_in_records?.today_unknown || 0
}}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="总成功率" :span="2">
<a-progress
@@ -102,8 +104,8 @@
</template>
<script setup>
import { onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { onMounted } from 'vue';
import { message } from 'ant-design-vue';
import {
BarChartOutlined,
ReloadOutlined,
@@ -111,45 +113,45 @@ import {
CheckOutlined,
UnorderedListOutlined,
CalendarOutlined,
} from '@ant-design/icons-vue'
import Layout from '@/components/Layout.vue'
import { useAdminStore } from '@/stores/admin'
} from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import { useAdminStore } from '@/stores/admin';
const adminStore = useAdminStore()
const adminStore = useAdminStore();
const getProgressColor = (percentage) => {
if (percentage >= 90) return '#52c41a'
if (percentage >= 70) return '#faad14'
return '#ff4d4f'
}
const getProgressColor = percentage => {
if (percentage >= 90) return '#52c41a';
if (percentage >= 70) return '#faad14';
return '#ff4d4f';
};
const calculateSuccessRate = () => {
const total = adminStore.stats?.check_in_records?.total || 0
const todaySuccess = adminStore.stats?.check_in_records?.today_success || 0
const total = adminStore.stats?.check_in_records?.total || 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)
// We need to get success count from backend or calculate differently
// For now, use today's success rate as approximation
const todayTotal = adminStore.stats?.check_in_records?.today || 0
if (todayTotal === 0) return 0
const todayTotal = adminStore.stats?.check_in_records?.today || 0;
if (todayTotal === 0) return 0;
return Math.round((todaySuccess / todayTotal) * 100)
}
return Math.round((todaySuccess / todayTotal) * 100);
};
const handleRefresh = async () => {
try {
await adminStore.fetchStats()
message.success('刷新成功')
await adminStore.fetchStats();
message.success('刷新成功');
} catch (error) {
message.error(error.message || '刷新失败')
message.error(error.message || '刷新失败');
}
}
};
onMounted(() => {
adminStore.fetchStats()
})
adminStore.fetchStats();
});
</script>
<style scoped>
+341 -220
View File
@@ -9,9 +9,14 @@
<h1 class="text-3xl font-bold text-gradient mb-2">任务模板管理</h1>
<p class="text-on-surface-variant">JSON 映射架构 - 配置即结构</p>
</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">
<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>
新建模板
</button>
@@ -25,13 +30,27 @@
</a-card>
</div>
<a-card v-else-if="templates.length === 0" class="md3-card text-center" style="padding: 48px 20px;">
<svg class="w-20 h-20 mx-auto text-on-surface-variant opacity-30 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
<a-card
v-else-if="templates.length === 0"
class="md3-card text-center"
style="padding: 48px 20px"
>
<svg
class="w-20 h-20 mx-auto text-on-surface-variant opacity-30 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 class="text-xl font-semibold text-on-surface mb-2">暂无模板</h3>
<p class="text-on-surface-variant mb-4">创建第一个模板让用户更轻松地创建打卡任务</p>
<button @click="showCreateDialog" class="md3-button-filled">新建模板</button>
<button class="md3-button-filled" @click="showCreateDialog">新建模板</button>
</a-card>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -43,8 +62,10 @@
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<h3 class="text-lg font-semibold text-on-surface mb-2">{{ template.name }}</h3>
<a-divider style="margin: 8px 0;" />
<p class="text-sm text-on-surface-variant mb-2">{{ template.description || '无描述' }}</p>
<a-divider style="margin: 8px 0" />
<p class="text-sm text-on-surface-variant mb-2">
{{ template.description || '无描述' }}
</p>
<span :class="template.is_active ? 'md3-badge-success' : 'md3-badge-info'">
{{ template.is_active ? '已启用' : '已禁用' }}
</span>
@@ -55,19 +76,50 @@
<!-- 第一行预览在左半部分居中编辑在右半部分居中 -->
<div class="grid grid-cols-2 gap-2">
<div class="flex justify-center">
<button @click="previewTemplate(template)" class="md3-button-outlined text-sm flex-shrink-0">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
<button
class="md3-button-outlined text-sm flex-shrink-0"
@click="previewTemplate(template)"
>
<svg
class="w-4 h-4 mr-1.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
预览
</button>
</div>
<div class="flex justify-center">
<button @click="editTemplate(template)" class="md3-button-outlined text-sm flex-shrink-0">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
<button
class="md3-button-outlined text-sm flex-shrink-0"
@click="editTemplate(template)"
>
<svg
class="w-4 h-4 mr-1.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
编辑
</button>
@@ -78,9 +130,22 @@
<div class="grid grid-cols-2 gap-2">
<div></div>
<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">
<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" />
<button
class="md3-button-outlined text-sm !text-red-600 dark:!text-red-500 !border-red-600 dark:!border-red-500 hover:!bg-red-50 dark:hover:!bg-red-900/20 flex-shrink-0"
@click="deleteTemplate(template)"
>
<svg
class="w-4 h-4 mr-1.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
删除
</button>
@@ -96,16 +161,25 @@
:title="dialogMode === 'create' ? '新建模板' : '编辑模板'"
:width="dialogWidth"
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
:maskClosable="false"
:mask-closable="false"
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-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 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 label="父模板">
@@ -145,12 +219,8 @@
<p class="text-sm mb-2">
<strong>配置即结构</strong>模板配置完全映射到生成的 Payload 结构
</p>
<p class="text-sm mb-2">
<strong>字段名保持原样</strong>不进行任何大小写转换
</p>
<p class="text-sm">
<strong>ThreadId</strong> 由用户填写无需在模板中配置
</p>
<p class="text-sm mb-2"><strong>字段名保持原样</strong>不进行任何大小写转换</p>
<p class="text-sm"><strong>ThreadId</strong> 由用户填写无需在模板中配置</p>
</template>
</a-alert>
@@ -166,20 +236,50 @@
<template #overlay>
<a-menu @click="handleAddField">
<a-menu-item key="field">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
<svg
class="w-4 h-4 inline mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
普通字段
</a-menu-item>
<a-menu-item key="array">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
<svg
class="w-4 h-4 inline mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
数组字段
</a-menu-item>
<a-menu-item key="object">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
<svg
class="w-4 h-4 inline mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
对象字段
</a-menu-item>
@@ -189,9 +289,22 @@
</div>
<!-- 递归渲染字段树 -->
<div v-if="Object.keys(formData.field_config).length === 0" class="text-center py-12 border-2 border-dashed border-outline-variant rounded-lg bg-surface-container">
<svg class="w-16 h-16 mx-auto text-on-surface-variant opacity-40 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
<div
v-if="Object.keys(formData.field_config).length === 0"
class="text-center py-12 border-2 border-dashed border-outline-variant rounded-lg bg-surface-container"
>
<svg
class="w-16 h-16 mx-auto text-on-surface-variant opacity-40 mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 class="text-lg font-semibold text-on-surface mb-2">暂无字段配置</h3>
<p class="text-sm text-on-surface-variant">点击上方"添加字段"开始配置模板</p>
@@ -204,9 +317,9 @@
:field-key="key"
:field-config="config"
:path="[key]"
@update="(event) => updateField(event.path, event.value)"
@delete="(path) => deleteField(path)"
@move="(event) => moveField(event.path, event.direction)"
@update="event => updateField(event.path, event.value)"
@delete="path => deleteField(path)"
@move="event => moveField(event.path, event.direction)"
/>
</div>
</div>
@@ -216,14 +329,16 @@
<span class="text-lg font-bold">JSON 预览</span>
</a-divider>
<div class="bg-surface-container text-green-400 p-4 rounded-lg font-mono text-sm overflow-auto max-h-96">
<div
class="bg-surface-container text-green-400 p-4 rounded-lg font-mono text-sm overflow-auto max-h-96"
>
<pre>{{ JSON.stringify(formData.field_config, null, 2) }}</pre>
</div>
</a-form>
<template #footer>
<a-button @click="dialogVisible = false">取消</a-button>
<a-button type="primary" @click="handleSubmit" :loading="submitting">
<a-button type="primary" :loading="submitting" @click="handleSubmit">
{{ dialogMode === 'create' ? '创建' : '更新' }}
</a-button>
</template>
@@ -265,12 +380,18 @@
<div v-if="previewData" class="space-y-4">
<div class="bg-surface-container rounded p-4">
<h4 class="font-semibold mb-2 text-on-surface">生成的 Payload使用默认值</h4>
<pre class="text-xs bg-surface text-on-surface p-3 rounded border border-outline-variant overflow-auto max-h-96">{{ JSON.stringify(previewData.preview_payload, null, 2) }}</pre>
<pre
class="text-xs bg-surface text-on-surface p-3 rounded border border-outline-variant overflow-auto max-h-96"
>{{ JSON.stringify(previewData.preview_payload, null, 2) }}</pre
>
</div>
<div class="bg-surface-container rounded p-4">
<h4 class="font-semibold mb-2 text-on-surface">字段配置</h4>
<pre class="text-xs bg-surface text-on-surface p-3 rounded border border-outline-variant overflow-auto max-h-96">{{ JSON.stringify(previewData.field_config, null, 2) }}</pre>
<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>
@@ -284,68 +405,68 @@
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { DownOutlined } from '@ant-design/icons-vue'
import Layout from '@/components/Layout.vue'
import FieldTreeNode from '@/components/FieldTreeNode.vue'
import { useTemplateStore } from '@/stores/template'
import { useBreakpoint } from '@/composables/useBreakpoint'
import { ref, onMounted, computed } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { DownOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import FieldTreeNode from '@/components/FieldTreeNode.vue';
import { useTemplateStore } from '@/stores/template';
import { useBreakpoint } from '@/composables/useBreakpoint';
const templateStore = useTemplateStore()
const { isMobile, isTablet } = useBreakpoint()
const templateStore = useTemplateStore();
const { isMobile, isTablet } = useBreakpoint();
// 计算对话框宽度 - 响应式设计
const dialogWidth = computed(() => {
if (isMobile.value) return '100%'
if (isTablet.value) return 900
return 1200
})
if (isMobile.value) return '100%';
if (isTablet.value) return 900;
return 1200;
});
const previewDialogWidth = computed(() => {
if (isMobile.value) return '100%'
if (isTablet.value) return 800
return 1000
})
if (isMobile.value) return '100%';
if (isTablet.value) return 800;
return 1000;
});
const templates = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const dialogMode = ref('create')
const currentTemplateId = ref(null)
const submitting = ref(false)
const templates = ref([]);
const loading = ref(false);
const dialogVisible = ref(false);
const dialogMode = ref('create');
const currentTemplateId = ref(null);
const submitting = ref(false);
const previewDialogVisible = ref(false)
const previewData = ref(null)
const previewDialogVisible = ref(false);
const previewData = ref(null);
const addFieldDialogVisible = ref(false)
const newFieldName = ref('')
const newFieldType = ref('field')
const fieldConfigVersion = ref(0) // 用于强制刷新字段列表
const addFieldDialogVisible = ref(false);
const newFieldName = ref('');
const newFieldType = ref('field');
const fieldConfigVersion = ref(0); // 用于强制刷新字段列表
const formData = ref({
name: '',
description: '',
parent_id: null,
is_active: true,
field_config: {}
})
field_config: {},
});
const availableParentTemplates = computed(() => {
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 labels = {
field: '普通字段',
array: '数组字段',
object: '对象字段'
}
return labels[newFieldType.value] || '字段'
})
object: '对象字段',
};
return labels[newFieldType.value] || '字段';
});
function createDefaultFieldConfig() {
return {
@@ -356,85 +477,85 @@ function createDefaultFieldConfig() {
hidden: false,
placeholder: '',
value_type: 'string',
options: []
}
options: [],
};
}
const fetchTemplates = async () => {
loading.value = true
loading.value = true;
try {
templates.value = await templateStore.fetchTemplates()
templates.value = await templateStore.fetchTemplates();
} catch (error) {
message.error(error.message || '获取模板列表失败')
message.error(error.message || '获取模板列表失败');
} finally {
loading.value = false
loading.value = false;
}
}
};
const showCreateDialog = () => {
dialogMode.value = 'create'
currentTemplateId.value = null
dialogMode.value = 'create';
currentTemplateId.value = null;
formData.value = {
name: '',
description: '',
parent_id: null,
is_active: true,
field_config: {}
}
dialogVisible.value = true
}
field_config: {},
};
dialogVisible.value = true;
};
const editTemplate = (template) => {
dialogMode.value = 'edit'
currentTemplateId.value = template.id
const editTemplate = template => {
dialogMode.value = 'edit';
currentTemplateId.value = template.id;
const fieldConfig = JSON.parse(template.field_config)
const fieldConfig = JSON.parse(template.field_config);
formData.value = {
name: template.name,
description: template.description || '',
parent_id: template.parent_id || null,
is_active: template.is_active,
field_config: fieldConfig
}
field_config: fieldConfig,
};
dialogVisible.value = true
}
dialogVisible.value = true;
};
const handleSubmit = async () => {
if (!formData.value.name) {
message.warning('请输入模板名称')
return
message.warning('请输入模板名称');
return;
}
submitting.value = true
submitting.value = true;
try {
const templateData = {
name: formData.value.name,
description: formData.value.description,
parent_id: formData.value.parent_id,
is_active: formData.value.is_active,
field_config: JSON.stringify(formData.value.field_config)
}
field_config: JSON.stringify(formData.value.field_config),
};
if (dialogMode.value === 'create') {
await templateStore.createTemplate(templateData)
message.success('模板创建成功')
await templateStore.createTemplate(templateData);
message.success('模板创建成功');
} else {
await templateStore.updateTemplate(currentTemplateId.value, templateData)
message.success('模板更新成功')
await templateStore.updateTemplate(currentTemplateId.value, templateData);
message.success('模板更新成功');
}
dialogVisible.value = false
await fetchTemplates()
dialogVisible.value = false;
await fetchTemplates();
} catch (error) {
message.error(error.message || '操作失败')
message.error(error.message || '操作失败');
} finally {
submitting.value = false
submitting.value = false;
}
}
};
const deleteTemplate = (template) => {
const deleteTemplate = template => {
Modal.confirm({
title: '确认删除',
content: `确定要删除模板"${template.name}"吗?此操作不可撤销。`,
@@ -443,224 +564,224 @@ const deleteTemplate = (template) => {
okType: 'danger',
onOk: async () => {
try {
await templateStore.deleteTemplate(template.id)
message.success('模板删除成功')
await fetchTemplates()
await templateStore.deleteTemplate(template.id);
message.success('模板删除成功');
await fetchTemplates();
} catch (error) {
message.error(error.message || '删除失败')
message.error(error.message || '删除失败');
}
},
})
}
});
};
const previewTemplate = async (template) => {
const previewTemplate = async template => {
try {
previewData.value = await templateStore.previewTemplate(template.id)
previewDialogVisible.value = true
previewData.value = await templateStore.previewTemplate(template.id);
previewDialogVisible.value = true;
} catch (error) {
message.error(error.message || '预览失败')
message.error(error.message || '预览失败');
}
}
};
const handleAddField = ({ key }) => {
newFieldType.value = key
newFieldName.value = ''
addFieldDialogVisible.value = true
}
newFieldType.value = key;
newFieldName.value = '';
addFieldDialogVisible.value = true;
};
const confirmAddField = () => {
if (!newFieldName.value) {
message.warning('请输入字段名')
return
message.warning('请输入字段名');
return;
}
if (formData.value.field_config[newFieldName.value]) {
message.warning('该字段已存在')
return
message.warning('该字段已存在');
return;
}
// 创建一个新对象,确保新字段被添加到末尾
const newConfig = { ...formData.value.field_config }
const newConfig = { ...formData.value.field_config };
// 创建对应类型的字段
if (newFieldType.value === 'field') {
newConfig[newFieldName.value] = createDefaultFieldConfig()
newConfig[newFieldName.value] = createDefaultFieldConfig();
} else if (newFieldType.value === 'array') {
newConfig[newFieldName.value] = []
newConfig[newFieldName.value] = [];
} else if (newFieldType.value === 'object') {
newConfig[newFieldName.value] = {}
newConfig[newFieldName.value] = {};
}
// 替换整个 field_config 以确保顺序和响应性
formData.value.field_config = newConfig
fieldConfigVersion.value++ // 强制刷新
formData.value.field_config = newConfig;
fieldConfigVersion.value++; // 强制刷新
addFieldDialogVisible.value = false
message.success('字段添加成功')
}
addFieldDialogVisible.value = false;
message.success('字段添加成功');
};
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++) {
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 副本以触发响应性
const newConfig = JSON.parse(JSON.stringify(formData.value.field_config))
let target = newConfig
const newConfig = JSON.parse(JSON.stringify(formData.value.field_config));
let target = newConfig;
// 导航到父对象/数组
for (let i = 0; i < path.length - 1; i++) {
if (!target || typeof target !== 'object') {
console.error('❌ 删除失败:路径无效', path, 'at index', i)
return
console.error('❌ 删除失败:路径无效', path, 'at index', i);
return;
}
target = target[path[i]]
target = target[path[i]];
}
if (!target || typeof target !== 'object') {
console.error('❌ 删除失败:父对象不存在', path)
return
console.error('❌ 删除失败:父对象不存在', path);
return;
}
const lastKey = path[path.length - 1]
const lastKey = path[path.length - 1];
// 如果父容器是数组,使用 splice;如果是对象,使用 delete
if (Array.isArray(target)) {
target.splice(lastKey, 1)
target.splice(lastKey, 1);
} else {
delete target[lastKey]
delete target[lastKey];
}
// 替换整个 field_config 以触发 Vue 响应性
formData.value.field_config = newConfig
fieldConfigVersion.value++ // 强制刷新
}
formData.value.field_config = newConfig;
fieldConfigVersion.value++; // 强制刷新
};
const moveField = (path, direction) => {
// 通过路径移动字段
if (!path || path.length === 0) return
if (!path || path.length === 0) return;
// 如果是根级别字段,直接重建整个 field_config
if (path.length === 1) {
const fieldKey = path[0]
const keys = Object.keys(formData.value.field_config)
const currentIndex = keys.indexOf(fieldKey)
const fieldKey = path[0];
const keys = Object.keys(formData.value.field_config);
const currentIndex = keys.indexOf(fieldKey);
if (currentIndex === -1) {
console.error('❌ 字段不存在:', fieldKey)
return
console.error('❌ 字段不存在:', fieldKey);
return;
}
let targetIndex = currentIndex
let targetIndex = currentIndex;
if (direction === 'up' && currentIndex > 0) {
targetIndex = currentIndex - 1
targetIndex = currentIndex - 1;
} else if (direction === 'down' && currentIndex < keys.length - 1) {
targetIndex = currentIndex + 1
targetIndex = currentIndex + 1;
} else {
return
return;
}
// 交换键的位置
const temp = keys[currentIndex]
keys[currentIndex] = keys[targetIndex]
keys[targetIndex] = temp
const temp = keys[currentIndex];
keys[currentIndex] = keys[targetIndex];
keys[targetIndex] = temp;
// 重建整个 field_config - 使用深拷贝确保完全新的对象
const newConfig = {}
const newConfig = {};
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.value = {
...formData.value,
field_config: newConfig
}
fieldConfigVersion.value++
return
field_config: newConfig,
};
fieldConfigVersion.value++;
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++) {
parent = parent[path[i]]
parent = parent[path[i]];
if (!parent) {
console.error('❌ 路径无效:', path)
return
console.error('❌ 路径无效:', path);
return;
}
}
const fieldKey = path[path.length - 1]
const fieldKey = path[path.length - 1];
if (Array.isArray(parent)) {
// 数组情况:直接交换元素
const index = Number(fieldKey)
const index = Number(fieldKey);
if (direction === 'up' && index > 0) {
const temp = parent[index]
parent[index] = parent[index - 1]
parent[index - 1] = temp
const temp = parent[index];
parent[index] = parent[index - 1];
parent[index - 1] = temp;
} else if (direction === 'down' && index < parent.length - 1) {
const temp = parent[index]
parent[index] = parent[index + 1]
parent[index + 1] = temp
const temp = parent[index];
parent[index] = parent[index + 1];
parent[index + 1] = temp;
} else {
return
return;
}
} else {
// 对象情况:重建对象以改变键顺序
const keys = Object.keys(parent)
const currentIndex = keys.indexOf(fieldKey)
const keys = Object.keys(parent);
const currentIndex = keys.indexOf(fieldKey);
if (currentIndex === -1) {
console.error('❌ 字段不存在:', fieldKey)
return
console.error('❌ 字段不存在:', fieldKey);
return;
}
let targetIndex = currentIndex
let targetIndex = currentIndex;
if (direction === 'up' && currentIndex > 0) {
targetIndex = currentIndex - 1
targetIndex = currentIndex - 1;
} else if (direction === 'down' && currentIndex < keys.length - 1) {
targetIndex = currentIndex + 1
targetIndex = currentIndex + 1;
} else {
return
return;
}
// 交换键数组中的位置
const temp = keys[currentIndex]
keys[currentIndex] = keys[targetIndex]
keys[targetIndex] = temp
const temp = keys[currentIndex];
keys[currentIndex] = keys[targetIndex];
keys[targetIndex] = temp;
// 重建父对象
const reorderedParent = {}
const reorderedParent = {};
keys.forEach(key => {
reorderedParent[key] = parent[key]
})
reorderedParent[key] = parent[key];
});
// 替换父容器的所有属性
Object.keys(parent).forEach(key => delete parent[key])
Object.assign(parent, reorderedParent)
Object.keys(parent).forEach(key => delete parent[key]);
Object.assign(parent, reorderedParent);
}
// 强制触发响应性更新
formData.value.field_config = newConfig
fieldConfigVersion.value++
}
formData.value.field_config = newConfig;
fieldConfigVersion.value++;
};
onMounted(() => {
fetchTemplates()
})
fetchTemplates();
});
</script>
<style scoped>
+158 -156
View File
@@ -22,13 +22,13 @@
</template>
<!-- Tab 切换 -->
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
<a-tabs v-model:active-key="activeTab" @change="handleTabChange">
<!-- 待审批用户 Tab -->
<a-tab-pane key="pending" tab="待审批用户">
<!-- 桌面端表格 -->
<a-table
v-if="!isMobile"
:dataSource="pendingUsers"
:data-source="pendingUsers"
:columns="pendingColumns"
:loading="loading"
:row-key="record => record.id"
@@ -44,9 +44,7 @@
<a-button type="primary" size="small" @click="handleApprove(record)">
通过
</a-button>
<a-button danger size="small" @click="handleReject(record)">
拒绝
</a-button>
<a-button danger size="small" @click="handleReject(record)"> 拒绝 </a-button>
</a-space>
</template>
</template>
@@ -59,10 +57,14 @@
<a-descriptions-item label="ID">{{ user.id }}</a-descriptions-item>
<a-descriptions-item label="用户名">{{ user.alias }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ user.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="注册时间">{{ formatDateTime(user.created_at) }}</a-descriptions-item>
<a-descriptions-item label="注册时间">{{
formatDateTime(user.created_at)
}}</a-descriptions-item>
</a-descriptions>
<a-space class="mt-3" style="width: 100%">
<a-button type="primary" size="small" block @click="handleApprove(user)">通过</a-button>
<a-button type="primary" size="small" block @click="handleApprove(user)"
>通过</a-button
>
<a-button danger size="small" block @click="handleReject(user)">拒绝</a-button>
</a-space>
</a-card>
@@ -75,7 +77,7 @@
<!-- 桌面端表格 -->
<a-table
v-if="!isMobile"
:dataSource="userStore.users"
:data-source="userStore.users"
:columns="allColumns"
:loading="loading"
:row-key="record => record.id"
@@ -95,7 +97,11 @@
</a-tag>
</template>
<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 v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
@@ -105,9 +111,7 @@
<a-button type="primary" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-button danger size="small" @click="handleDelete(record)">
删除
</a-button>
<a-button danger size="small" @click="handleDelete(record)"> 删除 </a-button>
</a-space>
</template>
</template>
@@ -115,7 +119,12 @@
<!-- 移动端卡片视图 -->
<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-item label="ID">{{ user.id }}</a-descriptions-item>
<a-descriptions-item label="用户名">{{ user.alias }}</a-descriptions-item>
@@ -131,32 +140,38 @@
</a-tag>
</a-descriptions-item>
<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 label="创建时间">{{ formatDateTime(user.created_at) }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{
formatDateTime(user.created_at)
}}</a-descriptions-item>
</a-descriptions>
<a-space class="mt-3" style="width: 100%">
<a-button type="primary" size="small" block @click="handleEdit(user)">编辑</a-button>
<a-button type="primary" size="small" block @click="handleEdit(user)"
>编辑</a-button
>
<a-button danger size="small" block @click="handleDelete(user)">删除</a-button>
</a-space>
</a-card>
</a-space>
<!-- 批量操作 -->
<div class="batch-actions" v-if="selectedUsers.length > 0">
<div v-if="selectedUsers.length > 0" class="batch-actions">
<a-alert
:message="`已选择 ${selectedUsers.length} 个用户`"
type="info"
:closable="false"
>
<template #description>
<a-space style="margin-top: 10px;">
<a-space style="margin-top: 10px">
<a-button type="primary" size="small" @click="handleBatchApprove">
批量审批
</a-button>
<a-button danger size="small" @click="handleBatchDelete">
批量删除
</a-button>
<a-button danger size="small" @click="handleBatchDelete"> 批量删除 </a-button>
</a-space>
</template>
</a-alert>
@@ -167,17 +182,12 @@
<!-- 创建/编辑用户对话框 -->
<a-modal
:title="dialogMode === 'create' ? '创建用户' : '编辑用户'"
v-model:open="dialogVisible"
:title="dialogMode === 'create' ? '创建用户' : '编辑用户'"
:width="isMobile ? '100%' : 600"
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
layout="vertical"
>
<a-form ref="formRef" :model="formData" :rules="formRules" layout="vertical">
<a-form-item label="用户名" name="alias">
<a-input v-model:value="formData.alias" placeholder="请输入用户名" />
</a-form-item>
@@ -203,14 +213,12 @@
v-model:value="formData.password"
:placeholder="dialogMode === 'create' ? '请输入密码' : '留空则不修改密码'"
/>
<span class="form-hint" v-if="dialogMode === 'edit'">
留空则不修改密码
</span>
<span v-if="dialogMode === 'edit'" class="form-hint"> 留空则不修改密码 </span>
</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" />
<span class="form-hint-danger" v-if="formData.reset_password">
<span v-if="formData.reset_password" class="form-hint-danger">
⚠️ 将重置为默认密码
</span>
</a-form-item>
@@ -218,9 +226,7 @@
<template #footer>
<a-button @click="dialogVisible = false">取消</a-button>
<a-button type="primary" @click="handleSubmit" :loading="submitting">
确定
</a-button>
<a-button type="primary" :loading="submitting" @click="handleSubmit"> 确定 </a-button>
</template>
</a-modal>
</div>
@@ -228,31 +234,29 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { UserOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import Layout from '@/components/Layout.vue'
import { useBreakpoint } from '@/composables/useBreakpoint'
import { useUserStore } from '@/stores/user'
import { useAdminStore } from '@/stores/admin'
import { adminAPI } from '@/api/index'
import { ref, onMounted } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { UserOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import { useBreakpoint } from '@/composables/useBreakpoint';
import { useUserStore } from '@/stores/user';
import { adminAPI } from '@/api/index';
const userStore = useUserStore()
const adminStore = useAdminStore()
const { isMobile } = useBreakpoint()
const userStore = useUserStore();
const { isMobile } = useBreakpoint();
// 状态
const loading = ref(false)
const activeTab = ref('all') // 默认展示所有用户
const pendingUsers = ref([])
const selectedUsers = ref([])
const selectedRowKeys = ref([])
const dialogVisible = ref(false)
const dialogMode = ref('create')
const submitting = ref(false)
const loading = ref(false);
const activeTab = ref('all'); // 默认展示所有用户
const pendingUsers = ref([]);
const selectedUsers = ref([]);
const selectedRowKeys = ref([]);
const dialogVisible = ref(false);
const dialogMode = ref('create');
const submitting = ref(false);
// 表单
const formRef = ref(null)
const formRef = ref(null);
const formData = ref({
alias: '',
role: 'user',
@@ -260,7 +264,7 @@ const formData = ref({
email: '',
password: '',
reset_password: false,
})
});
// 表单验证规则
const formRules = {
@@ -269,15 +273,13 @@ const formRules = {
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
],
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
],
}
email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
};
// 时间格式化
const formatDateTime = (timestamp) => {
if (!timestamp) return '-'
const date = new Date(timestamp)
const formatDateTime = timestamp => {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
@@ -285,8 +287,8 @@ const formatDateTime = (timestamp) => {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
});
};
// 待审批用户表格列
const pendingColumns = [
@@ -295,7 +297,7 @@ const pendingColumns = [
{ title: '邮箱', dataIndex: 'email', key: 'email', ellipsis: true },
{ title: '注册时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 200, fixed: 'right' },
]
];
// 所有用户表格列
const allColumns = [
@@ -307,40 +309,40 @@ const allColumns = [
{ title: 'Token 过期时间', dataIndex: 'jwt_exp', key: 'jwt_exp', width: 180 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 200, fixed: 'right' },
]
];
// 行选择配置
const rowSelection = {
selectedRowKeys: selectedRowKeys,
onChange: (keys, rows) => {
selectedRowKeys.value = keys
selectedUsers.value = rows
selectedRowKeys.value = keys;
selectedUsers.value = rows;
},
}
};
// 获取待审批用户
const fetchPendingUsers = async () => {
loading.value = true
loading.value = true;
try {
pendingUsers.value = await adminAPI.getPendingUsers()
pendingUsers.value = await adminAPI.getPendingUsers();
} catch (error) {
message.error(error.message || '获取待审批用户失败')
message.error(error.message || '获取待审批用户失败');
} finally {
loading.value = false
loading.value = false;
}
}
};
// Tab 切换
const handleTabChange = (tab) => {
const handleTabChange = tab => {
if (tab === 'pending') {
fetchPendingUsers()
fetchPendingUsers();
} else {
handleRefresh()
handleRefresh();
}
}
};
// 审批通过用户
const handleApprove = async (user) => {
const handleApprove = async user => {
Modal.confirm({
title: '审批确认',
content: `确认通过用户 "${user.alias}" 的审批吗?`,
@@ -348,18 +350,18 @@ const handleApprove = async (user) => {
cancelText: '取消',
onOk: async () => {
try {
await adminAPI.approveUser(user.id)
message.success('审批成功')
fetchPendingUsers()
await adminAPI.approveUser(user.id);
message.success('审批成功');
fetchPendingUsers();
} catch (error) {
message.error(error.message || '审批失败')
message.error(error.message || '审批失败');
}
},
})
}
});
};
// 拒绝用户
const handleReject = async (user) => {
const handleReject = async user => {
Modal.confirm({
title: '拒绝确认',
content: `确认拒绝用户 "${user.alias}" 的申请吗?拒绝后将删除该用户。`,
@@ -368,36 +370,36 @@ const handleReject = async (user) => {
okType: 'danger',
onOk: async () => {
try {
await adminAPI.rejectUser(user.id)
message.success('已拒绝并删除用户')
fetchPendingUsers()
await adminAPI.rejectUser(user.id);
message.success('已拒绝并删除用户');
fetchPendingUsers();
} catch (error) {
message.error(error.message || '操作失败')
message.error(error.message || '操作失败');
}
},
})
}
});
};
// 刷新数据
const handleRefresh = async () => {
if (activeTab.value === 'pending') {
await fetchPendingUsers()
await fetchPendingUsers();
} else {
loading.value = true
loading.value = true;
try {
await userStore.fetchUsers()
message.success('刷新成功')
await userStore.fetchUsers();
message.success('刷新成功');
} catch (error) {
message.error(error.message || '刷新失败')
message.error(error.message || '刷新失败');
} finally {
loading.value = false
loading.value = false;
}
}
}
};
// 创建用户
const handleCreate = () => {
dialogMode.value = 'create'
dialogMode.value = 'create';
formData.value = {
alias: '',
role: 'user',
@@ -405,13 +407,13 @@ const handleCreate = () => {
email: '',
password: '',
reset_password: false,
}
dialogVisible.value = true
}
};
dialogVisible.value = true;
};
// 编辑用户
const handleEdit = (user) => {
dialogMode.value = 'edit'
const handleEdit = user => {
dialogMode.value = 'edit';
formData.value = {
id: user.id,
alias: user.alias,
@@ -420,44 +422,44 @@ const handleEdit = (user) => {
email: user.email || '',
password: '',
reset_password: false,
}
dialogVisible.value = true
}
};
dialogVisible.value = true;
};
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
if (!formRef.value) return;
try {
await formRef.value.validate()
submitting.value = true
await formRef.value.validate();
submitting.value = true;
// 检查密码设置冲突
if (dialogMode.value === 'edit' && formData.value.password && formData.value.reset_password) {
message.warning('不能同时设置新密码和重置密码,请选择其一')
submitting.value = false
return
message.warning('不能同时设置新密码和重置密码,请选择其一');
submitting.value = false;
return;
}
if (dialogMode.value === 'create') {
await userStore.createUser(formData.value)
message.success('创建成功')
await userStore.createUser(formData.value);
message.success('创建成功');
} else {
await userStore.updateUser(formData.value.id, formData.value)
message.success('更新成功')
await userStore.updateUser(formData.value.id, formData.value);
message.success('更新成功');
}
dialogVisible.value = false
await handleRefresh()
dialogVisible.value = false;
await handleRefresh();
} catch (error) {
message.error(error.message || '操作失败')
message.error(error.message || '操作失败');
} finally {
submitting.value = false
submitting.value = false;
}
}
};
// 删除用户
const handleDelete = (user) => {
const handleDelete = user => {
Modal.confirm({
title: '警告',
content: `确定要删除用户 "${user.alias}" `,
@@ -466,15 +468,15 @@ const handleDelete = (user) => {
okType: 'danger',
onOk: async () => {
try {
await userStore.deleteUser(user.id)
message.success('删除成功')
await handleRefresh()
await userStore.deleteUser(user.id);
message.success('删除成功');
await handleRefresh();
} catch (error) {
message.error(error.message || '删除失败')
message.error(error.message || '删除失败');
}
},
})
}
});
};
// 批量审批
const handleBatchApprove = () => {
@@ -484,24 +486,24 @@ const handleBatchApprove = () => {
okText: '确认',
cancelText: '取消',
onOk: async () => {
const userIds = selectedUsers.value.map((u) => u.id)
let successCount = 0
let failureCount = 0
const userIds = selectedUsers.value.map(u => u.id);
let successCount = 0;
let failureCount = 0;
for (const userId of userIds) {
try {
await adminAPI.approveUser(userId)
successCount++
} catch (error) {
failureCount++
await adminAPI.approveUser(userId);
successCount++;
} catch {
failureCount++;
}
}
message.success(`批量审批完成成功 ${successCount}失败 ${failureCount}`)
await handleRefresh()
message.success(`批量审批完成成功 ${successCount}失败 ${failureCount}`);
await handleRefresh();
},
})
}
});
};
// 批量删除
const handleBatchDelete = () => {
@@ -512,29 +514,29 @@ const handleBatchDelete = () => {
cancelText: '取消',
okType: 'danger',
onOk: async () => {
const userIds = selectedUsers.value.map((u) => u.id)
let successCount = 0
let failureCount = 0
const userIds = selectedUsers.value.map(u => u.id);
let successCount = 0;
let failureCount = 0;
for (const userId of userIds) {
try {
await userStore.deleteUser(userId)
successCount++
} catch (error) {
failureCount++
await userStore.deleteUser(userId);
successCount++;
} catch {
failureCount++;
}
}
message.success(`批量删除完成成功 ${successCount}失败 ${failureCount}`)
await handleRefresh()
message.success(`批量删除完成成功 ${successCount}失败 ${failureCount}`);
await handleRefresh();
},
})
}
});
};
onMounted(() => {
// 默认加载所有用户
handleRefresh()
})
handleRefresh();
});
</script>
<style scoped>
+3 -6
View File
@@ -1,10 +1,7 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class', // 启用 class 模式的暗色模式
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
@@ -62,7 +59,7 @@ export default {
// Material Design 3 Shape System
'md3-xs': '4px', // Extra Small - chips, small tags
'md3-sm': '8px', // Small - text fields, small components
'md3': '12px', // Medium - cards, buttons (default)
md3: '12px', // Medium - cards, buttons (default)
'md3-lg': '16px', // Large - large cards, dialogs
'md3-xl': '28px', // Extra Large - fully rounded buttons
'md3-full': '9999px', // Full - pill shape
@@ -98,4 +95,4 @@ export default {
},
},
plugins: [],
}
};
+6 -6
View File
@@ -1,6 +1,6 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'path';
// https://vite.dev/config/
export default defineConfig({
@@ -33,13 +33,13 @@ export default defineConfig({
if (id.includes('node_modules')) {
// Ant Design Vue
if (id.includes('ant-design-vue')) {
return 'ant-design-vue'
return 'ant-design-vue';
}
// Group all other vendor code together
return 'vendor'
return 'vendor';
}
},
},
},
},
})
});