refactor(structure): reorganize app layout

BREAKING CHANGE: root backend/frontend directories and old run/manage entrypoints were removed. Use apps/backend, apps/frontend, and python main.py commands instead.
This commit is contained in:
2026-05-03 16:43:11 +08:00
parent 7e8852877e
commit d4d6f87730
112 changed files with 347 additions and 1596 deletions
+2
View File
@@ -0,0 +1,2 @@
# API Base URL (Development)
VITE_API_BASE_URL=http://localhost:8000
+3
View File
@@ -0,0 +1,3 @@
# API Base URL (Production)
# 留空,让 API 请求使用相对路径(由 Nginx 转发)
VITE_API_BASE_URL=
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
*.local
+9
View File
@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"arrowParens": "avoid",
"endOfLine": "auto"
}
+38
View File
@@ -0,0 +1,38 @@
import js from '@eslint/js';
import pluginVue from 'eslint-plugin-vue';
import prettierConfig from '@vue/eslint-config-prettier';
export default [
{
ignores: ['node_modules', 'dist', '*.local'],
},
js.configs.recommended,
...pluginVue.configs['flat/recommended'],
prettierConfig,
{
languageOptions: {
globals: {
// 浏览器环境
window: 'readonly',
document: 'readonly',
localStorage: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
navigator: 'readonly',
// Node.js 环境(用于配置文件)
process: 'readonly',
__dirname: 'readonly',
},
},
rules: {
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'warn',
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-unused-vars': 'warn',
},
},
];
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>接龙自动打卡</title>
<meta name="description" content="接龙自动打卡系统 - 轻松管理您的打卡任务" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+4314
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix",
"format": "prettier --write ."
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.2.6",
"axios": "^1.13.4",
"pinia": "^3.0.4",
"vue": "^3.5.27",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.7.0",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"tailwindcss": "^3.4.19",
"vite": "^7.3.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- 圆形绿色渐变背景 -->
<defs>
<linearGradient id="greenGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#66bb6a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#4caf50;stop-opacity:1" />
</linearGradient>
</defs>
<!-- 圆形背景 -->
<circle cx="50" cy="50" r="48" fill="url(#greenGradient)"/>
<!-- 白色打钩图标 -->
<path d="M 30 50 L 42 62 L 70 34"
stroke="white"
stroke-width="8"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 650 B

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

After

Width:  |  Height:  |  Size: 1.5 KiB

+85
View File
@@ -0,0 +1,85 @@
<template>
<a-config-provider :theme="antdTheme" :locale="zhCN">
<router-view />
</a-config-provider>
</template>
<script setup>
import { onMounted, computed } from 'vue';
import { ConfigProvider as AConfigProvider } from 'ant-design-vue';
import zhCN from 'ant-design-vue/es/locale/zh_CN';
import { useAuthStore } from '@/stores/auth';
import getAntdTheme from './antd-theme';
import { useTheme, initTheme, watchSystemTheme } from '@/composables/useTheme';
const authStore = useAuthStore();
// 初始化主题(全局)
initTheme();
watchSystemTheme();
// 使用主题
const { isDark } = useTheme();
// 动态生成 Ant Design 主题
const antdTheme = computed(() => getAntdTheme(isDark.value));
// 应用启动时验证 Token
onMounted(async () => {
if (authStore.isAuthenticated) {
try {
await authStore.fetchCurrentUser();
} catch (error) {
console.error('验证用户信息失败:', error);
// Token 可能已过期,清除认证状态
authStore.clearAuth();
}
}
});
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow-x: hidden;
}
#app {
width: 100%;
height: 100%;
min-height: 100vh;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 修复按钮图标与文本的垂直对齐 */
.ant-btn {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
}
.ant-btn .anticon {
display: inline-flex !important;
align-items: center !important;
line-height: 1 !important;
}
.ant-btn > span {
display: inline-flex !important;
align-items: center !important;
}
</style>
+248
View File
@@ -0,0 +1,248 @@
import { theme } from 'ant-design-vue';
/**
* Ant Design Vue 主题配置
* 严格遵循 Material Design 3 规范
* @param {boolean} isDark - 是否为暗色模式
*/
export default function getAntdTheme(isDark = false) {
return {
token: {
// === Material Design 3 Color System ===
// Primary - 主色调(绿色)
colorPrimary: isDark ? '#81c784' : '#4caf50',
// Secondary colors
colorSuccess: isDark ? '#81c784' : '#4caf50',
colorWarning: '#ff9800',
colorError: '#f44336', // MD3 标准错误色
colorInfo: isDark ? '#64b5f6' : '#2196f3',
// === Surface & Background (MD3 规范) ===
colorBgBase: isDark ? '#1c1b1f' : '#ffffff',
colorBgContainer: isDark ? '#1c1b1f' : '#ffffff',
colorBgElevated: isDark ? '#26252a' : '#ffffff',
colorBgLayout: isDark ? '#1c1b1f' : '#fefbff', // MD3 标准背景色
colorBgSpotlight: isDark ? '#26252a' : '#ffffff',
// === Typography (MD3 规范) ===
colorText: isDark ? '#e6e1e5' : '#1c1b1f', // On-surface
colorTextSecondary: isDark ? '#cac4d0' : '#49454f', // On-surface-variant
colorTextTertiary: isDark ? '#938f99' : '#79747e',
colorTextQuaternary: isDark ? '#79747e' : '#938f99',
// === Borders ===
colorBorder: isDark ? '#49454f' : '#d1cdd6',
colorBorderSecondary: isDark ? '#3a3740' : '#e3e1e6',
colorSplit: isDark ? '#49454f' : '#e3e1e6',
// === Shape System ===
borderRadius: 12, // Medium shape
borderRadiusLG: 16, // Large shape
borderRadiusSM: 8, // Small shape
borderRadiusXS: 4, // Extra small shape
// === Typography ===
fontFamily: "'Roboto', 'Inter', system-ui, -apple-system, sans-serif",
fontSize: 14, // Body Medium
fontSizeLG: 16, // Body Large
fontSizeSM: 12, // Body Small
lineHeight: 1.428, // 20/14 = 1.428
lineHeightLG: 1.5, // 24/16 = 1.5
// === Links ===
colorLink: isDark ? '#64b5f6' : '#2196f3',
colorLinkHover: isDark ? '#90caf9' : '#1976d2',
colorLinkActive: isDark ? '#42a5f5' : '#1565c0',
// === Components ===
controlHeight: 40,
controlHeightLG: 48,
controlHeightSM: 32,
// === Motion (MD3 规范) ===
motionDurationSlow: '0.3s',
motionDurationMid: '0.2s',
motionDurationFast: '0.1s',
},
components: {
// === Card 组件 (MD3 Elevated Card) ===
Card: {
borderRadiusLG: 16,
paddingLG: 24,
colorBgContainer: isDark ? '#1c1b1f' : '#ffffff',
colorBorderSecondary: isDark ? '#49454f' : '#e3e1e6',
colorTextHeading: isDark ? '#e6e1e5' : '#1c1b1f',
},
// === Button 组件 (MD3 规范) ===
Button: {
borderRadius: 20, // MD3 Filled Button 圆角
borderRadiusLG: 24,
borderRadiusSM: 16,
controlHeight: 40,
controlHeightLG: 48,
controlHeightSM: 32,
fontSize: 14,
fontSizeLG: 16,
fontSizeSM: 12,
paddingContentHorizontal: 24,
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
colorBgContainer: isDark ? '#26252a' : '#ffffff',
},
// === Input 组件 (MD3 Text Field) ===
Input: {
borderRadius: 12,
controlHeight: 40,
colorBgContainer: isDark ? '#26252a' : '#ffffff',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
colorTextPlaceholder: isDark ? '#938f99' : '#79747e',
colorBorder: isDark ? '#49454f' : '#d1cdd6',
},
// === Select 组件 ===
Select: {
borderRadius: 12,
controlHeight: 40,
colorBgContainer: isDark ? '#26252a' : '#ffffff',
colorBgElevated: isDark ? '#26252a' : '#ffffff',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
colorTextPlaceholder: isDark ? '#938f99' : '#79747e',
colorBorder: isDark ? '#49454f' : '#d1cdd6',
},
// === Modal 组件 (MD3 Dialog) ===
Modal: {
borderRadiusLG: 28, // MD3 Dialog 使用 Extra Large 圆角
colorBgElevated: isDark ? '#1c1b1f' : '#ffffff',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
colorTextHeading: isDark ? '#e6e1e5' : '#1c1b1f',
},
// === Table 组件 ===
Table: {
borderRadius: 12,
colorBgContainer: isDark ? '#1c1b1f' : '#ffffff',
colorFillAlter: isDark ? '#26252a' : '#f5f5f5',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
colorTextHeading: isDark ? '#e6e1e5' : '#1c1b1f',
colorBorderSecondary: isDark ? '#49454f' : '#e3e1e6',
},
// === Tabs 组件 ===
Tabs: {
borderRadius: 12,
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
colorBgContainer: isDark ? '#1c1b1f' : '#ffffff',
},
// === Menu 组件 ===
Menu: {
colorItemBg: isDark ? '#1c1b1f' : '#ffffff',
colorItemBgHover: isDark ? '#26252a' : '#f5f5f5',
colorItemBgSelected: isDark ? '#3a4a3f' : '#e8f5e9',
colorItemText: isDark ? '#e6e1e5' : '#1c1b1f',
colorItemTextSelected: isDark ? '#81c784' : '#4caf50',
borderRadius: 12,
},
// === Dropdown 组件 ===
Dropdown: {
colorBgElevated: isDark ? '#26252a' : '#ffffff',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
borderRadiusLG: 12,
},
// === Descriptions 组件 ===
Descriptions: {
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
colorTextSecondary: isDark ? '#cac4d0' : '#49454f',
colorBgContainer: isDark ? '#1c1b1f' : '#ffffff',
colorFillAlter: isDark ? '#201f24' : '#f3f4f6', // Label 背景色 = surface-container
colorSplit: isDark ? '#49454f' : '#e3e1e6',
borderRadiusLG: 8, // 设置 Descriptions 容器圆角
},
// === Alert 组件 ===
Alert: {
borderRadiusLG: 12,
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
},
// === Drawer 组件 ===
Drawer: {
colorBgElevated: isDark ? '#1c1b1f' : '#ffffff',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
borderRadiusLG: 16,
},
// === Form 组件 ===
Form: {
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
colorTextHeading: isDark ? '#e6e1e5' : '#1c1b1f',
},
// === Empty 组件 ===
Empty: {
colorTextDescription: isDark ? '#938f99' : '#79747e',
},
// === Tag 组件 ===
Tag: {
borderRadiusSM: 16, // 药丸形
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
},
// === Switch 组件 ===
Switch: {
colorPrimary: isDark ? '#81c784' : '#4caf50',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
},
// === Segmented 组件 ===
Segmented: {
borderRadius: 12,
borderRadiusSM: 8,
// 根据源码,Segmented 使用这些 token 映射:
// labelColor <- colorTextLabel
// labelColorHover <- colorText
// bgColor <- colorBgLayout
// bgColorHover <- colorFillSecondary
// bgColorSelected <- colorBgElevated
// 未选中项文字颜色
colorTextLabel: isDark ? '#938f99' : '#79747e',
labelColor: isDark ? '#938f99' : '#79747e',
// 选中项和 hover 时的文字颜色
colorText: isDark ? '#ffffff' : '#1c1b1f',
labelColorHover: isDark ? '#ffffff' : '#1c1b1f',
// 整体背景色
colorBgLayout: isDark ? '#26252a' : '#f5f5f5',
bgColor: isDark ? '#26252a' : '#f5f5f5',
// hover 背景色(降低透明度,保持文字可见)
colorFillSecondary: isDark ? 'rgba(129, 199, 132, 0.12)' : 'rgba(76, 175, 80, 0.08)',
bgColorHover: isDark ? 'rgba(129, 199, 132, 0.12)' : 'rgba(76, 175, 80, 0.08)',
// 选中项背景色(主题色)
colorBgElevated: isDark ? '#81c784' : '#4caf50',
bgColorSelected: isDark ? '#81c784' : '#4caf50',
},
// === Tooltip 组件 ===
Tooltip: {
colorBgSpotlight: isDark ? '#313033' : '#f5f5f5', // Tooltip 背景色(跟随主题)
colorTextLightSolid: isDark ? '#ffffff' : '#1c1b1f', // Tooltip 文本颜色(跟随主题)
borderRadius: 8,
},
},
// 算法配置 - 使用 Ant Design 内置的暗黑算法
algorithm: isDark ? [theme.darkAlgorithm] : [],
};
}
+75
View File
@@ -0,0 +1,75 @@
import axios from 'axios';
// 创建 axios 实例
const client = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器 - 添加 Token
client.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => {
return Promise.reject(error);
}
);
// 响应拦截器 - 统一错误处理
client.interceptors.response.use(
response => {
return response.data;
},
error => {
if (error.response) {
// 服务器返回错误状态码
const { status, data } = error.response;
if (status === 401) {
// JWT token 过期或无效:需要重新登录
// 注意:打卡业务的 authorization token 过期不会影响网站登录状态
localStorage.removeItem('token');
localStorage.removeItem('user');
// 延迟跳转到登录页
setTimeout(() => {
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}, 100);
}
// 返回统一的错误对象
return Promise.reject({
status,
message: data.detail || data.message || '请求失败',
data,
});
} else if (error.request) {
// 请求已发出但没有收到响应(超时或网络错误)
return Promise.reject({
status: 0,
message:
error.code === 'ECONNABORTED' ? '请求超时,请稍后重试' : '网络错误,请检查您的网络连接',
data: null,
});
} else {
// 发生了触发请求错误的问题
return Promise.reject({
status: 0,
message: error.message || '请求配置错误',
data: null,
});
}
}
);
export default client;
+258
View File
@@ -0,0 +1,258 @@
import client from './client';
/**
* 认证 API
*/
export const authAPI = {
// 请求 QR 码
requestQRCode: alias => {
return client.post('/api/auth/request_qrcode', { alias });
},
// 查询扫码状态
getQRCodeStatus: sessionId => {
return client.get(`/api/auth/qrcode_status/${sessionId}`);
},
// 取消 QR 码登录会话
cancelQRCodeSession: sessionId => {
return client.delete(`/api/auth/qrcode_session/${sessionId}`);
},
// 别名+密码登录
aliasLogin: (alias, password) => {
return client.post('/api/auth/alias_login', { alias, password });
},
// 验证 Token
verifyToken: token => {
return client.post('/api/auth/verify_token', { token });
},
};
/**
* 用户 API
*/
export const userAPI = {
// 获取当前用户信息
getCurrentUser: () => {
return client.get('/api/users/me');
},
// 获取当前用户审批状态
getUserStatus: () => {
return client.get('/api/users/me/status');
},
// 获取当前用户 Token 状态
getTokenStatus: () => {
return client.get('/api/users/me/token_status');
},
// 更新当前用户个人信息
updateProfile: profileData => {
return client.put('/api/users/me/profile', profileData);
},
// 创建用户(管理员)
createUser: userData => {
return client.post('/api/users', userData);
},
// 获取所有用户(管理员)
getUsers: (params = {}) => {
return client.get('/api/users', { params });
},
// 获取指定用户
getUser: userId => {
return client.get(`/api/users/${userId}`);
},
// 更新用户
updateUser: (userId, userData) => {
return client.put(`/api/users/${userId}`, userData);
},
// 删除用户
deleteUser: userId => {
return client.delete(`/api/users/${userId}`);
},
};
/**
* 任务 API (V2 新增)
*/
export const taskAPI = {
// 获取当前用户的任务列表
getMyTasks: (params = {}) => {
return client.get('/api/tasks', { params });
},
// 获取任务详情
getTask: taskId => {
return client.get(`/api/tasks/${taskId}`);
},
// 更新任务
updateTask: (taskId, taskData) => {
return client.put(`/api/tasks/${taskId}`, taskData);
},
// 删除任务
deleteTask: taskId => {
return client.delete(`/api/tasks/${taskId}`);
},
// 切换任务启用状态
toggleTask: taskId => {
return client.post(`/api/tasks/${taskId}/toggle`);
},
// 手动触发任务打卡(异步,立即返回)
checkInTask: taskId => {
return client.post(`/api/check_in/manual/${taskId}`);
},
// 查询打卡记录状态
getCheckInRecordStatus: recordId => {
return client.get(`/api/check_in/record/${recordId}/status`);
},
// 获取任务的打卡记录
getTaskRecords: (taskId, params = {}) => {
return client.get(`/api/check_in/task/${taskId}/records`, { params });
},
};
/**
* 打卡 API
*/
export const checkInAPI = {
// 手动打卡(兼容旧版,推荐使用 taskAPI.checkInTask
manualCheckIn: taskId => {
// 打卡操作耗时较长,设置 120 秒超时
return client.post(
`/api/check_in/manual/${taskId}`,
{},
{
timeout: 120000, // 120 秒
}
);
},
// 获取任务打卡记录(兼容旧版,推荐使用 taskAPI.getTaskRecords
getMyRecords: (params = {}) => {
return client.get('/api/check_in/my-records', { params });
},
// 获取所有打卡记录(管理员)
getAllRecords: (params = {}) => {
return client.get('/api/check_in/records', { params });
},
// 统计打卡记录数
getRecordsCount: (params = {}) => {
return client.get('/api/check_in/records/count', { params });
},
};
/**
* 管理员 API
*/
export const adminAPI = {
// 获取待审批用户
getPendingUsers: () => {
return client.get('/api/admin/users/pending');
},
// 审批通过用户
approveUser: userId => {
return client.post(`/api/admin/users/${userId}/approve`);
},
// 拒绝用户
rejectUser: userId => {
return client.delete(`/api/admin/users/${userId}/reject`);
},
// 批量启用/禁用任务(V2 更新)
batchToggleTasks: (taskIds, isActive) => {
return client.post('/api/admin/batch_toggle_tasks', {
task_ids: taskIds,
is_active: isActive,
});
},
// 批量触发打卡(V2 更新)
batchCheckIn: taskIds => {
return client.post('/api/admin/batch_check_in', {
task_ids: taskIds,
});
},
// 查看系统日志
getLogs: (params = {}) => {
return client.get('/api/admin/logs', { params });
},
// 系统统计信息
getStats: () => {
return client.get('/api/admin/stats');
},
};
/**
* 模板 API
*/
export const templateAPI = {
// 获取所有模板列表
getTemplates: (params = {}) => {
return client.get('/api/templates', { params });
},
// 获取启用的模板列表
getActiveTemplates: (params = {}) => {
return client.get('/api/templates/active', { params });
},
// 获取单个模板详情
getTemplate: templateId => {
return client.get(`/api/templates/${templateId}`);
},
// 预览模板生成的 payload
previewTemplate: templateId => {
return client.get(`/api/templates/${templateId}/preview`);
},
// 创建模板(管理员)
createTemplate: templateData => {
return client.post('/api/templates', templateData);
},
// 更新模板(管理员)
updateTemplate: (templateId, templateData) => {
return client.put(`/api/templates/${templateId}`, templateData);
},
// 删除模板(管理员)
deleteTemplate: templateId => {
return client.delete(`/api/templates/${templateId}`);
},
// 从模板创建任务
createTaskFromTemplate: requestData => {
return client.post('/api/templates/create-task', requestData);
},
};
// 导出所有 API
export default {
auth: authAPI,
user: userAPI,
task: taskAPI, // V2 新增
checkIn: checkInAPI,
admin: adminAPI,
template: templateAPI, // V2.2 新增
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

@@ -0,0 +1,509 @@
<template>
<div class="crontab-editor">
<!-- 模式选择 Tab -->
<div class="mode-tabs">
<button
v-for="m in modes"
:key="m"
:class="{ active: mode === m }"
class="mode-tab"
type="button"
@click.prevent="switchMode(m)"
>
{{ modeLabels[m] }}
</button>
</div>
<!-- 快速模式仅日期 20:00 -->
<div v-if="mode === 'quick'" class="mode-content">
<div class="quick-option">
<a-radio-group v-model:value="selectedQuick">
<a-radio value="20:00">
<span class="option-label">每天 20:00默认</span>
<span class="option-desc">推荐的默认时间</span>
</a-radio>
</a-radio-group>
</div>
</div>
<!-- 自定义模式:可视化构建器 -->
<div v-if="mode === 'custom'" class="mode-content">
<a-form layout="vertical">
<a-form-item label="时间" name="customTime">
<a-time-picker
id="cron-custom-time"
v-model:value="customTimeValue"
format="HH:mm"
placeholder="选择时间"
:minute-step="30"
style="width: 100%"
@change="onCustomTimeChange"
/>
</a-form-item>
<a-form-item label="频率" name="customFrequency">
<a-select id="cron-custom-frequency" v-model:value="customFrequency" style="width: 100%">
<a-select-option value="daily">每天</a-select-option>
<a-select-option value="weekday">工作日周一-周五</a-select-option>
<a-select-option value="weekend">周末周六-周日</a-select-option>
</a-select>
</a-form-item>
</a-form>
</div>
<!-- 高级模式原始 Crontab 表达式 -->
<div v-if="mode === 'advanced'" class="mode-content">
<div class="expression-input">
<a-textarea
v-model:value="advancedExpression"
placeholder="输入 crontab 表达式(例如:0 20 * * *"
:rows="2"
@input="handleAdvancedInput"
/>
<div class="help-text">
格式: 分钟 小时 日期 月份 星期
<a href="https://crontab.guru" target="_blank">了解更多</a>
</div>
</div>
</div>
<!-- 预览部分 -->
<div v-if="nextExecutions.length" class="preview-section">
<h4>下一个执行时间</h4>
<ul class="execution-list">
<li v-for="(time, idx) in nextExecutions" :key="idx">{{ time }}</li>
</ul>
</div>
<!-- 验证消息 -->
<div v-if="validationMessage" :class="['validation-message', validationStatus]">
{{ validationMessage }}
</div>
</div>
</template>
<script setup>
import { ref, watch, onBeforeUnmount } from 'vue';
import dayjs from 'dayjs';
import client from '@/api/client';
const props = defineProps({
modelValue: {
type: String,
default: '0 0 * * *',
},
});
const emit = defineEmits(['update:modelValue']);
const mode = ref('quick');
const modeLabels = {
quick: '快速',
custom: '自定义',
advanced: '高级',
};
const modes = ['quick', 'custom', 'advanced'];
// 快速模式
const selectedQuick = ref('20:00');
// 自定义模式
const customTime = ref('20:00');
const customTimeValue = ref(dayjs('20:00', 'HH:mm'));
const customFrequency = ref('daily');
// 高级模式
const advancedExpression = ref(props.modelValue || '0 20 * * *');
const validationMessage = ref('');
const validationStatus = ref('');
// 通用
const nextExecutions = ref([]);
// 标志:是否正在手动编辑高级模式(防止自动解析导致模式切换)
let isManualEditing = false;
// 切换模式 - 防止页面刷新
function switchMode(newMode) {
mode.value = newMode;
// 切换到快速模式时,自动选择默认值并触发保存
if (newMode === 'quick') {
selectedQuick.value = '20:00';
const cron = buildCrontabFromQuick();
advancedExpression.value = cron;
emit('update:modelValue', cron);
if (cron) validateAndPreview(cron);
}
// 切换到自定义模式时,基于当前值构建 cron
else if (newMode === 'custom') {
const cron = buildCrontabFromCustom();
advancedExpression.value = cron;
emit('update:modelValue', cron);
if (cron) validateAndPreview(cron);
}
// 切换到高级模式时,使用当前的 advancedExpression
else if (newMode === 'advanced') {
if (advancedExpression.value) {
emit('update:modelValue', advancedExpression.value);
validateAndPreview(advancedExpression.value);
}
}
}
// 处理时间选择器变化
function onCustomTimeChange(time) {
if (time) {
customTime.value = time.format('HH:mm');
}
}
// 监听 - 只在有效值时更新
watch(selectedQuick, () => {
const cron = buildCrontabFromQuick();
advancedExpression.value = cron;
emit('update:modelValue', cron);
if (cron) validateAndPreview(cron);
});
watch(customFrequency, () => {
const cron = buildCrontabFromCustom();
advancedExpression.value = cron;
emit('update:modelValue', cron);
if (cron) validateAndPreview(cron);
});
watch(customTime, () => {
const cron = buildCrontabFromCustom();
advancedExpression.value = cron;
emit('update:modelValue', cron);
if (cron) validateAndPreview(cron);
});
// 工具函数
function buildCrontabFromQuick() {
if (selectedQuick.value === '20:00') {
return '0 20 * * *'; // 每天 20:00
}
return null;
}
function buildCrontabFromCustom() {
const [hour, minute] = customTime.value.split(':');
let dow = '*'; // 星期
if (customFrequency.value === 'weekday') {
dow = '1-5'; // 周一至周五
} else if (customFrequency.value === 'weekend') {
dow = '0,6'; // 周六和周日
}
return `${minute} ${hour} * * ${dow}`;
}
// 处理高级模式输入 - 使用防抖以避免频繁调用API
let debounceTimer = null;
function handleAdvancedInput() {
// 设置手动编辑标志
isManualEditing = true;
// 立即触发 emit,保证值实时同步
emit('update:modelValue', advancedExpression.value);
// 使用防抖延迟验证
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(async () => {
if (!advancedExpression.value.trim()) {
validationMessage.value = '';
nextExecutions.value = [];
return;
}
await validateAndPreview(advancedExpression.value);
}, 500); // 500ms 防抖延迟
}
async function validateAndPreview(expr) {
if (!expr) {
validationMessage.value = '';
nextExecutions.value = [];
return;
}
try {
const response = await client.post('/api/tasks/validate-cron', {
cron_expression: expr,
});
if (response.valid) {
validationStatus.value = 'success';
validationMessage.value = `有效: ${response.description}`;
nextExecutions.value = response.next_times;
}
} catch (error) {
validationStatus.value = 'error';
validationMessage.value = error.message || '无效的 crontab 表达式';
nextExecutions.value = [];
}
}
// 解析 cron 表达式并设置对应的模式
function parseCronExpression(cron) {
if (!cron) return;
advancedExpression.value = cron;
// 尝试匹配快速模式: 0 20 * * *
if (cron === '0 20 * * *') {
mode.value = 'quick';
selectedQuick.value = '20:00';
validateAndPreview(cron);
return;
}
// 尝试解析为自定义模式
const parts = cron.trim().split(/\s+/);
if (parts.length === 5) {
const [minute, hour, day, month, dow] = parts;
// 检查是否是简单的每天或工作日/周末模式
if (day === '*' && month === '*') {
const hourNum = parseInt(hour);
const minuteNum = parseInt(minute);
if (
!isNaN(hourNum) &&
!isNaN(minuteNum) &&
hourNum >= 0 &&
hourNum < 24 &&
minuteNum >= 0 &&
minuteNum < 60
) {
mode.value = 'custom';
customTime.value = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
customTimeValue.value = dayjs(customTime.value, 'HH:mm');
// 识别频率
if (dow === '*') {
customFrequency.value = 'daily';
} else if (dow === '1-5') {
customFrequency.value = 'weekday';
} else if (dow === '0,6' || dow === '6,0') {
customFrequency.value = 'weekend';
} else {
// 不支持的星期模式,使用高级模式
mode.value = 'advanced';
}
validateAndPreview(cron);
return;
}
}
}
// 其他情况使用高级模式
mode.value = 'advanced';
validateAndPreview(cron);
}
// 初始化 - 解析传入的 cron 表达式
watch(
() => props.modelValue,
newVal => {
// 如果正在手动编辑高级模式,跳过自动解析
if (isManualEditing) {
isManualEditing = false; // 重置标志
return;
}
if (newVal) {
parseCronExpression(newVal);
}
},
{ immediate: true }
);
// 组件卸载时清理防抖定时器,防止内存泄漏
onBeforeUnmount(() => {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
});
</script>
<style scoped>
/* === Material Design 3 样式重写 === */
.crontab-editor {
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: 12px;
padding: 20px;
background-color: var(--md-sys-color-surface-container-lowest);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.crontab-editor:focus-within {
border-color: var(--md-sys-color-primary);
box-shadow: 0 0 0 1px var(--md-sys-color-primary);
}
/* 模式选择标签 */
.mode-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
padding-bottom: 0;
}
.mode-tab {
padding: 10px 20px;
background: none;
border: none;
cursor: pointer;
color: var(--md-sys-color-on-surface-variant);
font-size: 14px;
font-weight: 500;
letter-spacing: 0.1px;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.mode-tab:hover {
color: var(--md-sys-color-on-surface);
background-color: rgba(76, 175, 80, 0.04);
}
.mode-tab.active {
color: var(--md-sys-color-primary);
border-bottom-color: var(--md-sys-color-primary);
font-weight: 600;
}
/* 模式内容区域 */
.mode-content {
margin: 20px 0;
}
/* 快速选项 */
.quick-option {
padding: 16px;
background-color: var(--md-sys-color-surface);
border-radius: 12px;
border: 1px solid var(--md-sys-color-outline-variant);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.quick-option:hover {
border-color: var(--md-sys-color-outline);
box-shadow:
0px 1px 2px 0px rgba(0, 0, 0, 0.3),
0px 1px 3px 1px rgba(0, 0, 0, 0.15);
}
.option-label {
font-size: 14px;
font-weight: 500;
color: var(--md-sys-color-on-surface);
letter-spacing: 0.1px;
}
.option-desc {
margin-left: 12px;
color: var(--md-sys-color-on-surface-variant);
font-size: 12px;
letter-spacing: 0.4px;
}
/* 表达式输入 */
.expression-input {
margin: 16px 0;
}
.help-text {
margin-top: 8px;
color: var(--md-sys-color-on-surface-variant);
font-size: 12px;
line-height: 16px;
letter-spacing: 0.4px;
}
.help-text a {
color: var(--md-sys-color-secondary);
text-decoration: none;
font-weight: 500;
transition: color 0.2s;
}
.help-text a:hover {
color: var(--md-sys-color-primary);
text-decoration: underline;
}
/* 预览区域 */
.preview-section {
margin: 16px 0;
padding: 16px;
background-color: var(--md-sys-color-surface-container-low);
border-radius: 12px;
border: 1px solid var(--md-sys-color-outline-variant);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.preview-section h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 500;
color: var(--md-sys-color-on-surface);
letter-spacing: 0.1px;
}
.execution-list {
margin: 0;
padding-left: 24px;
font-size: 13px;
line-height: 20px;
color: var(--md-sys-color-on-surface-variant);
}
.execution-list li {
margin-bottom: 4px;
}
/* 验证消息 */
.validation-message {
padding: 12px 16px;
border-radius: 12px;
margin-top: 16px;
font-size: 13px;
line-height: 20px;
letter-spacing: 0.25px;
border: 1px solid;
display: flex;
align-items: center;
gap: 8px;
}
.validation-message.success {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
border-color: var(--md-sys-color-primary);
}
.validation-message.error {
background-color: var(--md-sys-color-error-container);
color: var(--md-sys-color-on-error-container);
border-color: var(--md-sys-color-error);
}
.validation-message.info {
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface-variant);
border-color: var(--md-sys-color-outline-variant);
}
</style>
@@ -0,0 +1,277 @@
<template>
<div class="field-config-editor space-y-4">
<!-- Row 1: Display Name and Field Type -->
<div class="grid grid-cols-2 gap-4">
<a-form-item label="显示名称" class="mb-0">
<a-input
:value="modelValue.display_name"
placeholder="在表单中显示的名称"
allow-clear
@change="e => updateField('display_name', e.target.value)"
/>
<span class="text-xs text-on-surface-variant mt-1">显示名称</span>
</a-form-item>
<a-form-item label="字段类型" class="mb-0">
<a-select
:value="modelValue.field_type"
placeholder="选择输入控件类型"
class="w-full"
@change="handleFieldTypeChange"
>
<a-select-option label="📝 单行文本" value="text" />
<a-select-option label="📄 多行文本" value="textarea" />
<a-select-option label="🔢 数字输入" value="number" />
<a-select-option label="📋 下拉选择" value="select" />
</a-select>
<span class="text-xs text-on-surface-variant mt-1">用户填写时使用的输入控件</span>
</a-form-item>
</div>
<!-- Row 2: Value Type and Default Value -->
<div class="grid grid-cols-2 gap-4">
<a-form-item label="值类型" class="mb-0">
<a-select
:value="modelValue.value_type"
placeholder="选择数据类型"
class="w-full"
@change="value => updateField('value_type', value)"
>
<a-select-option label="字符串 (string)" value="string">
<span class="text-xs text-on-surface-variant">字符串 (string)</span>
</a-select-option>
<a-select-option label="整数 (int)" value="int">
<span class="text-xs text-on-surface-variant">整数 (int)</span>
</a-select-option>
<a-select-option label="浮点数 (double)" value="double">
<span class="text-xs text-on-surface-variant">浮点数 (double)</span>
</a-select-option>
<a-select-option label="布尔值 (bool)" value="bool">
<span class="text-xs text-on-surface-variant">布尔值 (bool)</span>
</a-select-option>
<a-select-option label="JSON对象 (json)" value="json">
<span class="text-xs text-on-surface-variant">JSON对象 (json) - 用于Values字段</span>
</a-select-option>
</a-select>
<span class="text-xs text-on-surface-variant mt-1">数据存储时的类型</span>
</a-form-item>
<a-form-item label="默认值" class="mb-0">
<a-input
v-if="modelValue.value_type !== 'json'"
:value="modelValue.default_value"
placeholder="字段的默认值"
allow-clear
@change="e => updateField('default_value', e.target.value)"
/>
<a-textarea
v-else
:value="modelValue.default_value"
placeholder="字段的默认值"
:rows="3"
allow-clear
@change="e => updateField('default_value', e.target.value)"
/>
<span class="text-xs text-on-surface-variant mt-1">
<template v-if="modelValue.value_type === 'json'">
<p>输入JSON对象,会自动序列化为字符串</p>
<p>:{"key1":value1,"key2":value2}</p>
</template>
<template v-else> 用户未填写时使用此值 </template>
</span>
</a-form-item>
</div>
<!-- Row 3: Placeholder -->
<a-form-item label="占位符提示" class="mb-0">
<a-input
:value="modelValue.placeholder"
placeholder="输入框的灰色提示文本"
allow-clear
@change="e => updateField('placeholder', e.target.value)"
/>
<span class="text-xs text-on-surface-variant mt-1">占位符</span>
</a-form-item>
<!-- Row 4: Switches -->
<div
class="grid grid-cols-2 gap-4 p-3 bg-surface-container-low rounded-md3 border border-outline-variant"
>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-on-surface">是否必填</label>
<p class="text-xs text-on-surface-variant">用户必须填写此字段</p>
</div>
<a-switch
:checked="modelValue.required"
:disabled="modelValue.hidden"
@change="handleRequiredChange"
/>
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-on-surface">是否隐藏</label>
<p class="text-xs text-on-surface-variant">直接使用默认值不在表单中显示</p>
</div>
<a-switch :checked="modelValue.hidden" @change="handleHiddenChange" />
</div>
</div>
<a-alert v-if="modelValue.hidden" message="💡 提示" type="info" :closable="false" class="mt-3">
<template #description>
<p class="text-xs">
隐藏字段将自动使用默认值不会在创建任务表单中显示请确保设置了合适的默认值
</p>
</template>
</a-alert>
<!-- Options for select type -->
<div v-if="modelValue.field_type === 'select'" class="border-t pt-4 mt-4">
<a-form-item label="选项列表" class="mb-0">
<div class="space-y-2">
<div
v-for="(option, index) in modelValue.options || []"
:key="index"
class="flex items-center gap-2 p-2 bg-surface-container rounded-md3"
>
<span class="text-xs text-on-surface-variant w-8">{{ index + 1 }}.</span>
<a-input
:value="option.label"
placeholder="显示文本(如:健康)"
size="small"
class="flex-1"
@change="e => updateOption(index, 'label', e.target.value)"
/>
<a-input
:value="option.value"
placeholder="选项值(如:healthy"
size="small"
class="flex-1"
@change="e => updateOption(index, 'value', e.target.value)"
/>
<a-button size="small" danger @click="removeOption(index)">
<template #icon><DeleteOutlined /></template>
</a-button>
</div>
<a-button size="small" type="primary" class="w-full" @click="addOption">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
添加选项
</a-button>
<p class="text-xs text-on-surface-variant mt-2">
💡 提示显示文本是用户看到的内容,选项值是实际保存的数据
</p>
</div>
</a-form-item>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import { DeleteOutlined } from '@ant-design/icons-vue';
const props = defineProps({
modelValue: {
type: Object,
required: true,
},
fieldKey: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue']);
// Update single field
const updateField = (field, value) => {
emit('update:modelValue', {
...props.modelValue,
[field]: value,
});
};
// Handle required change
const handleRequiredChange = value => {
updateField('required', value);
};
// Handle hidden change - 当隐藏时,自动设置 required 为 false
const handleHiddenChange = value => {
const updated = {
...props.modelValue,
hidden: value,
};
// 如果设置为隐藏,则取消必填
if (value) {
updated.required = false;
}
emit('update:modelValue', updated);
};
// Handle field type change
const handleFieldTypeChange = newType => {
const updated = {
...props.modelValue,
field_type: newType,
};
if (newType === 'select' && !updated.options) {
updated.options = [];
}
emit('update:modelValue', updated);
};
// Add option
const addOption = () => {
const options = [...(props.modelValue.options || [])];
options.push({ label: '', value: '' });
emit('update:modelValue', {
...props.modelValue,
options,
});
};
// Update option
const updateOption = (index, field, value) => {
const options = [...(props.modelValue.options || [])];
options[index] = {
...options[index],
[field]: value,
};
emit('update:modelValue', {
...props.modelValue,
options,
});
};
// Remove option
const removeOption = index => {
const options = [...(props.modelValue.options || [])];
options.splice(index, 1);
emit('update:modelValue', {
...props.modelValue,
options,
});
};
</script>
<style scoped>
/* 样式已移至全局 CSS (style.css) 以保持统一性 */
</style>
@@ -0,0 +1,630 @@
<template>
<div
class="field-tree-node border border-outline-variant rounded-md3 p-4 bg-surface shadow-md3-1 hover:shadow-md3-2 transition-shadow"
>
<!-- 普通字段 -->
<div v-if="isFieldConfig" class="field-config">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-outline-variant">
<div class="flex items-center gap-3">
<button
type="button"
class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
@click="isCollapsed = !isCollapsed"
>
<svg
class="w-4 h-4 text-on-surface-variant transition-transform"
:class="{ 'rotate-180': !isCollapsed }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<span class="font-mono text-base font-bold text-primary">{{ fieldKey }}</span>
<a-tag type="primary" size="small">普通字段</a-tag>
</div>
<div class="flex gap-2">
<a-button size="small" title="上移" @click="handleMove('up')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg>
</a-button>
<a-button size="small" title="下移" @click="handleMove('down')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</a-button>
<a-button size="small" type="danger" plain @click="handleDelete">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
删除
</a-button>
</div>
</div>
<div v-show="!isCollapsed" class="bg-surface-container-low rounded-md3 p-3">
<FieldConfigEditor v-model="localFieldConfig" :field-key="fieldKey" />
</div>
</div>
<!-- 数组字段 -->
<div v-else-if="isArray" class="array-field">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-outline-variant">
<div class="flex items-center gap-3">
<button
type="button"
class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
@click="isCollapsed = !isCollapsed"
>
<svg
class="w-4 h-4 text-on-surface-variant transition-transform"
:class="{ 'rotate-180': !isCollapsed }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<svg class="w-5 h-5 text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
<span class="font-mono text-base font-bold text-secondary">{{ fieldKey }}</span>
<a-tag type="warning" size="small">数组字段</a-tag>
</div>
<div class="flex gap-2">
<a-button size="small" title="上移" @click="handleMove('up')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg>
</a-button>
<a-button size="small" title="下移" @click="handleMove('down')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</a-button>
<a-button size="small" type="primary" @click="addArrayItem">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
添加元素
</a-button>
<a-button size="small" type="danger" plain @click="handleDelete">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
删除
</a-button>
</div>
</div>
<div v-show="!isCollapsed">
<div
v-if="localFieldConfig.length === 0"
class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline"
>
<p class="text-sm text-on-surface-variant mb-2">数组为空</p>
<a-button size="small" type="primary" @click="addArrayItem">添加第一个元素</a-button>
</div>
<div v-else class="space-y-3 mt-3">
<div
v-for="(item, index) in localFieldConfig"
:key="index"
class="border border-outline-variant rounded-md3 p-3 bg-surface-container"
>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-secondary">元素 #{{ index + 1 }}</span>
<a-button size="small" type="danger" plain @click="removeArrayItem(index)">
删除元素
</a-button>
</div>
<!-- 如果数组元素是字段配置对象直接渲染为字段编辑器 -->
<div
v-if="typeof item === 'object' && !Array.isArray(item) && 'display_name' in item"
class="bg-surface rounded-md3 p-3"
>
<FieldConfigEditor
:model-value="item"
:field-key="`元素${index + 1}`"
@update:model-value="updateArrayItemField(index, $event)"
/>
</div>
<!-- 如果数组元素是对象但不是字段配置递归渲染其中的字段 -->
<div v-else-if="typeof item === 'object' && !Array.isArray(item)" class="space-y-2">
<FieldTreeNode
v-for="(subConfig, subKey) in item"
:key="subKey"
:field-key="subKey"
:field-config="subConfig"
:path="[...path, index, subKey]"
@update="$emit('update', $event)"
@delete="$emit('delete', $event)"
@move="$emit('move', $event)"
/>
<a-button
class="w-full"
size="small"
type="primary"
plain
@click="addFieldToArrayItem(index)"
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
添加字段
</a-button>
</div>
<!-- 如果数组元素是数组递归渲染 -->
<div v-else-if="Array.isArray(item)">
<FieldTreeNode
:field-key="`元素${index + 1}`"
:field-config="item"
:path="[...path, index]"
@update="$emit('update', $event)"
@delete="$emit('delete', $event)"
@move="$emit('move', $event)"
/>
</div>
</div>
</div>
</div>
</div>
<!-- 对象字段 -->
<div v-else-if="isObject" class="object-field">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-outline-variant">
<div class="flex items-center gap-3">
<button
type="button"
class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
@click="isCollapsed = !isCollapsed"
>
<svg
class="w-4 h-4 text-on-surface-variant transition-transform"
:class="{ 'rotate-180': !isCollapsed }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span class="font-mono text-base font-bold text-accent">{{ fieldKey }}</span>
<a-tag type="success" size="small">对象字段</a-tag>
</div>
<div class="flex gap-2">
<a-button size="small" title="上移" @click="handleMove('up')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg>
</a-button>
<a-button size="small" title="下移" @click="handleMove('down')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</a-button>
<a-button size="small" type="primary" @click="addFieldToObject">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
添加子字段
</a-button>
<a-button size="small" type="danger" plain @click="handleDelete">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
删除
</a-button>
</div>
</div>
<div v-show="!isCollapsed">
<div
v-if="Object.keys(localFieldConfig).length === 0"
class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline"
>
<p class="text-sm text-on-surface-variant mb-2">对象为空</p>
<a-button size="small" type="primary" @click="addFieldToObject"
>添加第一个子字段</a-button
>
</div>
<div v-else class="space-y-3 mt-3 pl-4 border-l-4 border-accent">
<!-- 递归渲染对象中的字段 -->
<FieldTreeNode
v-for="(subConfig, subKey) in localFieldConfig"
:key="subKey"
:field-key="subKey"
:field-config="subConfig"
:path="[...path, subKey]"
@update="$emit('update', $event)"
@delete="$emit('delete', $event)"
@move="$emit('move', $event)"
/>
</div>
</div>
</div>
<!-- 添加字段对话框 -->
<a-modal
v-model:open="addFieldDialogVisible"
:title="currentArrayIndex === -1 ? '添加数组元素' : '添加字段'"
width="400px"
>
<a-form>
<a-form-item :label="currentArrayIndex === -1 ? '字段名(可选)' : '字段名'">
<a-input
v-model:value="newFieldName"
:placeholder="
currentArrayIndex === -1
? '留空则作为数组元素,填写则作为对象字段'
: '例如: FieldId, Values, Texts'
"
/>
</a-form-item>
<a-form-item label="元素类型">
<a-radio-group v-model:value="newFieldType">
<a-radio value="field">普通字段</a-radio>
<a-radio value="array">数组字段</a-radio>
<a-radio value="object">对象字段</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
<template #footer>
<a-button @click="addFieldDialogVisible = false">取消</a-button>
<a-button type="primary" @click="confirmAddField">确定</a-button>
</template>
</a-modal>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { message } from 'ant-design-vue';
import FieldConfigEditor from './FieldConfigEditor.vue';
const props = defineProps({
fieldKey: {
type: String,
required: true,
},
fieldConfig: {
type: [Object, Array],
required: true,
},
path: {
type: Array,
required: true,
},
});
const emit = defineEmits(['update', 'delete', 'move']);
const localFieldConfig = ref(JSON.parse(JSON.stringify(props.fieldConfig)));
const addFieldDialogVisible = ref(false);
const newFieldName = ref('');
const newFieldType = ref('field');
const currentArrayIndex = ref(null);
const isAddingToObject = ref(false);
const isCollapsed = ref(false);
// 标志位,防止循环更新
let isUpdatingFromProps = false;
// 监听 props.fieldConfig 的变化,同步更新 localFieldConfig
watch(
() => props.fieldConfig,
newVal => {
isUpdatingFromProps = true;
localFieldConfig.value = JSON.parse(JSON.stringify(newVal));
// 使用 nextTick 确保在下一个 tick 后重置标志
nextTick(() => {
isUpdatingFromProps = false;
});
},
{ deep: true }
);
// 判断字段类型
const isFieldConfig = computed(() => {
return (
typeof props.fieldConfig === 'object' &&
!Array.isArray(props.fieldConfig) &&
'display_name' in props.fieldConfig
);
});
const isArray = computed(() => {
return Array.isArray(props.fieldConfig);
});
const isObject = computed(() => {
return (
typeof props.fieldConfig === 'object' &&
!Array.isArray(props.fieldConfig) &&
!('display_name' in props.fieldConfig)
);
});
// 监听本地配置变化 - 只在非 props 更新时触发
watch(
localFieldConfig,
newVal => {
if (!isUpdatingFromProps) {
emit('update', { path: props.path, value: newVal });
}
},
{ deep: true }
);
// 删除字段
const handleDelete = () => {
emit('delete', props.path);
};
// 移动字段
const handleMove = direction => {
emit('move', { path: props.path, direction });
};
// 添加数组元素
const addArrayItem = () => {
// 弹出对话框让用户选择添加元素类型
currentArrayIndex.value = -1; // 标记为添加数组元素
isAddingToObject.value = false;
newFieldName.value = ''; // 数组元素不需要字段名,但复用对话框
newFieldType.value = 'field';
addFieldDialogVisible.value = true;
};
// 删除数组元素
const removeArrayItem = index => {
localFieldConfig.value.splice(index, 1);
};
// 更新数组元素的字段配置
const updateArrayItemField = (index, newValue) => {
localFieldConfig.value[index] = newValue;
};
// 为数组元素添加字段
const addFieldToArrayItem = index => {
currentArrayIndex.value = index;
isAddingToObject.value = false;
newFieldName.value = '';
newFieldType.value = 'field';
addFieldDialogVisible.value = true;
};
// 为对象添加字段
const addFieldToObject = () => {
currentArrayIndex.value = null;
isAddingToObject.value = true;
newFieldName.value = '';
newFieldType.value = 'field';
addFieldDialogVisible.value = true;
};
// 确认添加字段
const confirmAddField = () => {
// 如果是添加数组元素(currentArrayIndex === -1
if (currentArrayIndex.value === -1) {
// 检查是否输入了字段名
if (!newFieldName.value || newFieldName.value.trim() === '') {
// 字段名为空,直接添加为数组元素
if (newFieldType.value === 'field') {
localFieldConfig.value.push({
display_name: '',
field_type: 'text',
default_value: '',
required: false,
hidden: false,
value_type: 'string',
options: [],
});
} else if (newFieldType.value === 'array') {
localFieldConfig.value.push([]);
} else if (newFieldType.value === 'object') {
localFieldConfig.value.push({});
}
addFieldDialogVisible.value = false;
message.success({ content: '数组元素添加成功', duration: 2 });
return;
} else {
// 字段名不为空,添加为包含命名字段的对象
const newObject = {};
if (newFieldType.value === 'field') {
newObject[newFieldName.value] = {
display_name: '',
field_type: 'text',
default_value: '',
required: false,
hidden: false,
value_type: 'string',
options: [],
};
} else if (newFieldType.value === 'array') {
newObject[newFieldName.value] = [];
} else if (newFieldType.value === 'object') {
newObject[newFieldName.value] = {};
}
localFieldConfig.value.push(newObject);
addFieldDialogVisible.value = false;
message.success({ content: '带命名字段的对象添加成功', duration: 2 });
return;
}
}
// 其他情况需要字段名
if (!newFieldName.value) {
message.warning({ content: '请输入字段名', duration: 2 });
return;
}
if (isAddingToObject.value) {
// 添加到对象字段
if (localFieldConfig.value[newFieldName.value]) {
message.warning({ content: '该字段已存在', duration: 2 });
return;
}
if (newFieldType.value === 'field') {
localFieldConfig.value[newFieldName.value] = {
display_name: '',
field_type: 'text',
default_value: '',
required: false,
hidden: false,
value_type: 'string',
options: [],
};
} else if (newFieldType.value === 'array') {
localFieldConfig.value[newFieldName.value] = [];
} else if (newFieldType.value === 'object') {
localFieldConfig.value[newFieldName.value] = {};
}
} else if (currentArrayIndex.value !== null) {
// 添加到数组元素
const arrayItem = localFieldConfig.value[currentArrayIndex.value];
if (arrayItem[newFieldName.value]) {
message.warning({ content: '该字段已存在', duration: 2 });
return;
}
if (newFieldType.value === 'field') {
arrayItem[newFieldName.value] = {
display_name: '',
field_type: 'text',
default_value: '',
required: false,
hidden: false,
value_type: 'string',
options: [],
};
} else if (newFieldType.value === 'array') {
arrayItem[newFieldName.value] = [];
} else if (newFieldType.value === 'object') {
arrayItem[newFieldName.value] = {};
}
}
addFieldDialogVisible.value = false;
message.success({ content: '字段添加成功', duration: 2 });
};
</script>
<style scoped>
.field-tree-node {
position: relative;
}
.rotate-180 {
transform: rotate(180deg);
}
</style>
+41
View File
@@ -0,0 +1,41 @@
<template>
<div class="layout-container">
<Navbar />
<div class="main-content">
<slot />
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue';
import Navbar from './Navbar.vue';
import { useTokenMonitor } from '@/composables/useTokenMonitor';
// 启动全局 Token 监控
const { startMonitoring } = useTokenMonitor();
onMounted(() => {
startMonitoring();
});
</script>
<style scoped>
.layout-container {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(
135deg,
var(--md-sys-color-surface-container-lowest) 0%,
var(--md-sys-color-surface-container-low) 100%
);
}
.main-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
</style>
+473
View File
@@ -0,0 +1,473 @@
<template>
<div
class="navbar-wrapper sticky top-0 z-50"
:style="{
backgroundColor: isDark ? '#1c1b1f' : '#ffffff',
boxShadow: isDark
? '0 2px 8px rgba(0, 0, 0, 0.6), 0 4px 16px rgba(0, 0, 0, 0.4)'
: '0 2px 8px rgba(0, 0, 0, 0.15), 0 4px 16px rgba(0, 0, 0, 0.1)',
borderBottom: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.08)',
}"
>
<nav class="max-w-7xl mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<!-- Logo and Brand -->
<div class="flex items-center space-x-8">
<router-link to="/" class="flex items-center space-x-3 group">
<div
class="w-10 h-10 bg-gradient-to-br from-primary-500 to-secondary-500 rounded-md3 flex items-center justify-center transform group-hover:scale-110 transition-transform"
>
<CheckCircleOutlined class="text-white text-xl" />
</div>
<span class="text-xl font-bold text-gradient">接龙自动打卡</span>
</router-link>
<!-- Desktop Navigation Links -->
<div v-if="!isMobile" class="hidden md:flex items-center space-x-2">
<router-link v-slot="{ isActive }" to="/dashboard" custom>
<a
:class="[
'nav-button px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
: 'text-on-surface',
]"
@click="router.push('/dashboard')"
>
<div class="flex items-center space-x-2">
<HomeOutlined />
<span>仪表盘</span>
</div>
</a>
</router-link>
<router-link v-slot="{ isActive }" to="/tasks" custom>
<a
:class="[
'nav-button px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
: 'text-on-surface',
]"
@click="router.push('/tasks')"
>
<div class="flex items-center space-x-2">
<FileTextOutlined />
<span>任务管理</span>
</div>
</a>
</router-link>
<router-link v-slot="{ isActive }" to="/records" custom>
<a
:class="[
'nav-button px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
: 'text-on-surface',
]"
@click="router.push('/records')"
>
<div class="flex items-center space-x-2">
<UnorderedListOutlined />
<span>打卡记录</span>
</div>
</a>
</router-link>
<!-- Admin Dropdown Menu -->
<a-dropdown v-if="authStore.isAdmin" :trigger="['hover']">
<a
:class="[
'admin-nav-button px-4 py-2 rounded-full font-medium transition-all flex items-center space-x-2 cursor-pointer',
isAdminPath
? 'bg-secondary-100 dark:bg-secondary-900/30 text-secondary-700 dark:text-secondary-400'
: 'text-on-surface',
]"
>
<SettingOutlined />
<span>管理后台</span>
<DownOutlined class="text-xs" />
</a>
<template #overlay>
<a-menu>
<a-menu-item key="users" @click="router.push('/admin/users')">
<UserOutlined />
<span class="ml-2">用户管理</span>
</a-menu-item>
<a-menu-item key="templates" @click="router.push('/admin/templates')">
<FileOutlined />
<span class="ml-2">模板管理</span>
</a-menu-item>
<a-menu-item key="records" @click="router.push('/admin/records')">
<CheckSquareOutlined />
<span class="ml-2">打卡记录</span>
</a-menu-item>
<a-menu-item key="stats" @click="router.push('/admin/stats')">
<BarChartOutlined />
<span class="ml-2">统计信息</span>
</a-menu-item>
<a-menu-item key="logs" @click="router.push('/admin/logs')">
<FileTextOutlined />
<span class="ml-2">系统日志</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<!-- User Menu & Mobile Hamburger -->
<div class="flex items-center space-x-2 md:space-x-4">
<!-- Token Status Indicator (Desktop & Mobile) -->
<a-tooltip v-if="showTokenStatus" :title="tokenStatusTooltip">
<div
class="navbar-item px-2 md:px-3 py-1.5 rounded-full cursor-pointer transition-all flex items-center space-x-1 md:space-x-2"
@click="handleTokenStatusClick"
>
<a-badge :status="tokenBadgeStatus" />
<ClockCircleOutlined :class="[tokenIconClass, 'text-sm md:text-base']" />
<span class="text-xs md:text-sm hidden sm:inline">{{ tokenBadgeText }}</span>
<!-- 过期时显示刷新按钮响应式设计 -->
<a-button
v-if="remainingMinutes !== null && remainingMinutes < 0"
type="primary"
size="small"
class="!text-xs !px-2 md:!px-3"
@click.stop="handleRefreshToken"
>
<span class="hidden sm:inline">刷新</span>
<ReloadOutlined class="sm:hidden" />
</a-button>
</div>
</a-tooltip>
<!-- Theme Toggle Button -->
<a-tooltip :title="isDark ? '切换到亮色模式' : '切换到暗色模式'" placement="bottom">
<button
type="button"
class="navbar-item w-10 h-10 rounded-full flex items-center justify-center transition-all"
@click="toggleTheme"
>
<BulbFilled v-if="isDark" class="text-xl text-yellow-400" />
<BulbOutlined v-else class="text-xl text-on-surface" />
</button>
</a-tooltip>
<!-- Desktop User Menu -->
<a-dropdown v-if="!isMobile" :trigger="['hover']">
<a
class="navbar-item flex items-center space-x-3 px-4 py-2 rounded-full transition-all cursor-pointer"
>
<a-avatar :style="{ backgroundColor: '#f56a00' }">
{{ userInitial }}
</a-avatar>
<span class="hidden md:block font-medium text-on-surface">{{
authStore.user?.alias || '用户'
}}</span>
<DownOutlined class="text-xs text-on-surface-variant" />
</a>
<template #overlay>
<a-menu>
<a-menu-item key="info" disabled>
<div class="px-2 py-1">
<p class="text-sm font-medium text-on-surface">{{ authStore.user?.alias }}</p>
<p class="text-xs text-on-surface-variant mt-1">
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
</p>
</div>
</a-menu-item>
<a-menu-divider />
<a-menu-item key="settings" @click="router.push('/settings')">
<SettingOutlined />
<span class="ml-2">个人设置</span>
</a-menu-item>
<a-menu-item key="logout" danger @click="handleLogout">
<LogoutOutlined />
<span class="ml-2">退出登录</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<!-- Mobile Hamburger Button -->
<button
v-if="isMobile"
type="button"
class="navbar-item w-10 h-10 rounded-full flex items-center justify-center transition-all"
@click="drawerVisible = true"
>
<MenuOutlined class="text-xl text-on-surface" />
</button>
</div>
</div>
</nav>
<!-- Mobile Drawer -->
<a-drawer v-model:open="drawerVisible" placement="left" :width="280" title="菜单">
<!-- User Info in Drawer -->
<div class="mb-6 pb-4 border-b border-outline-variant">
<div class="flex items-center space-x-3">
<a-avatar :size="48" :style="{ backgroundColor: '#f56a00' }">
{{ userInitial }}
</a-avatar>
<div>
<p class="font-medium text-on-surface">{{ authStore.user?.alias || '用户' }}</p>
<p class="text-xs text-on-surface-variant">
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
</p>
</div>
</div>
</div>
<!-- Mobile Navigation Menu -->
<a-menu mode="inline" :selected-keys="[currentMenuKey]" @click="handleMenuClick">
<a-menu-item key="dashboard">
<template #icon><HomeOutlined /></template>
仪表盘
</a-menu-item>
<a-menu-item key="tasks">
<template #icon><FileTextOutlined /></template>
任务管理
</a-menu-item>
<a-menu-item key="records">
<template #icon><UnorderedListOutlined /></template>
打卡记录
</a-menu-item>
<!-- Admin Menu Group -->
<a-sub-menu v-if="authStore.isAdmin" key="admin">
<template #icon><SettingOutlined /></template>
<template #title>管理后台</template>
<a-menu-item key="admin-users">
<template #icon><UserOutlined /></template>
用户管理
</a-menu-item>
<a-menu-item key="admin-templates">
<template #icon><FileOutlined /></template>
模板管理
</a-menu-item>
<a-menu-item key="admin-records">
<template #icon><CheckSquareOutlined /></template>
打卡记录
</a-menu-item>
<a-menu-item key="admin-stats">
<template #icon><BarChartOutlined /></template>
统计信息
</a-menu-item>
<a-menu-item key="admin-logs">
<template #icon><FileTextOutlined /></template>
系统日志
</a-menu-item>
</a-sub-menu>
<a-menu-divider />
<a-menu-item key="settings">
<template #icon><SettingOutlined /></template>
个人设置
</a-menu-item>
<a-menu-item key="logout" danger>
<template #icon><LogoutOutlined /></template>
退出登录
</a-menu-item>
</a-menu>
</a-drawer>
<!-- Token 刷新 QR 码模态框 -->
<QRCodeModal
v-model:visible="qrcodeModalVisible"
:alias="authStore.user?.alias || ''"
@success="handleQRCodeSuccess"
@error="handleQRCodeError"
/>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { useUserStore } from '@/stores/user';
import { useTokenMonitor } from '@/composables/useTokenMonitor';
import { useBreakpoint } from '@/composables/useBreakpoint';
import { useTheme } from '@/composables/useTheme';
import { Modal, message } from 'ant-design-vue';
import QRCodeModal from './QRCodeModal.vue';
import {
MenuOutlined,
HomeOutlined,
FileTextOutlined,
UnorderedListOutlined,
SettingOutlined,
UserOutlined,
FileOutlined,
CheckSquareOutlined,
BarChartOutlined,
LogoutOutlined,
DownOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
BulbOutlined,
BulbFilled,
ReloadOutlined,
} from '@ant-design/icons-vue';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const userStore = useUserStore();
const { isMobile } = useBreakpoint();
const { getRemainingMinutes, tokenStatus, stopMonitoring } = useTokenMonitor();
const { isDark, toggleTheme } = useTheme();
const drawerVisible = ref(false);
const qrcodeModalVisible = ref(false);
const isAdminPath = computed(() => route.path.startsWith('/admin'));
const userInitial = computed(() => {
const name = authStore.user?.alias || 'U';
return name.charAt(0).toUpperCase();
});
// Token 状态计算
const remainingMinutes = computed(() => {
return getRemainingMinutes();
});
const showTokenStatus = computed(() => {
if (!authStore.isAuthenticated || !tokenStatus.value) return false;
const mins = remainingMinutes.value;
// 显示条件:Token 即将过期(60分钟内)或已过期(5分钟内)
if (mins === null) return false;
return mins <= 60 || (mins < 0 && Math.abs(mins) <= 5);
});
const tokenBadgeStatus = computed(() => {
const mins = remainingMinutes.value;
if (mins === null) return 'default';
if (mins < 0) return 'error'; // 已过期
if (mins <= 10) return 'error'; // 10分钟内过期
if (mins <= 30) return 'warning'; // 30分钟内过期
return 'processing'; // 正常但快过期
});
const tokenBadgeText = computed(() => {
const mins = remainingMinutes.value;
if (mins === null) return '';
if (mins < 0) return 'Token 已过期';
if (mins < 60) return `Token 剩余:${mins}分钟`;
return '';
});
const tokenIconClass = computed(() => {
const mins = remainingMinutes.value;
if (mins === null) return 'text-on-surface-variant';
if (mins < 0) return 'text-red-500 dark:text-red-400'; // 已过期
if (mins <= 10) return 'text-red-500 dark:text-red-400 animate-pulse'; // 10分钟内,闪烁
if (mins <= 30) return 'text-orange-500 dark:text-orange-400'; // 30分钟内
return 'text-blue-500 dark:text-blue-400'; // 正常
});
const tokenStatusTooltip = computed(() => {
const mins = remainingMinutes.value;
if (mins === null) return 'Token 状态未知';
if (mins < 0) {
const expiredMins = Math.abs(mins);
return `登录凭证已过期 ${expiredMins} 分钟,点击右侧按钮刷新`;
}
if (mins < 60) {
return `Token 剩余时间:${mins} 分钟,过期后可刷新`;
}
return 'Token 状态正常';
});
const handleTokenStatusClick = () => {
const mins = remainingMinutes.value;
// Token 已过期时提醒刷新
if (mins !== null && mins < 0) {
message.info({ content: 'Token 已过期,请进行刷新', duration: 3 });
}
// Token 未过期时,点击无效果
};
const currentMenuKey = computed(() => {
const path = route.path;
if (path.startsWith('/admin/users')) return 'admin-users';
if (path.startsWith('/admin/templates')) return 'admin-templates';
if (path.startsWith('/admin/records')) return 'admin-records';
if (path.startsWith('/admin/stats')) return 'admin-stats';
if (path.startsWith('/admin/logs')) return 'admin-logs';
if (path.startsWith('/dashboard')) return 'dashboard';
if (path.startsWith('/tasks')) return 'tasks';
if (path.startsWith('/records')) return 'records';
if (path.startsWith('/settings')) return 'settings';
return '';
});
const handleMenuClick = ({ key }) => {
const routes = {
dashboard: '/dashboard',
tasks: '/tasks',
records: '/records',
'admin-users': '/admin/users',
'admin-templates': '/admin/templates',
'admin-records': '/admin/records',
'admin-stats': '/admin/stats',
'admin-logs': '/admin/logs',
settings: '/settings',
};
if (key === 'logout') {
handleLogout();
} else if (routes[key]) {
router.push(routes[key]);
drawerVisible.value = false;
}
};
const handleLogout = () => {
Modal.confirm({
title: '提示',
content: '确定要退出登录吗?',
okText: '确定',
cancelText: '取消',
onOk() {
// 停止 token 监控
stopMonitoring();
// 清除登录状态
authStore.logout();
router.push('/login');
drawerVisible.value = false;
},
});
};
// 处理 Token 刷新
const handleRefreshToken = () => {
qrcodeModalVisible.value = true;
};
// 处理 QR 码扫码成功
const handleQRCodeSuccess = async () => {
message.success({ content: 'Token 刷新成功', duration: 3 });
qrcodeModalVisible.value = false;
// 刷新用户信息和 Token 状态
try {
await authStore.fetchCurrentUser();
await userStore.fetchTokenStatus();
} catch (error) {
console.error('刷新用户信息失败:', error);
}
};
// 处理 QR 码扫码失败
const handleQRCodeError = error => {
message.error({ content: error?.message || 'Token 刷新失败', duration: 4 });
};
</script>
@@ -0,0 +1,323 @@
<template>
<a-modal
v-model:open="dialogVisible"
title="QQ 扫码登录"
:width="isMobile ? '100%' : 400"
:style="isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : {}"
:mask-closable="false"
:footer="null"
@cancel="handleClose"
>
<div class="qrcode-container">
<!-- 加载中 -->
<div v-if="status === 'loading'" class="status-container">
<a-spin size="large" />
<p class="status-text">正在获取二维码...</p>
</div>
<!-- 显示二维码 -->
<div v-else-if="status === 'pending'" class="qrcode-wrapper">
<img :src="qrcodeUrl" alt="QR Code" class="qrcode-image" />
<p class="hint-text">请使用手机 QQ 扫描二维码登录</p>
<a-progress :percent="progress" :show-info="false" />
<p class="countdown-text">{{ countdown }}s</p>
</div>
<!-- 扫码成功 -->
<div v-else-if="status === 'success'" class="status-container">
<CheckCircleFilled class="status-icon success-icon" />
<p class="status-text success">登录成功</p>
</div>
<!-- 二维码过期 -->
<div v-else-if="status === 'expired'" class="status-container">
<WarningFilled class="status-icon warning-icon" />
<p class="status-text">二维码已过期</p>
<a-button type="primary" class="mt-4" @click="refreshQRCode">刷新二维码</a-button>
</div>
<!-- 失败 -->
<div v-else-if="status === 'failed'" class="status-container">
<CloseCircleFilled class="status-icon error-icon" />
<p class="status-text error">{{ errorMessage }}</p>
<a-button type="primary" class="mt-4" @click="refreshQRCode">重试</a-button>
</div>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useBreakpoint } from '@/composables/useBreakpoint';
import { usePollStatus } from '@/composables/usePollStatus';
import { message } from 'ant-design-vue';
import { CheckCircleFilled, WarningFilled, CloseCircleFilled } from '@ant-design/icons-vue';
const props = defineProps({
visible: {
type: Boolean,
required: true,
},
alias: {
type: String,
required: true,
},
});
const emit = defineEmits(['update:visible', 'success', 'error']);
const authStore = useAuthStore();
const { isMobile } = useBreakpoint();
// 使用轮询 composable
const { startPolling: startQRPolling, stopPolling } = usePollStatus({
interval: 2000,
maxRetries: 90, // 3分钟 = 180秒 / 2秒间隔 = 90次
backoff: false,
});
const dialogVisible = computed({
get: () => props.visible,
set: val => emit('update:visible', val),
});
const status = ref('loading'); // loading, pending, success, expired, failed
const qrcodeUrl = ref('');
const sessionId = ref('');
const errorMessage = ref('');
const countdown = ref(180); // 倒计时 3 分钟
const progress = ref(100);
let countdownTimer = null;
// 获取二维码
const fetchQRCode = async () => {
status.value = 'loading';
try {
const result = await authStore.loginWithQRCode(props.alias);
sessionId.value = result.session_id;
qrcodeUrl.value = `data:image/png;base64,${result.qrcode_base64}`;
status.value = 'pending';
// 开始轮询扫码状态(使用 composable
startQRPolling(
async () => {
const result = await authStore.checkQRCodeStatus(sessionId.value);
// 检查是否完成(成功、过期或失败)
const completed =
result.status === 'expired' || result.status === 'failed' || result.success;
return {
completed,
success: result.success === true,
data: result,
};
},
{
onSuccess: result => {
status.value = 'success';
stopCountdown();
message.success({ content: '登录成功!', duration: 2 });
// 延迟关闭对话框
setTimeout(() => {
emit('success', result.user);
handleClose();
}, 1500);
},
onFailure: result => {
if (result.status === 'expired') {
status.value = 'expired';
} else {
status.value = 'failed';
errorMessage.value = result.message || '扫码失败';
}
stopCountdown();
},
onTimeout: () => {
status.value = 'expired';
stopCountdown();
},
}
);
startCountdown();
} catch (error) {
status.value = 'failed';
errorMessage.value = error.message || '获取二维码失败';
emit('error', error);
}
};
// 开始倒计时
const startCountdown = () => {
countdown.value = 180;
if (countdownTimer) {
clearInterval(countdownTimer);
}
countdownTimer = setInterval(() => {
countdown.value--;
progress.value = (countdown.value / 180) * 100;
if (countdown.value <= 0) {
status.value = 'expired';
stopPolling(); // 停止轮询
stopCountdown();
}
}, 1000);
};
// 停止倒计时
const stopCountdown = () => {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
};
// 刷新二维码
const refreshQRCode = () => {
fetchQRCode();
};
// 关闭对话框
const handleClose = () => {
stopPolling(); // 停止轮询
stopCountdown();
// 如果有未完成的会话,取消它
if (sessionId.value && status.value !== 'success') {
try {
authStore.cancelQRCodeSession(sessionId.value);
} catch (error) {
console.error('取消会话失败:', error);
}
}
dialogVisible.value = false;
};
// 监听对话框显示状态
watch(
() => props.visible,
visible => {
if (visible) {
fetchQRCode();
} else {
stopPolling();
stopCountdown();
}
}
);
// 组件卸载时清理定时器,防止内存泄漏
onBeforeUnmount(() => {
stopPolling();
stopCountdown();
});
</script>
<style scoped>
.qrcode-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
min-height: 300px;
}
.status-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
}
.status-icon {
font-size: 60px;
}
.success-icon {
color: #4caf50;
}
.dark .success-icon {
color: #81c784;
}
.warning-icon {
color: #ff9800;
}
.dark .warning-icon {
color: #ffb74d;
}
.error-icon {
color: #f44336;
}
.dark .error-icon {
color: #ef5350;
}
.status-text {
margin-top: 20px;
font-size: 16px;
color: var(--md-sys-color-on-surface-variant);
}
.status-text.success {
color: #4caf50;
font-weight: bold;
}
.dark .status-text.success {
color: #81c784;
}
.status-text.error {
color: #f44336;
}
.dark .status-text.error {
color: #ef5350;
}
.qrcode-wrapper {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.qrcode-image {
width: 240px;
height: 240px;
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: 8px;
padding: 10px;
background-color: var(--md-sys-color-surface);
}
.hint-text {
margin-top: 20px;
font-size: 14px;
color: var(--md-sys-color-on-surface-variant);
}
.countdown-text {
margin-top: 10px;
font-size: 12px;
color: var(--md-sys-color-on-surface-variant);
}
.mt-4 {
margin-top: 16px;
}
</style>
@@ -0,0 +1,110 @@
<template>
<a-card class="md3-card text-center" style="padding: 48px 20px">
<!-- 图标 -->
<div v-if="icon" class="mb-6">
<component :is="icon" class="text-8xl mx-auto" :class="iconColorClass" />
</div>
<!-- 标题 -->
<h3 class="md3-title-large text-on-surface mb-2">
{{ title || '暂无数据' }}
</h3>
<!-- 描述 -->
<p class="md3-body-medium text-on-surface-variant mb-6">
{{ description || '当前没有内容可显示' }}
</p>
<!-- 操作按钮可选 -->
<div v-if="$slots.action || actionText">
<slot name="action">
<a-button v-if="actionText" type="primary" :loading="loading" @click="handleAction">
<template v-if="actionIcon" #icon>
<component :is="actionIcon" />
</template>
{{ actionText }}
</a-button>
</slot>
</div>
</a-card>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
/**
* 图标组件
*/
icon: {
type: Object,
default: null,
},
/**
* 标题文本
*/
title: {
type: String,
default: '',
},
/**
* 描述文本
*/
description: {
type: String,
default: '',
},
/**
* 操作按钮文本
*/
actionText: {
type: String,
default: '',
},
/**
* 操作按钮图标
*/
actionIcon: {
type: Object,
default: null,
},
/**
* 加载状态
*/
loading: {
type: Boolean,
default: false,
},
/**
* 图标颜色
*/
iconColor: {
type: String,
default: 'neutral',
validator: v => ['primary', 'neutral', 'success', 'warning', 'error'].includes(v),
},
});
const emit = defineEmits(['action']);
const handleAction = () => {
emit('action');
};
const iconColorClass = computed(() => {
const colors = {
primary: 'text-primary',
neutral: 'text-on-surface-variant',
success: 'text-green-500',
warning: 'text-orange-500',
error: 'text-error',
};
return colors[props.iconColor];
});
</script>
@@ -0,0 +1,87 @@
<template>
<div v-if="loading" class="loading-state">
<!-- 卡片骨架屏 -->
<div v-if="type === 'card'" class="grid grid-cols-1 gap-4">
<a-card v-for="i in count" :key="i" class="md3-card">
<a-skeleton :active="true" :paragraph="{ rows: paragraphRows }" :avatar="showAvatar" />
</a-card>
</div>
<!-- 列表骨架屏 -->
<div v-else-if="type === 'list'" class="space-y-4">
<a-card v-for="i in count" :key="i" class="md3-card">
<a-skeleton :active="true" :paragraph="{ rows: 1 }" :avatar="showAvatar" />
</a-card>
</div>
<!-- 表格骨架屏 -->
<a-card v-else-if="type === 'table'" class="md3-card">
<a-skeleton :active="true" :paragraph="{ rows: count * 2 }" />
</a-card>
<!-- 默认骨架屏 -->
<a-card v-else class="md3-card">
<a-skeleton :active="true" :paragraph="{ rows: paragraphRows }" :avatar="showAvatar" />
</a-card>
</div>
</template>
<script setup>
defineProps({
/**
* 是否显示加载状态
*/
loading: {
type: Boolean,
default: true,
},
/**
* 骨架屏类型
*/
type: {
type: String,
default: 'card',
validator: v => ['card', 'list', 'table', 'default'].includes(v),
},
/**
* 骨架屏数量
*/
count: {
type: Number,
default: 3,
},
/**
* 段落行数
*/
paragraphRows: {
type: Number,
default: 4,
},
/**
* 是否显示头像
*/
showAvatar: {
type: Boolean,
default: false,
},
});
</script>
<style scoped>
.loading-state {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>
@@ -0,0 +1,188 @@
<template>
<a-card
class="md3-card animate-slide-up transition-standard hover:elevation-3"
:style="{ animationDelay }"
>
<div class="flex items-center justify-between">
<!-- 数值和标签 -->
<div class="flex-1">
<p class="md3-label-medium text-on-surface-variant mb-1">{{ label }}</p>
<p class="md3-headline-medium" :class="valueColorClass">
{{ formattedValue }}
</p>
<p v-if="subtitle" class="md3-body-small text-on-surface-variant mt-1">
{{ subtitle }}
</p>
</div>
<!-- 图标 -->
<div
v-if="icon"
class="w-12 h-12 rounded-md3 flex items-center justify-center flex-shrink-0 ml-4"
:class="iconBgClass"
>
<component :is="icon" :class="iconColorClass" class="text-2xl" />
</div>
</div>
<!-- 趋势指示器可选 -->
<div v-if="trend !== undefined" class="mt-3 pt-3 border-t border-outline-variant">
<div class="flex items-center text-sm">
<component :is="trendIcon" :class="trendColorClass" class="mr-1" />
<span :class="trendColorClass" class="md3-label-small">
{{ trendText }}
</span>
</div>
</div>
</a-card>
</template>
<script setup>
import { computed } from 'vue';
import { ArrowUpOutlined, ArrowDownOutlined, MinusOutlined } from '@ant-design/icons-vue';
const props = defineProps({
/**
* 卡片标签
*/
label: {
type: String,
required: true,
},
/**
* 显示的数值
*/
value: {
type: [String, Number],
required: true,
},
/**
* 副标题/描述
*/
subtitle: {
type: String,
default: '',
},
/**
* 图标组件
*/
icon: {
type: Object,
default: null,
},
/**
* 颜色主题
*/
color: {
type: String,
default: 'primary',
validator: v => ['primary', 'success', 'warning', 'error', 'info', 'neutral'].includes(v),
},
/**
* 动画延迟(秒)
*/
delay: {
type: Number,
default: 0,
},
/**
* 格式化函数
*/
formatter: {
type: Function,
default: null,
},
/**
* 趋势值(正数上升,负数下降,0持平)
*/
trend: {
type: Number,
default: undefined,
},
/**
* 趋势文本
*/
trendText: {
type: String,
default: '',
},
});
// 动画延迟
const animationDelay = computed(() => `${props.delay}s`);
// 格式化数值
const formattedValue = computed(() => {
if (props.formatter) {
return props.formatter(props.value);
}
return props.value;
});
// 颜色映射
const colorClasses = {
primary: {
value: 'text-primary',
iconBg: 'bg-primary-100 dark:bg-primary-900/30',
icon: 'text-primary',
},
success: {
value: 'text-green-600 dark:text-green-400',
iconBg: 'bg-green-100 dark:bg-green-900/30',
icon: 'text-green-600 dark:text-green-400',
},
warning: {
value: 'text-orange-600 dark:text-orange-400',
iconBg: 'bg-orange-100 dark:bg-orange-900/30',
icon: 'text-orange-600 dark:text-orange-400',
},
error: {
value: 'text-error',
iconBg: 'bg-red-100 dark:bg-red-900/30',
icon: 'text-error',
},
info: {
value: 'text-secondary',
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
icon: 'text-secondary',
},
neutral: {
value: 'text-on-surface',
iconBg: 'bg-surface-container',
icon: 'text-on-surface-variant',
},
};
const valueColorClass = computed(() => colorClasses[props.color].value);
const iconBgClass = computed(() => colorClasses[props.color].iconBg);
const iconColorClass = computed(() => colorClasses[props.color].icon);
// 趋势图标和颜色
const trendIcon = computed(() => {
if (props.trend === undefined) return null;
if (props.trend > 0) return ArrowUpOutlined;
if (props.trend < 0) return ArrowDownOutlined;
return MinusOutlined;
});
const trendColorClass = computed(() => {
if (props.trend === undefined) return '';
if (props.trend > 0) return 'text-green-600 dark:text-green-400';
if (props.trend < 0) return 'text-red-600 dark:text-red-400';
return 'text-on-surface-variant';
});
</script>
<style scoped>
.md3-card:hover {
transform: translateY(-2px);
}
</style>
@@ -0,0 +1,84 @@
/**
* 通用异步操作 Composable
* 统一处理 loading、error 状态和消息提示
*
* @example
* const { loading, error, execute } = useAsyncAction()
*
* const handleSubmit = async () => {
* await execute(
* () => api.createTask(formData),
* { successMsg: '创建成功', errorMsg: '创建失败' }
* )
* }
*/
import { ref } from 'vue';
import { message } from 'ant-design-vue';
export function useAsyncAction(options = {}) {
const loading = ref(false);
const error = ref(null);
/**
* 执行异步操作
* @param {Function} asyncFn - 异步函数
* @param {Object} config - 配置选项
* @param {string} config.successMsg - 成功提示消息
* @param {string} config.errorMsg - 错误提示消息
* @param {boolean} config.throwOnError - 是否抛出错误
* @param {boolean} config.silent - 是否静默模式(不显示消息)
* @returns {Promise} 异步函数的返回值
*/
const execute = async (asyncFn, config = {}) => {
const {
successMsg = options.successMsg,
errorMsg = options.errorMsg,
throwOnError = false,
silent = false,
} = config;
loading.value = true;
error.value = null;
try {
const result = await asyncFn();
if (!silent && successMsg) {
message.success({ content: successMsg, duration: 3 });
}
return result;
} catch (err) {
error.value = err;
if (!silent) {
const msg = err.message || err.detail || errorMsg || '操作失败';
message.error({ content: msg, duration: 4 });
}
if (throwOnError) {
throw err;
}
return null;
} finally {
loading.value = false;
}
};
/**
* 重置状态
*/
const reset = () => {
loading.value = false;
error.value = null;
};
return {
loading,
error,
execute,
reset,
};
}
@@ -0,0 +1,65 @@
import { ref, onMounted, onUnmounted } from 'vue';
/**
* 响应式断点检测 Composable
* 基于 Ant Design 的断点系统
* - xs: <576px (手机)
* - sm: ≥576px (平板竖屏)
* - md: ≥768px (平板横屏)
* - lg: ≥992px (桌面)
* - xl: ≥1200px (大屏)
* - xxl: ≥1600px (超大屏)
*/
export function useBreakpoint() {
const isMobile = ref(window.innerWidth < 768);
const isTablet = ref(window.innerWidth >= 768 && window.innerWidth < 992);
const isDesktop = ref(window.innerWidth >= 992);
// Ant Design 断点
const isXs = ref(window.innerWidth < 576);
const isSm = ref(window.innerWidth >= 576 && window.innerWidth < 768);
const isMd = ref(window.innerWidth >= 768 && window.innerWidth < 992);
const isLg = ref(window.innerWidth >= 992 && window.innerWidth < 1200);
const isXl = ref(window.innerWidth >= 1200 && window.innerWidth < 1600);
const isXxl = ref(window.innerWidth >= 1600);
const updateBreakpoints = () => {
const width = window.innerWidth;
// 简化断点
isMobile.value = width < 768;
isTablet.value = width >= 768 && width < 992;
isDesktop.value = width >= 992;
// Ant Design 断点
isXs.value = width < 576;
isSm.value = width >= 576 && width < 768;
isMd.value = width >= 768 && width < 992;
isLg.value = width >= 992 && width < 1200;
isXl.value = width >= 1200 && width < 1600;
isXxl.value = width >= 1600;
};
onMounted(() => {
window.addEventListener('resize', updateBreakpoints);
});
onUnmounted(() => {
window.removeEventListener('resize', updateBreakpoints);
});
return {
// 简化断点(常用)
isMobile,
isTablet,
isDesktop,
// Ant Design 断点(详细)
isXs,
isSm,
isMd,
isLg,
isXl,
isXxl,
};
}
@@ -0,0 +1,124 @@
/**
* 状态轮询 Composable
* 支持指数退避、最大重试次数、自动清理
*
* @example
* const { polling, startPolling, stopPolling } = usePollStatus({
* interval: 2000,
* maxRetries: 15,
* backoff: true
* })
*
* startPolling(
* async () => {
* const status = await api.getStatus(id)
* return {
* completed: status.status !== 'pending',
* success: status.status === 'success',
* data: status
* }
* },
* {
* onSuccess: (result) => console.log('完成', result),
* onFailure: (error) => console.error('失败', error),
* onTimeout: () => console.warn('超时')
* }
* )
*/
import { ref, onUnmounted } from 'vue';
export function usePollStatus(options = {}) {
const {
interval = 2000, // 初始轮询间隔(毫秒)
maxRetries = 15, // 最大重试次数
backoff = false, // 是否使用指数退避
maxBackoffInterval = 10000, // 最大退避间隔(毫秒)
} = options;
const polling = ref(false);
let pollTimer = null;
let retryCount = 0;
/**
* 开始轮询
* @param {Function} checkFn - 检查函数,应返回 { completed, success, data }
* @param {Object} callbacks - 回调函数
* @param {Function} callbacks.onSuccess - 成功回调
* @param {Function} callbacks.onFailure - 失败回调
* @param {Function} callbacks.onTimeout - 超时回调
*/
const startPolling = async (checkFn, callbacks = {}) => {
const { onSuccess, onFailure, onTimeout } = callbacks;
// 重置状态
stopPolling();
polling.value = true;
retryCount = 0;
const poll = async () => {
try {
const result = await checkFn();
// 检查是否完成
if (result.completed) {
stopPolling();
if (result.success) {
onSuccess?.(result.data || result);
} else {
onFailure?.(result.data || result);
}
return;
}
// 检查是否超时
retryCount++;
if (retryCount >= maxRetries) {
stopPolling();
onTimeout?.();
return;
}
// 计算下次轮询间隔(支持指数退避)
let nextInterval = interval;
if (backoff) {
// 指数退避:2s -> 4s -> 8s -> 最大10s
nextInterval = Math.min(interval * Math.pow(2, retryCount - 1), maxBackoffInterval);
}
// 继续轮询
pollTimer = setTimeout(poll, nextInterval);
} catch (error) {
stopPolling();
onFailure?.(error);
}
};
// 立即执行第一次检查
poll();
};
/**
* 停止轮询
*/
const stopPolling = () => {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
polling.value = false;
retryCount = 0;
};
// 组件卸载时自动清理
onUnmounted(() => {
stopPolling();
});
return {
polling,
startPolling,
stopPolling,
};
}
+106
View File
@@ -0,0 +1,106 @@
import { ref, computed } from 'vue';
const THEME_STORAGE_KEY = 'checkin-app-theme';
// 全局主题状态(单例模式)
const theme = ref('light');
/**
* 应用主题到 DOM
*/
const applyTheme = newTheme => {
const html = document.documentElement;
if (newTheme === 'dark') {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
};
/**
* 初始化主题
* 优先级: localStorage > 系统偏好 > 默认亮色
*/
export const initTheme = () => {
// 1. 尝试从 localStorage 读取
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (savedTheme === 'light' || savedTheme === 'dark') {
theme.value = savedTheme;
applyTheme(savedTheme);
return;
}
// 2. 检测系统偏好
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
theme.value = 'dark';
applyTheme('dark');
return;
}
// 3. 默认亮色
theme.value = 'light';
applyTheme('light');
};
/**
* 监听系统主题变化
*/
export const watchSystemTheme = () => {
if (!window.matchMedia) return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = e => {
// 仅在用户未手动设置主题时才跟随系统
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (!savedTheme) {
const systemTheme = e.matches ? 'dark' : 'light';
theme.value = systemTheme;
applyTheme(systemTheme);
}
};
mediaQuery.addEventListener('change', handleChange);
// 返回清理函数
return () => mediaQuery.removeEventListener('change', handleChange);
};
/**
* 主题管理 Composable
* 支持亮色/暗色模式切换,并持久化到 localStorage
*/
export function useTheme() {
/**
* 切换主题
*/
const toggleTheme = () => {
const newTheme = theme.value === 'light' ? 'dark' : 'light';
theme.value = newTheme;
applyTheme(newTheme);
localStorage.setItem(THEME_STORAGE_KEY, newTheme);
};
/**
* 设置指定主题
*/
const setTheme = newTheme => {
if (newTheme !== 'light' && newTheme !== 'dark') {
console.warn(`Invalid theme: ${newTheme}. Using 'light' instead.`);
newTheme = 'light';
}
theme.value = newTheme;
applyTheme(newTheme);
localStorage.setItem(THEME_STORAGE_KEY, newTheme);
};
return {
theme,
toggleTheme,
setTheme,
isDark: computed(() => theme.value === 'dark'),
isLight: computed(() => theme.value === 'light'),
};
}
@@ -0,0 +1,190 @@
import { computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { useAuthStore } from '@/stores/auth';
import { useUserStore } from '@/stores/user';
import { useRouter } from 'vue-router';
/**
* Token 过期监控 Composable
*
* 功能:
* 1. 定时检查 Token 状态
* 2. Token 过期后 5 分钟内提醒用户
* 3. 为有密码的用户提供友好的过期处理
*
* 注意:使用单例模式,确保全局只有一个监控实例
*/
// 全局单例:确保整个应用只有一个监控实例
let monitorTimer = null;
let warningShown = false;
let isMonitoring = false; // 新增:防止重复启动
// 检查间隔(毫秒)
const NORMAL_CHECK_INTERVAL = 15 * 60 * 1000; // 正常情况:15 分钟
const URGENT_CHECK_INTERVAL = 5 * 60 * 1000; // Token 即将过期:5 分钟
export function useTokenMonitor() {
const authStore = useAuthStore();
const userStore = useUserStore();
const router = useRouter();
const tokenStatus = computed(() => userStore.tokenStatus);
const hasPassword = computed(() => authStore.user?.has_password || false);
// 计算 Token 剩余分钟数
const getRemainingMinutes = () => {
if (!tokenStatus.value?.expires_at) return null;
const now = Math.floor(Date.now() / 1000);
const expiresAt = tokenStatus.value.expires_at;
const diffSeconds = expiresAt - now;
return Math.floor(diffSeconds / 60);
};
// 检查 Token 状态并显示提醒
const checkTokenStatus = async () => {
// 如果未登录,不检查
if (!authStore.isAuthenticated) {
return;
}
try {
// 获取最新的 Token 状态
await userStore.fetchTokenStatus();
const remainingMinutes = getRemainingMinutes();
// Token 已过期(负数分钟)
if (remainingMinutes !== null && remainingMinutes < 0) {
const expiredMinutes = Math.abs(remainingMinutes);
// Token 过期后 5 分钟内提醒
if (expiredMinutes <= 5) {
if (hasPassword.value) {
// 有密码的用户:友好提示
if (!warningShown) {
message.warning({
content: `您的 Token 已过期 ${expiredMinutes} 分钟,部分功能可能受限。建议您扫码刷新凭证。`,
duration: 3,
key: 'token-expired-warning',
});
warningShown = true;
}
} else {
// 没有密码的用户:必须重新登录
message.error({
content: '您的 Token 已过期,请重新扫码登录',
duration: 5,
key: 'token-expired-error',
});
// 清除登录状态并跳转
authStore.logout();
router.push('/login');
}
} else if (expiredMinutes > 5) {
// 过期超过 5 分钟
if (!hasPassword.value) {
// 没有密码的用户:强制退出
authStore.logout();
router.push('/login');
}
}
}
// Token 即将过期(1小时内)
else if (remainingMinutes !== null && remainingMinutes > 0 && remainingMinutes <= 60) {
if (!warningShown) {
message.warning({
content: `您的 Token 将在 ${remainingMinutes} 分钟后过期,建议您及时刷新`,
duration: 3,
key: 'token-expiring-warning',
});
warningShown = true;
}
// Token 即将过期时,切换到更频繁的检查(5 分钟)
adjustCheckInterval(URGENT_CHECK_INTERVAL);
}
// Token 状态正常
else if (remainingMinutes !== null && remainingMinutes > 60) {
// 重置警告标志
warningShown = false;
// 恢复正常检查频率(15 分钟)
adjustCheckInterval(NORMAL_CHECK_INTERVAL);
}
} catch (error) {
console.error('检查 Token 状态失败:', error);
}
};
// 调整检查间隔
const adjustCheckInterval = newInterval => {
if (monitorTimer) {
const currentInterval = monitorTimer._idleTimeout || 0;
// 只有当新间隔与当前间隔不同时才重启定时器
if (currentInterval !== newInterval) {
clearInterval(monitorTimer);
monitorTimer = setInterval(() => {
checkTokenStatus();
}, newInterval);
}
}
};
// 启动监控
const startMonitoring = () => {
// 避免重复启动(单例模式)
if (isMonitoring || monitorTimer) {
return;
}
isMonitoring = true;
// 立即检查一次
checkTokenStatus();
// 默认使用正常检查频率(15 分钟)
monitorTimer = setInterval(() => {
checkTokenStatus();
}, NORMAL_CHECK_INTERVAL);
};
// 停止监控
const stopMonitoring = () => {
if (monitorTimer) {
clearInterval(monitorTimer);
monitorTimer = null;
}
isMonitoring = false;
warningShown = false;
};
// 手动触发检查
const checkNow = () => {
warningShown = false; // 重置警告标志,允许再次显示
checkTokenStatus();
};
// 组件挂载时启动监控
onMounted(() => {
if (authStore.isAuthenticated) {
startMonitoring();
}
});
// 组件卸载时不停止监控(因为是全局单例)
// onUnmounted 中不调用 stopMonitoring(),让监控持续运行
return {
tokenStatus,
hasPassword,
startMonitoring,
stopMonitoring,
checkNow,
getRemainingMinutes,
};
}
+53
View File
@@ -0,0 +1,53 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
// Ant Design Vue
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import App from './App.vue';
import router from './router';
import './style.css';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
// Ant Design Vue
app.use(Antd);
// 全局未捕获的 Promise 错误处理
window.addEventListener('unhandledrejection', event => {
console.error('未捕获的 Promise 错误:', event.reason);
// 显示用户友好的错误提示
const errorMessage = event.reason?.message || event.reason || '操作失败';
// 只对非网络错误显示提示(网络错误已在 axios 拦截器中处理)
if (!errorMessage.includes('网络错误') && !errorMessage.includes('请求超时')) {
message.error({
content: `操作失败: ${errorMessage}`,
duration: 3,
});
}
// 阻止默认的控制台错误输出(已经用 console.error 输出了)
event.preventDefault();
});
// 全局错误处理(捕获 Vue 组件内的错误)
app.config.errorHandler = (err, instance, info) => {
console.error('Vue 错误:', err);
console.error('错误信息:', info);
console.error('组件实例:', instance);
// 显示用户友好的错误提示
message.error({
content: '应用发生错误,请刷新页面重试',
duration: 3,
});
};
app.mount('#app');
+162
View File
@@ -0,0 +1,162 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { userAPI } from '@/api';
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/LoginView.vue'),
meta: { requiresAuth: false, title: '登录' },
},
{
path: '/',
redirect: '/dashboard',
},
{
path: '/pending-approval',
name: 'PendingApproval',
component: () => import('@/views/PendingApprovalView.vue'),
meta: { requiresAuth: true, title: '等待审批' },
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { requiresAuth: true, title: '我的仪表盘' },
},
{
path: '/tasks',
name: 'Tasks',
component: () => import('@/views/TasksView.vue'),
meta: { requiresAuth: true, title: '任务管理' },
},
{
path: '/tasks/:taskId/records',
name: 'TaskRecords',
component: () => import('@/views/TaskRecordsView.vue'),
meta: { requiresAuth: true, title: '任务打卡记录' },
},
{
path: '/records',
name: 'Records',
component: () => import('@/views/RecordsView.vue'),
meta: { requiresAuth: true, title: '打卡记录' },
},
{
path: '/settings',
name: 'Settings',
component: () => import('@/views/SettingsView.vue'),
meta: { requiresAuth: true, title: '个人设置' },
},
{
path: '/admin',
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{
path: 'users',
name: 'AdminUsers',
component: () => import('@/views/admin/UsersView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: '用户管理' },
},
{
path: 'records',
name: 'AdminRecords',
component: () => import('@/views/admin/RecordsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: '打卡记录' },
},
{
path: 'logs',
name: 'AdminLogs',
component: () => import('@/views/admin/LogsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: '系统日志' },
},
{
path: 'stats',
name: 'AdminStats',
component: () => import('@/views/admin/StatsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: '统计信息' },
},
{
path: 'templates',
name: 'AdminTemplates',
component: () => import('@/views/admin/TemplatesView.vue'),
meta: { requiresAuth: true, requiresAdmin: true, title: '模板管理' },
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFoundView.vue'),
meta: { requiresAuth: false, title: '页面未找到' },
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
// 设置页面标题
document.title = to.meta.title ? `${to.meta.title} - 接龙自动打卡系统` : '接龙自动打卡系统';
// 检查是否需要认证
if (to.meta.requiresAuth) {
if (!authStore.isAuthenticated) {
// 未登录,重定向到登录页
next({ name: 'Login', query: { redirect: to.fullPath } });
return;
}
// 检查用户审批状态(除了待审批页面本身)
if (to.name !== 'PendingApproval') {
try {
const status = await userAPI.getUserStatus();
if (!status.is_approved) {
// 未审批用户只能访问待审批页面
next({ name: 'PendingApproval' });
return;
}
} catch (error) {
console.error('检查审批状态失败:', error);
// 如果检查失败,允许继续访问(避免阻塞正常用户)
}
} else {
// 访问待审批页面时,检查是否已审批
try {
const status = await userAPI.getUserStatus();
if (status.is_approved) {
// 已审批用户不能访问待审批页面
next({ name: 'Dashboard' });
return;
}
} catch (error) {
console.error('检查审批状态失败:', error);
}
}
// 检查是否需要管理员权限
if (to.meta.requiresAdmin && !authStore.isAdmin) {
// 非管理员,重定向到仪表盘
next({ name: 'Dashboard' });
return;
}
} else {
// 不需要认证的页面,如果已登录则重定向到仪表盘
if (to.name === 'Login' && authStore.isAuthenticated) {
next({ name: 'Dashboard' });
return;
}
}
next();
});
export default router;
+62
View File
@@ -0,0 +1,62 @@
import { defineStore } from 'pinia';
import { adminAPI } from '@/api';
export const useAdminStore = defineStore('admin', {
state: () => ({
stats: null, // 系统统计信息
logs: [],
logsTotal: 0,
loading: false,
}),
getters: {
totalUsers: state => state.stats?.users?.total || 0,
activeUsers: state => {
// Active users = 已审批的用户(is_approved=true
return state.stats?.users?.active || 0;
},
totalRecords: state => state.stats?.check_in_records?.total || 0,
todayRecords: state => state.stats?.check_in_records?.today || 0,
},
actions: {
// 获取系统统计信息
async fetchStats() {
this.loading = true;
try {
const stats = await adminAPI.getStats();
this.stats = stats;
return stats;
} catch (error) {
throw new Error(error.message || '获取统计信息失败');
} finally {
this.loading = false;
}
},
// 批量触发打卡
async batchCheckIn(userIds) {
try {
const result = await adminAPI.batchCheckIn(userIds);
return result;
} catch (error) {
throw new Error(error.message || '批量打卡失败');
}
},
// 获取系统日志
async fetchLogs(params = {}) {
this.loading = true;
try {
const data = await adminAPI.getLogs(params);
this.logs = data.logs || data;
this.logsTotal = data.total || this.logs.length;
return data;
} catch (error) {
throw new Error(error.message || '获取日志失败');
} finally {
this.loading = false;
}
},
},
});
+133
View File
@@ -0,0 +1,133 @@
import { defineStore } from 'pinia';
import { authAPI, userAPI } from '@/api';
export const useAuthStore = defineStore('auth', {
state: () => {
// 安全地解析 localStorage 中的用户数据
let user = null;
try {
const userStr = localStorage.getItem('user');
if (userStr && userStr !== 'undefined' && userStr !== 'null') {
user = JSON.parse(userStr);
}
} catch (e) {
console.warn('Failed to parse user from localStorage:', e);
localStorage.removeItem('user');
}
return {
token: localStorage.getItem('token') || null,
user,
};
},
getters: {
// 将 isAuthenticated 改为 getter,这样它会实时反应 state.token 的变化
isAuthenticated: state => !!state.token,
isAdmin: state => state.user?.role === 'admin',
},
actions: {
// 设置认证信息
setAuth(token, user) {
// 清理 token:移除 URL 编码的 Bearer 前缀
let cleanToken = token;
if (cleanToken) {
// URL 解码
cleanToken = decodeURIComponent(cleanToken);
// 移除 Bearer 前缀(如果存在)
if (cleanToken.toLowerCase().startsWith('bearer ')) {
cleanToken = cleanToken.substring(7);
}
}
this.token = cleanToken;
this.user = user;
localStorage.setItem('token', cleanToken);
localStorage.setItem('user', JSON.stringify(user));
},
// 清除认证信息
clearAuth() {
this.token = null;
this.user = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
},
// QR 码登录流程
async loginWithQRCode(alias) {
try {
// 1. 请求 QR 码
const qrData = await authAPI.requestQRCode(alias);
const { session_id, qrcode_base64 } = qrData;
// 2. 返回 session_id 和 qrcode,由组件处理轮询
return { session_id, qrcode_base64 };
} catch (error) {
throw new Error(error.message || '请求二维码失败');
}
},
// 检查扫码状态
async checkQRCodeStatus(sessionId) {
try {
const result = await authAPI.getQRCodeStatus(sessionId);
if (result.status === 'success') {
// 扫码成功,保存 Token 和用户信息
this.setAuth(result.token, result.user);
return { success: true, user: result.user };
} else if (result.status === 'failed') {
return { success: false, message: result.message };
} else {
// pending 或 expired
return { success: false, status: result.status };
}
} catch (error) {
throw new Error(error.message || '检查扫码状态失败');
}
},
// 取消扫码会话
async cancelQRCodeSession(sessionId) {
try {
await authAPI.cancelQRCodeSession(sessionId);
} catch (error) {
console.error('取消会话失败:', error);
}
},
// 验证 Token
async verifyToken(token) {
try {
const userData = await authAPI.verifyToken(token);
this.setAuth(token, userData);
return userData;
} catch (error) {
this.clearAuth();
throw new Error(error.message || 'Token 验证失败');
}
},
// 获取当前用户信息
async fetchCurrentUser() {
try {
const userData = await userAPI.getCurrentUser();
// 更新本地用户信息
this.user = userData;
localStorage.setItem('user', JSON.stringify(userData));
return userData;
} catch (error) {
throw new Error(error.message || '获取用户信息失败');
}
},
// 登出
logout() {
this.clearAuth();
},
},
});
+96
View File
@@ -0,0 +1,96 @@
import { defineStore } from 'pinia';
import { checkInAPI } from '@/api';
export const useCheckInStore = defineStore('checkIn', {
state: () => ({
myRecords: [],
allRecords: [], // 管理员查看所有记录
currentPage: 1,
pageSize: 20,
total: 0,
loading: false,
}),
getters: {
todayRecords: state => {
const today = new Date().toISOString().split('T')[0];
return state.myRecords.filter(record => {
const recordDate = new Date(record.check_in_time).toISOString().split('T')[0];
return recordDate === today;
});
},
successRate: state => {
if (state.myRecords.length === 0) return 0;
const successCount = state.myRecords.filter(r => r.status === 'success').length;
return ((successCount / state.myRecords.length) * 100).toFixed(2);
},
},
actions: {
// 手动打卡
async manualCheckIn() {
this.loading = true;
try {
const result = await checkInAPI.manualCheckIn();
// 刷新打卡记录
await this.fetchMyRecords();
return result;
} catch (error) {
throw new Error(error.message || '打卡失败');
} finally {
this.loading = false;
}
},
// 获取我的打卡记录
async fetchMyRecords(params = {}) {
this.loading = true;
try {
const data = await checkInAPI.getMyRecords({
skip: (this.currentPage - 1) * this.pageSize,
limit: this.pageSize,
...params,
});
// 后端现在返回 { records, total, skip, limit }
this.myRecords = data.records || data;
this.total = data.total || 0;
return data;
} catch (error) {
throw new Error(error.message || '获取打卡记录失败');
} finally {
this.loading = false;
}
},
// 获取所有打卡记录(管理员)
async fetchAllRecords(params = {}) {
this.loading = true;
try {
const data = await checkInAPI.getAllRecords({
skip: (this.currentPage - 1) * this.pageSize,
limit: this.pageSize,
...params,
});
// 后端现在返回 { records, total, skip, limit }
this.allRecords = data.records || data;
this.total = data.total || 0;
return data;
} catch (error) {
throw new Error(error.message || '获取打卡记录失败');
} finally {
this.loading = false;
}
},
// 统计打卡记录
async getRecordsCount(params = {}) {
try {
const count = await checkInAPI.getRecordsCount(params);
return count;
} catch (error) {
throw new Error(error.message || '获取统计信息失败');
}
},
},
});
+164
View File
@@ -0,0 +1,164 @@
import { defineStore } from 'pinia';
import api from '@/api';
export const useTaskStore = defineStore('task', {
state: () => ({
tasks: [], // 当前用户的任务列表
currentTask: null, // 当前选中的任务
loading: false,
error: null,
}),
getters: {
// 启用的任务
activeTasks: state => state.tasks.filter(t => t.is_active),
// 禁用的任务
inactiveTasks: state => state.tasks.filter(t => !t.is_active),
// 任务数量统计
taskStats: state => ({
total: state.tasks.length,
active: state.tasks.filter(t => t.is_active).length,
inactive: state.tasks.filter(t => !t.is_active).length,
}),
// 根据 ID 获取任务
getTaskById: state => taskId => {
return state.tasks.find(t => t.id === taskId);
},
},
actions: {
// 获取当前用户的所有任务
async fetchMyTasks(includeInactive = true) {
this.loading = true;
this.error = null;
try {
const tasks = await api.task.getMyTasks({ include_inactive: includeInactive });
this.tasks = tasks;
return tasks;
} catch (error) {
this.error = error.message || '获取任务列表失败';
throw error;
} finally {
this.loading = false;
}
},
// 更新任务
async updateTask(taskId, taskData) {
this.loading = true;
this.error = null;
try {
const updatedTask = await api.task.updateTask(taskId, taskData);
const index = this.tasks.findIndex(t => t.id === taskId);
if (index !== -1) {
this.tasks[index] = updatedTask;
}
return updatedTask;
} catch (error) {
this.error = error.message || '更新任务失败';
throw error;
} finally {
this.loading = false;
}
},
// 删除任务
async deleteTask(taskId) {
this.loading = true;
this.error = null;
try {
await api.task.deleteTask(taskId);
this.tasks = this.tasks.filter(t => t.id !== taskId);
} catch (error) {
this.error = error.message || '删除任务失败';
throw error;
} finally {
this.loading = false;
}
},
// 切换任务启用状态
async toggleTask(taskId) {
this.loading = true;
this.error = null;
try {
const updatedTask = await api.task.toggleTask(taskId);
const index = this.tasks.findIndex(t => t.id === taskId);
if (index !== -1) {
// 保留原任务的 last_check_in_time 和 last_check_in_status
const originalTask = this.tasks[index];
this.tasks[index] = {
...updatedTask,
last_check_in_time: updatedTask.last_check_in_time || originalTask.last_check_in_time,
last_check_in_status:
updatedTask.last_check_in_status || originalTask.last_check_in_status,
};
}
return updatedTask;
} catch (error) {
this.error = error.message || '切换任务状态失败';
throw error;
} finally {
this.loading = false;
}
},
// 获取任务详情
async fetchTask(taskId) {
this.loading = true;
this.error = null;
try {
const task = await api.task.getTask(taskId);
this.currentTask = task;
return task;
} catch (error) {
this.error = error.message || '获取任务详情失败';
throw error;
} finally {
this.loading = false;
}
},
// 手动触发任务打卡(异步方式,立即返回 record_id)
async checkInTask(taskId) {
// Don't set global loading state to avoid blocking UI during long check-in operations
this.error = null;
try {
const result = await api.task.checkInTask(taskId);
return result;
} catch (error) {
this.error = error.message || '打卡失败';
throw error;
}
},
// 查询打卡记录状态
async getCheckInRecordStatus(recordId) {
const result = await api.task.getCheckInRecordStatus(recordId);
return result;
},
// 获取任务的打卡记录
async fetchTaskRecords(taskId, params = {}) {
this.loading = true;
this.error = null;
try {
const records = await api.task.getTaskRecords(taskId, params);
return records;
} catch (error) {
this.error = error.message || '获取打卡记录失败';
throw error;
} finally {
this.loading = false;
}
},
// 清空当前任务
clearCurrentTask() {
this.currentTask = null;
},
},
});
+169
View File
@@ -0,0 +1,169 @@
import { defineStore } from 'pinia';
import { templateAPI } from '@/api';
export const useTemplateStore = defineStore('template', {
state: () => ({
templates: [],
currentTemplate: null,
loading: false,
error: null,
}),
getters: {
activeTemplates: state => state.templates.filter(t => t.is_active),
getTemplateById: state => id => {
return state.templates.find(t => t.id === id);
},
},
actions: {
async fetchTemplates(isActive = null) {
this.loading = true;
this.error = null;
try {
const params = {};
if (isActive !== null) {
params.is_active = isActive;
}
this.templates = await templateAPI.getTemplates(params);
return this.templates;
} catch (error) {
this.error = error.message || '获取模板列表失败';
throw error;
} finally {
this.loading = false;
}
},
async fetchActiveTemplates() {
this.loading = true;
this.error = null;
try {
this.templates = await templateAPI.getActiveTemplates();
return this.templates;
} catch (error) {
this.error = error.message || '获取启用模板失败';
throw error;
} finally {
this.loading = false;
}
},
async fetchTemplate(id) {
this.loading = true;
this.error = null;
try {
this.currentTemplate = await templateAPI.getTemplate(id);
return this.currentTemplate;
} catch (error) {
this.error = error.message || '获取模板详情失败';
throw error;
} finally {
this.loading = false;
}
},
async previewTemplate(id) {
this.loading = true;
this.error = null;
try {
const preview = await templateAPI.previewTemplate(id);
return preview;
} catch (error) {
this.error = error.message || '预览模板失败';
throw error;
} finally {
this.loading = false;
}
},
async createTemplate(templateData) {
this.loading = true;
this.error = null;
try {
const newTemplate = await templateAPI.createTemplate(templateData);
this.templates.unshift(newTemplate);
return newTemplate;
} catch (error) {
this.error = error.message || '创建模板失败';
throw error;
} finally {
this.loading = false;
}
},
async updateTemplate(id, templateData) {
this.loading = true;
this.error = null;
try {
const updatedTemplate = await templateAPI.updateTemplate(id, templateData);
const index = this.templates.findIndex(t => t.id === id);
if (index !== -1) {
this.templates[index] = updatedTemplate;
}
if (this.currentTemplate && this.currentTemplate.id === id) {
this.currentTemplate = updatedTemplate;
}
return updatedTemplate;
} catch (error) {
this.error = error.message || '更新模板失败';
throw error;
} finally {
this.loading = false;
}
},
async deleteTemplate(id) {
this.loading = true;
this.error = null;
try {
await templateAPI.deleteTemplate(id);
this.templates = this.templates.filter(t => t.id !== id);
if (this.currentTemplate && this.currentTemplate.id === id) {
this.currentTemplate = null;
}
return true;
} catch (error) {
this.error = error.message || '删除模板失败';
throw error;
} finally {
this.loading = false;
}
},
async createTaskFromTemplate(
templateId,
threadId,
fieldValues,
taskName = null,
cronExpression = '0 20 * * *'
) {
this.loading = true;
this.error = null;
try {
const task = await templateAPI.createTaskFromTemplate({
template_id: templateId,
thread_id: threadId,
field_values: fieldValues,
task_name: taskName,
cron_expression: cronExpression,
});
return task;
} catch (error) {
this.error = error.message || '从模板创建任务失败';
throw error;
} finally {
this.loading = false;
}
},
clearCurrentTemplate() {
this.currentTemplate = null;
},
clearError() {
this.error = null;
},
},
});
+94
View File
@@ -0,0 +1,94 @@
import { defineStore } from 'pinia';
import { userAPI } from '@/api';
export const useUserStore = defineStore('user', {
state: () => ({
tokenStatus: null, // Token 状态信息
users: [], // 用户列表(管理员)
currentPage: 1,
pageSize: 20,
total: 0,
}),
getters: {
isTokenExpiring: state => {
if (!state.tokenStatus) return false;
return state.tokenStatus.expiring_soon || false;
},
tokenExpireTime: state => {
if (!state.tokenStatus || !state.tokenStatus.expires_at) return null;
return new Date(state.tokenStatus.expires_at * 1000);
},
},
actions: {
// 获取 Token 状态
async fetchTokenStatus() {
try {
const status = await userAPI.getTokenStatus();
this.tokenStatus = status;
return status;
} catch (error) {
throw new Error(error.message || '获取 Token 状态失败');
}
},
// 获取用户列表(管理员)
async fetchUsers(params = {}) {
try {
const data = await userAPI.getUsers(params);
this.users = data.users || data;
this.total = data.total || this.users.length;
return data;
} catch (error) {
throw new Error(error.message || '获取用户列表失败');
}
},
// 创建用户(管理员)
async createUser(userData) {
try {
const newUser = await userAPI.createUser(userData);
// 刷新用户列表
await this.fetchUsers();
return newUser;
} catch (error) {
throw new Error(error.message || '创建用户失败');
}
},
// 更新用户
async updateUser(userId, userData) {
try {
// 过滤空密码字段
const cleanedData = { ...userData };
if (
cleanedData.password === '' ||
cleanedData.password === null ||
cleanedData.password === undefined
) {
delete cleanedData.password;
}
const updatedUser = await userAPI.updateUser(userId, cleanedData);
// 刷新用户列表
await this.fetchUsers();
return updatedUser;
} catch (error) {
throw new Error(error.message || '更新用户失败');
}
},
// 删除用户
async deleteUser(userId) {
try {
await userAPI.deleteUser(userId);
// 刷新用户列表
await this.fetchUsers();
} catch (error) {
throw new Error(error.message || '删除用户失败');
}
},
},
});
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
/**
* 格式化日期时间
* @param {string|Date} date - 日期
* @param {boolean} includeTime - 是否包含时间
* @returns {string}
*/
export function formatDateTime(date, includeTime = true) {
if (!date) return '-';
const d = new Date(date);
if (isNaN(d.getTime())) return '-';
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
if (!includeTime) {
return `${year}-${month}-${day}`;
}
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
const seconds = String(d.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* 格式化相对时间(多久之前)
* @param {string|Date} date - 日期
* @returns {string}
*/
export function formatRelativeTime(date) {
if (!date) return '-';
const d = new Date(date);
if (isNaN(d.getTime())) return '-';
const now = new Date();
const diff = now - d; // 毫秒差
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return '刚刚';
if (minutes < 60) return `${minutes} 分钟前`;
if (hours < 24) return `${hours} 小时前`;
if (days < 7) return `${days} 天前`;
return formatDateTime(date, false);
}
/**
* 格式化文件大小
* @param {number} bytes - 字节数
* @returns {string}
*/
export function formatFileSize(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
}
/**
* 防抖函数
* @param {Function} fn - 要防抖的函数
* @param {number} delay - 延迟时间(毫秒)
* @returns {Function}
*/
export function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
/**
* 节流函数
* @param {Function} fn - 要节流的函数
* @param {number} delay - 延迟时间(毫秒)
* @returns {Function}
*/
export function throttle(fn, delay = 300) {
let timer = null;
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime < delay) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
lastTime = now;
fn.apply(this, args);
}, delay);
} else {
lastTime = now;
fn.apply(this, args);
}
};
}
/**
* 复制文本到剪贴板
* @param {string} text - 要复制的文本
* @returns {Promise<boolean>}
*/
export async function copyToClipboard(text) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
} else {
// 降级方案
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
textArea.remove();
return true;
} catch (error) {
console.error('复制失败', error);
textArea.remove();
return false;
}
}
} catch (error) {
console.error('复制失败', error);
return false;
}
}
+547
View File
@@ -0,0 +1,547 @@
<template>
<Layout>
<div class="dashboard-container">
<!-- 邮箱未设置提醒 -->
<a-alert
v-if="!authStore.user?.email"
message="您还未设置邮箱地址"
type="info"
:closable="true"
show-icon
style="margin-bottom: 20px"
>
<template #description>
<div>
设置邮箱后可以接收打卡任务的通知和提醒
<a style="margin-left: 8px; cursor: pointer" @click="goToSettings"> 立即前往设置 </a>
</div>
</template>
</a-alert>
<!-- 密码未设置提醒 -->
<a-alert
v-if="!authStore.user?.has_password"
message="您还未设置登录密码"
type="info"
:closable="true"
show-icon
style="margin-bottom: 20px"
>
<template #description>
<div>
设置密码后可以使用用户名+密码快速登录
<a style="margin-left: 8px; cursor: pointer" @click="goToSettings"> 立即前往设置 </a>
</div>
</template>
</a-alert>
<!-- Token 已过期提醒 -->
<a-alert
v-if="tokenStatus && !tokenStatus.is_valid"
message="打卡凭证已过期"
type="warning"
:closable="true"
show-icon
style="margin-bottom: 20px"
>
<template #description>
<div>
打卡凭证已过期无法自动打卡请扫码刷新 Token
<a style="margin-left: 8px; cursor: pointer" @click="qrcodeModalVisible = true">
立即刷新
</a>
</div>
</template>
</a-alert>
<!-- 没有打卡任务提醒 -->
<a-alert
v-if="!taskStore.loading && taskStore.tasks.length === 0"
message="您还没有打卡任务"
type="info"
:closable="true"
show-icon
style="margin-bottom: 20px"
>
<template #description>
<div>
创建您的第一个打卡任务开启自动打卡之旅
<a style="margin-left: 8px; cursor: pointer" @click="goToTasks"> 立即创建 </a>
</div>
</template>
</a-alert>
<a-row :gutter="[20, 20]">
<!-- Token 状态卡片 -->
<a-col :xs="24" :sm="24" :md="24">
<a-card class="status-card md3-card">
<template #title>
<div class="card-header">
<KeyOutlined />
<span>Token 状态</span>
</div>
</template>
<div v-if="tokenStatusLoading" class="loading-container">
<a-skeleton :active="true" :paragraph="{ rows: 3 }" />
</div>
<div v-else-if="tokenStatus" class="token-status">
<a-descriptions :column="{ xs: 1, sm: 1, md: 2 }" bordered>
<a-descriptions-item label="Token 状态">
<a-tag :color="tokenStatus.is_valid ? 'success' : 'error'">
{{ tokenStatus.is_valid ? '有效' : '无效' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="过期时间">
{{ formatExpireTime }}
</a-descriptions-item>
<a-descriptions-item label="剩余时间">
<a-tag
v-if="tokenStatus.is_valid"
:color="tokenStatus.expiring_soon ? 'warning' : 'success'"
>
{{ formatRemainTime }}
</a-tag>
<a-tag v-else color="error">已过期</a-tag>
</a-descriptions-item>
<a-descriptions-item label="即将过期">
<a-tag v-if="!tokenStatus.is_valid" color="error"> 已过期 </a-tag>
<a-tag v-else :color="tokenStatus.expiring_soon ? 'warning' : 'success'">
{{ tokenStatus.expiring_soon ? '是' : '否' }}
</a-tag>
</a-descriptions-item>
</a-descriptions>
<!-- 刷新 Token 按钮 -->
<div style="margin-top: 24px; text-align: center">
<!-- Token 未过期时禁用按钮并显示提示 -->
<a-tooltip v-if="tokenStatus.is_valid" title="Token 过期后才可以扫码刷新 Token">
<a-button type="primary" size="large" :disabled="true">
<template #icon><ReloadOutlined /></template>
刷新 Token
</a-button>
</a-tooltip>
<!-- Token 已过期时启用按钮且无提示 -->
<a-button v-else type="primary" size="large" @click="qrcodeModalVisible = true">
<template #icon><ReloadOutlined /></template>
刷新 Token
</a-button>
</div>
<a-alert
v-if="tokenStatus.expiring_soon"
message="Token 即将过期"
description="您的 Token 将在 30 分钟内过期,请在过期后及时刷新 Token!"
type="warning"
:closable="false"
show-icon
style="margin-top: 15px"
/>
</div>
</a-card>
</a-col>
<!-- 手动打卡卡片 -->
<a-col :xs="24" :sm="24" :md="24">
<a-card class="md3-card">
<template #title>
<div class="card-header">
<CalendarOutlined />
<span>手动打卡</span>
</div>
</template>
<div class="check-in-container">
<p class="hint">选择任务并点击下方按钮立即执行打卡操作</p>
<!-- 任务选择 -->
<a-select
v-model:value="selectedTaskId"
placeholder="请选择要打卡的任务"
:loading="taskStore.loading"
style="width: 100%; max-width: 400px; margin-bottom: 20px"
>
<a-select-option v-for="task in taskStore.tasks" :key="task.id" :value="task.id">
<div style="display: flex; justify-content: space-between; align-items: center">
<span>{{ task.name }}</span>
<a-tag size="small" :color="task.is_active ? 'success' : 'default'">
{{ task.is_active ? '启用' : '禁用' }}
</a-tag>
</div>
</a-select-option>
</a-select>
<a-button
type="primary"
size="large"
:loading="checkInLoading"
:disabled="!selectedTaskId"
@click="handleCheckIn"
>
<template #icon><CalendarOutlined /></template>
{{ checkInLoading ? '打卡中...' : '立即打卡' }}
</a-button>
<div v-if="lastCheckIn" class="last-check-in">
<a-divider />
<p class="label">上次打卡</p>
<a-descriptions :column="{ xs: 1, sm: 1, md: 2 }" bordered size="small">
<a-descriptions-item label="时间">
{{ formatDateTime(lastCheckIn.check_in_time) }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag
:color="
lastCheckIn.status === 'success'
? 'success'
: lastCheckIn.status === 'out_of_time'
? 'default'
: lastCheckIn.status === 'unknown'
? 'warning'
: 'error'
"
>
{{
lastCheckIn.status === 'success'
? '成功'
: lastCheckIn.status === 'out_of_time'
? '时间范围外'
: lastCheckIn.status === 'unknown'
? '异常'
: '失败'
}}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="打卡响应" :span="{ xs: 1, sm: 1, md: 2 }">
{{ lastCheckIn.response_text || lastCheckIn.error_message || '-' }}
</a-descriptions-item>
</a-descriptions>
</div>
</div>
</a-card>
</a-col>
<!-- 用户信息卡片 -->
<a-col :xs="24" :sm="24" :md="24">
<a-card class="md3-card">
<template #title>
<div class="card-header">
<UserOutlined />
<span>个人信息</span>
</div>
</template>
<a-descriptions :column="{ xs: 1, sm: 1, md: 2 }" bordered>
<a-descriptions-item label="用户名">
{{ authStore.user?.alias }}
</a-descriptions-item>
<a-descriptions-item label="角色">
<a-tag :color="authStore.isAdmin ? 'error' : 'blue'">
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="邮箱">
{{ authStore.user?.email || '未设置' }}
</a-descriptions-item>
<a-descriptions-item label="注册时间">
{{ formatDateTime(authStore.user?.created_at, false) }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
</a-row>
</div>
<!-- QR Code Modal for Token Refresh -->
<QRCodeModal
v-model:visible="qrcodeModalVisible"
:alias="authStore.user?.alias || ''"
@success="handleQRCodeSuccess"
@error="handleQRCodeError"
/>
</Layout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { CalendarOutlined, KeyOutlined, UserOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import QRCodeModal from '@/components/QRCodeModal.vue';
import { useAuthStore } from '@/stores/auth';
import { useUserStore } from '@/stores/user';
import { useTaskStore } from '@/stores/task';
import { useCheckInStore } from '@/stores/checkIn';
import { formatDateTime } from '@/utils/helpers';
import { usePollStatus } from '@/composables/usePollStatus';
const router = useRouter();
const authStore = useAuthStore();
const userStore = useUserStore();
const taskStore = useTaskStore();
const checkInStore = useCheckInStore();
// 使用轮询 composable
const { startPolling } = usePollStatus({
interval: 2000, // 每 2 秒轮询一次
maxRetries: 15, // 最多 15 次 (30 秒)
backoff: false, // 不使用指数退避
});
const tokenStatusLoading = ref(false);
const checkInLoading = ref(false);
const selectedTaskId = ref(null);
const qrcodeModalVisible = ref(false);
const tokenStatus = computed(() => userStore.tokenStatus);
const lastCheckIn = computed(() => {
if (checkInStore.myRecords.length > 0) {
return checkInStore.myRecords[0];
}
return null;
});
const formatExpireTime = computed(() => {
if (!tokenStatus.value) return '-';
// Token 无效时,尝试从 user.jwt_exp 获取过期时间
if (!tokenStatus.value.expires_at) {
// 如果后端没有返回 expires_at,说明 Token 可能无效或未设置
const jwtExp = authStore.user?.jwt_exp;
if (jwtExp && jwtExp !== '0') {
try {
const timestamp = parseInt(jwtExp);
return formatDateTime(timestamp * 1000);
} catch {
return '-';
}
}
return '-';
}
return formatDateTime(tokenStatus.value.expires_at * 1000);
});
const formatRemainTime = computed(() => {
if (!tokenStatus.value || !tokenStatus.value.expires_at) return '-';
const now = Date.now();
const expireTime = tokenStatus.value.expires_at * 1000;
const diff = expireTime - now;
if (diff <= 0) return '已过期';
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (days > 0) return `${days}${hours} 小时`;
if (hours > 0) return `${hours} 小时 ${minutes} 分钟`;
return `${minutes} 分钟`;
});
// 跳转到设置页面
const goToSettings = () => {
router.push('/settings');
};
// 跳转到任务页面
const goToTasks = () => {
router.push('/tasks');
};
// 获取 Token 状态
const fetchTokenStatus = async () => {
tokenStatusLoading.value = true;
try {
await userStore.fetchTokenStatus();
} catch (error) {
message.error(error.message || '获取 Token 状态失败');
} finally {
tokenStatusLoading.value = false;
}
};
// 手动打卡
const handleCheckIn = async () => {
if (!selectedTaskId.value) {
message.warning('请先选择要打卡的任务');
return;
}
checkInLoading.value = true;
try {
// 调用异步打卡接口,立即返回 record_id
const result = await taskStore.checkInTask(selectedTaskId.value);
// 获取 record_id
const recordId = result.record_id;
if (!recordId) {
message.error('打卡请求失败:未获取到记录ID');
checkInLoading.value = false;
return;
}
// 如果初始状态就是失败,显示错误并刷新记录
if (result.status === 'failure') {
message.error(result.message || '打卡失败');
checkInLoading.value = false;
checkInStore.fetchMyRecords({ limit: 1 });
return;
}
// 显示提示消息
message.info('打卡任务已启动,正在后台处理...');
// 使用轮询 composable 检查打卡状态
startPolling(
async () => {
const status = await taskStore.getCheckInRecordStatus(recordId);
return {
completed: status.status !== 'pending',
success: status.status === 'success',
data: status,
};
},
{
onSuccess: () => {
checkInLoading.value = false;
message.success('打卡成功!');
checkInStore.fetchMyRecords({ limit: 1 });
},
onFailure: statusData => {
checkInLoading.value = false;
// 优先使用 error_message,如果为空则使用 response_text,都为空则使用默认消息
const errorMsg =
(statusData.error_message && statusData.error_message.trim()) ||
(statusData.response_text && statusData.response_text.trim()) ||
'打卡失败';
message.error(errorMsg);
checkInStore.fetchMyRecords({ limit: 1 });
},
onTimeout: () => {
checkInLoading.value = false;
message.warning('打卡处理时间较长,请稍后查看打卡记录');
},
}
);
} catch (error) {
console.error('启动打卡失败:', error);
checkInLoading.value = false;
message.error(error.message || '启动打卡任务失败');
}
};
// 处理扫码成功(Token 刷新)
const handleQRCodeSuccess = async () => {
try {
// 获取最新的用户信息和 Token 状态
await authStore.fetchCurrentUser();
await fetchTokenStatus();
message.success({ content: 'Token 刷新成功!', duration: 3 });
} catch (error) {
console.error('刷新用户信息失败:', error);
message.error({ content: '获取最新信息失败,请刷新页面', duration: 3 });
}
};
// 处理扫码失败
const handleQRCodeError = errorMsg => {
message.error({ content: errorMsg || '扫码刷新 Token 失败', duration: 3 });
};
onMounted(async () => {
// 刷新用户信息,确保 email 和 has_password 是最新的
try {
await authStore.fetchCurrentUser();
} catch (error) {
console.error('刷新用户信息失败:', error);
}
// 获取 Token 状态
fetchTokenStatus();
checkInStore.fetchMyRecords({ limit: 1 });
// 加载任务列表
try {
await taskStore.fetchMyTasks();
// 如果只有一个任务,自动选中(优先选择启用的任务)
if (taskStore.activeTasks.length === 1) {
selectedTaskId.value = taskStore.activeTasks[0].id;
} else if (taskStore.tasks.length === 1) {
selectedTaskId.value = taskStore.tasks[0].id;
}
} catch (error) {
message.error(error.message || '加载任务列表失败');
}
});
</script>
<style scoped>
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 500;
}
.loading-container {
padding: 20px;
}
.token-status {
padding: 0;
}
.token-status .ant-descriptions {
margin-bottom: 0;
}
.check-in-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 20px;
gap: 12px;
}
.check-in-container .hint {
color: var(--md-sys-color-on-surface-variant);
font-size: 14px;
margin: 0 0 4px 0;
text-align: center;
}
.last-check-in {
width: 100%;
margin-top: 20px;
}
.last-check-in .label {
font-size: 14px;
font-weight: 500;
color: var(--md-sys-color-on-surface-variant);
margin: 12px 0 8px 0;
}
.ant-alert {
margin-top: 16px;
}
.ant-select {
margin-bottom: 0;
}
</style>
+524
View File
@@ -0,0 +1,524 @@
<template>
<div class="login-container">
<a-row justify="center" align="middle" style="height: 100%">
<a-col :xs="22" :sm="18" :md="12" :lg="10" :xl="8">
<a-card class="login-card">
<template #title>
<div class="card-header">
<h2>接龙自动打卡系统</h2>
<p class="subtitle">
{{ loginMode === 'qrcode' ? 'QQ 扫码登录/注册' : '用户名密码登录' }}
</p>
</div>
</template>
<!-- 登录模式切换 -->
<div class="mode-switch">
<a-segmented v-model:value="loginMode" :options="loginModeOptions" block />
</div>
<!-- QR码登录表单 -->
<a-form
v-if="loginMode === 'qrcode'"
ref="qrcodeFormRef"
:model="qrcodeForm"
:rules="qrcodeRules"
layout="vertical"
@submit.prevent="handleQRCodeLogin"
>
<a-form-item name="alias">
<a-input
v-model:value="qrcodeForm.alias"
placeholder="请输入您的用户名"
size="large"
autocomplete="username"
allow-clear
@keyup.enter="handleQRCodeLogin"
>
<template #prefix>
<UserOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item>
<a-button
type="primary"
size="large"
block
:loading="loading"
@click="handleQRCodeLogin"
>
{{ loading ? '正在登录...' : '扫码登录/注册' }}
</a-button>
</a-form-item>
</a-form>
<!-- 别名+密码登录表单 -->
<a-form
v-else
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
layout="vertical"
>
<a-form-item name="alias">
<a-input
v-model:value="passwordForm.alias"
placeholder="请输入您的用户名"
size="large"
autocomplete="username"
allow-clear
>
<template #prefix>
<UserOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item name="password">
<a-input-password
v-model:value="passwordForm.password"
placeholder="请输入密码"
size="large"
autocomplete="current-password"
@keyup.enter="handlePasswordLogin"
>
<template #prefix>
<KeyOutlined />
</template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-button
type="primary"
size="large"
block
:loading="loading"
@click="handlePasswordLogin"
>
{{ loading ? '登录中...' : '登录' }}
</a-button>
</a-form-item>
<div class="tips-link">
<a class="link-text" @click="loginMode = 'qrcode'"> 没有密码使用扫码登录 </a>
</div>
</a-form>
<div class="tips">
<a-alert
:message="loginMode === 'qrcode' ? '扫码登录提示' : '密码登录提示'"
type="info"
:closable="false"
show-icon
>
<template #description>
<template v-if="loginMode === 'qrcode'">
<p>1. 输入您的用户名(用于标识身份)</p>
<p>2. 点击"扫码登录/注册"按钮</p>
<p>3. 使用手机 QQ 扫描弹出的二维码</p>
<p>4. 扫码成功后即可登录系统</p>
<p class="tip-note">💡 新用户首次扫码将自动注册账户</p>
</template>
<template v-else>
<p>1. 输入您的用户名和密码</p>
<p>2. 点击"登录"按钮直接登录</p>
<p>3. 首次使用请先扫码登录/注册然后在设置中设置密码</p>
</template>
</template>
</a-alert>
</div>
</a-card>
</a-col>
</a-row>
<!-- QR 码弹窗 -->
<QRCodeModal
v-model:visible="qrcodeVisible"
:alias="qrcodeForm.alias"
@success="handleLoginSuccess"
@error="handleLoginError"
/>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import { UserOutlined, KeyOutlined } from '@ant-design/icons-vue';
import { authAPI } from '@/api';
import { useAuthStore } from '@/stores/auth';
import QRCodeModal from '@/components/QRCodeModal.vue';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const qrcodeFormRef = ref(null);
const passwordFormRef = ref(null);
const loading = ref(false);
const qrcodeVisible = ref(false);
// 登录模式
const loginMode = ref('qrcode');
const loginModeOptions = [
{ label: '扫码登录', value: 'qrcode' },
{ label: '密码登录', value: 'password' },
];
// 监听登录模式切换,同步用户名
watch(loginMode, () => {
// 从密码登录切换到扫码登录
if (loginMode.value === 'qrcode' && passwordForm.value.alias) {
qrcodeForm.value.alias = passwordForm.value.alias;
}
// 从扫码登录切换到密码登录
else if (loginMode.value === 'password' && qrcodeForm.value.alias) {
passwordForm.value.alias = qrcodeForm.value.alias;
}
});
// QR码登录表单
const qrcodeForm = ref({
alias: '',
});
const qrcodeRules = {
alias: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
],
};
// 密码登录表单
const passwordForm = ref({
alias: '',
password: '',
});
const passwordRules = {
alias: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6个字符', trigger: 'blur' },
],
};
// QR码登录
const handleQRCodeLogin = async () => {
if (!qrcodeFormRef.value) return;
try {
await qrcodeFormRef.value.validate();
// 显示 QR 码弹窗
qrcodeVisible.value = true;
} catch {
// 表单验证失败,不需要打印错误(由 Ant Design 自动显示错误提示)
}
};
// 密码登录
const handlePasswordLogin = async () => {
if (!passwordFormRef.value) return;
try {
await passwordFormRef.value.validate();
loading.value = true;
const response = await authAPI.aliasLogin(
passwordForm.value.alias,
passwordForm.value.password
);
if (response.success) {
// 保存 JWT token 和用户信息
authStore.setAuth(response.token, response.user);
// 如果有打卡 Token 警告,显示提示(不影响网站登录)
if (response.token_warning && response.warning_message) {
message.warning({
content: response.warning_message,
duration: 2,
});
} else {
message.success(`欢迎回来,${response.user.alias}`);
}
// 跳转到重定向页面或仪表盘
const redirect = route.query.redirect || '/dashboard';
router.push(redirect);
} else {
// 根据不同错误类型提供友好提示
handlePasswordLoginError(response.message);
}
} catch (error) {
console.error('密码登录失败:', error);
const errorMsg = error.response?.data?.detail || error.message || '登录失败,请稍后重试';
handlePasswordLoginError(errorMsg);
} finally {
loading.value = false;
}
};
// 处理密码登录错误
const handlePasswordLoginError = msg => {
if (!msg) {
message.error('登录失败,请稍后重试');
return;
}
// 用户不存在或密码错误
if (msg.includes('用户名或密码错误')) {
message.error('用户名或密码错误');
return;
}
// 未设置密码
if (msg.includes('未设置密码')) {
message.warning('该账户未设置密码,请使用扫码登录');
return;
}
// 用户不存在
if (msg.includes('用户不存在')) {
message.error('用户不存在,请检查用户名或使用扫码登录注册');
return;
}
// 其他错误
message.error(msg || '登录失败,请稍后重试');
};
const handleLoginSuccess = user => {
message.success(`欢迎回来,${user.alias}`);
// 跳转到重定向页面或仪表盘
const redirect = route.query.redirect || '/dashboard';
router.push(redirect);
};
const handleLoginError = error => {
message.error(error.message || '登录失败');
};
</script>
<style scoped>
.login-container {
width: 100vw;
height: 100vh;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
padding: 16px;
transition: background 0.3s ease;
}
/* 暗色模式背景 */
.dark .login-container {
background: linear-gradient(135deg, #1a237e 0%, #4a148c 100%);
}
.login-card {
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
width: 100%;
margin: 20px 0;
}
/* 暗色模式卡片阴影 */
.dark .login-card {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.card-header {
text-align: center;
}
.card-header h2 {
margin: 0;
font-size: 24px;
color: #303133;
transition: color 0.3s ease;
}
/* 暗色模式标题 */
.dark .card-header h2 {
color: #e6e1e5;
}
.subtitle {
margin: 10px 0 0 0;
font-size: 14px;
color: #909399;
transition: color 0.3s ease;
}
/* 暗色模式副标题 */
.dark .subtitle {
color: #cac4d0;
}
.mode-switch {
margin-bottom: 20px;
}
.tips-link {
text-align: center;
margin-top: 10px;
}
.link-text {
color: #2196f3;
cursor: pointer;
text-decoration: none;
transition: color 0.3s ease;
}
.link-text:hover {
text-decoration: underline;
}
/* 暗色模式链接 */
.dark .link-text {
color: #64b5f6;
}
.dark .link-text:hover {
color: #90caf9;
}
.tips {
margin-top: 20px;
}
.tips :deep(p) {
margin: 5px 0;
font-size: 14px;
line-height: 1.5;
}
.tip-note {
margin-top: 12px !important;
padding-top: 8px;
border-top: 1px dashed #e0e0e0;
color: #606266;
font-weight: 500;
transition: all 0.3s ease;
}
/* 暗色模式提示注释 */
.dark .tip-note {
border-top-color: #49454f;
color: #cac4d0;
}
/* 确保 Ant Design Row 占满高度 */
.login-container :deep(.ant-row) {
width: 100%;
min-height: 100%;
}
/* 移动端优化 */
@media (max-width: 768px) {
.login-container {
padding: 12px;
}
.login-card {
border-radius: 12px;
}
.card-header h2 {
font-size: 20px;
}
.subtitle {
font-size: 13px;
}
.tips :deep(p) {
font-size: 13px;
}
.tips :deep(.ant-alert) {
font-size: 13px;
}
}
/* 小屏手机优化 */
@media (max-width: 576px) {
.login-container {
padding: 8px;
}
.login-card {
border-radius: 8px;
margin: 10px 0;
}
.card-header h2 {
font-size: 18px;
}
.subtitle {
font-size: 12px;
}
.mode-switch {
margin-bottom: 16px;
}
.tips {
margin-top: 16px;
}
.tips :deep(p) {
font-size: 12px;
margin: 4px 0;
}
}
/* 横屏优化 */
@media (max-height: 600px) and (orientation: landscape) {
.login-container {
padding: 8px;
align-items: flex-start;
}
.login-card {
margin: 8px 0;
}
.card-header h2 {
font-size: 18px;
}
.tips :deep(p) {
margin: 3px 0;
font-size: 12px;
}
.mode-switch {
margin-bottom: 12px;
}
.tips {
margin-top: 12px;
}
}
</style>
+30
View File
@@ -0,0 +1,30 @@
<template>
<div class="not-found-container">
<a-result status="404" title="404" sub-title="抱歉您访问的页面不存在">
<template #extra>
<a-button type="primary" @click="goHome">返回首页</a-button>
</template>
</a-result>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router';
const router = useRouter();
const goHome = () => {
router.push('/');
};
</script>
<style scoped>
.not-found-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
}
</style>
@@ -0,0 +1,360 @@
<template>
<div class="pending-container">
<div class="pending-card">
<div class="card-header">
<h2>🕐 等待审批</h2>
</div>
<div class="pending-content">
<div class="result-icon">
<span class="info-icon"></span>
</div>
<h3 class="result-title">您的账户正在等待管理员审批</h3>
<div class="result-subtitle">
<p>您已成功注册账户信息如下</p>
</div>
<a-descriptions :column="1" bordered class="mb-6">
<a-descriptions-item label="用户名">
{{ user?.alias || '加载中...' }}
</a-descriptions-item>
<a-descriptions-item label="邮箱">
<template v-if="user?.email">
{{ user.email }}
</template>
<template v-else>
<a-tag color="warning">未设置</a-tag>
</template>
</a-descriptions-item>
<a-descriptions-item label="密码">
<template v-if="user?.has_password">
<a-tag color="success">已设置</a-tag>
</template>
<template v-else>
<a-tag color="warning">未设置</a-tag>
</template>
</a-descriptions-item>
<a-descriptions-item label="注册时间">
{{ formatDate(user?.created_at) }}
</a-descriptions-item>
<a-descriptions-item label="审批状态">
<a-tag color="warning">待审批</a-tag>
</a-descriptions-item>
</a-descriptions>
<a-alert message="⚠️ 审批说明" type="info" :closable="false" show-icon class="mb-6">
<template #description>
<ul class="tips-list">
<li>管理员将在 <strong>24 小时内</strong> 审核您的注册申请</li>
<li>审核通过后您将可以使用所有功能</li>
<li>如超过 24 小时未审批账户将被自动删除</li>
<li><strong>建议</strong>审批期间可以设置邮箱和密码方便后续使用</li>
<li>您可以随时刷新此页面查看最新状态</li>
</ul>
</template>
</a-alert>
<div class="actions">
<a-button type="primary" size="large" @click="checkStatus">
<template #icon><ReloadOutlined /></template>
刷新状态
</a-button>
<a-button size="large" @click="showProfileModal = true">
<template #icon><SettingOutlined /></template>
完善信息
</a-button>
<a-button size="large" @click="logout">
<template #icon><LogoutOutlined /></template>
退出登录
</a-button>
</div>
</div>
</div>
<!-- 完善信息弹窗 -->
<a-modal
v-model:open="showProfileModal"
title="完善个人信息"
:confirm-loading="profileLoading"
width="500px"
@ok="handleUpdateProfile"
@cancel="resetProfileForm"
>
<a-form :model="profileForm" layout="vertical">
<a-form-item label="邮箱地址(可选)" name="email">
<a-input v-model:value="profileForm.email" placeholder="用于接收审批通知" type="email" />
<div class="form-hint">建议设置邮箱方便接收审批结果通知</div>
</a-form-item>
<a-form-item
label="新密码(可选)"
name="new_password"
:help="user?.has_password ? '留空表示不修改密码' : '设置密码后可以使用密码登录'"
>
<a-input-password
v-model:value="profileForm.new_password"
placeholder="至少6位字符"
autocomplete="new-password"
/>
</a-form-item>
<a-form-item v-if="profileForm.new_password" label="确认密码" name="confirm_password">
<a-input-password
v-model:value="profileForm.confirm_password"
placeholder="再次输入新密码"
autocomplete="new-password"
/>
</a-form-item>
<a-form-item
v-if="user?.has_password && profileForm.new_password"
label="当前密码"
name="current_password"
>
<a-input-password
v-model:value="profileForm.current_password"
placeholder="修改密码时需要提供当前密码"
autocomplete="current-password"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { ReloadOutlined, LogoutOutlined, SettingOutlined } from '@ant-design/icons-vue';
import { userAPI } from '@/api';
import { useAuthStore } from '@/stores/auth';
const router = useRouter();
const authStore = useAuthStore();
const user = ref(null);
const showProfileModal = ref(false);
const profileLoading = ref(false);
const profileForm = ref({
email: '',
new_password: '',
confirm_password: '',
current_password: '',
});
const checkStatus = async () => {
try {
const response = await userAPI.getUserStatus();
user.value = response;
if (response.is_approved) {
message.success('恭喜!您的账户已通过审批');
router.push('/dashboard');
} else {
message.info('仍在等待审批中');
}
} catch (error) {
console.error('获取状态失败:', error);
message.error('获取状态失败:' + (error.message || '未知错误'));
}
};
const loadUserInfo = async () => {
try {
const response = await userAPI.getCurrentUser();
user.value = response;
// 初始化表单
profileForm.value.email = response.email || '';
} catch (error) {
console.error('加载用户信息失败:', error);
}
};
const handleUpdateProfile = async () => {
// 验证
if (profileForm.value.new_password && profileForm.value.new_password.length < 6) {
message.error('密码至少需要 6 位字符');
return;
}
if (profileForm.value.new_password !== profileForm.value.confirm_password) {
message.error('两次输入的密码不一致');
return;
}
if (
user.value?.has_password &&
profileForm.value.new_password &&
!profileForm.value.current_password
) {
message.error('修改密码时需要提供当前密码');
return;
}
profileLoading.value = true;
try {
const updateData = {};
// 只提交有变化的字段
if (profileForm.value.email !== (user.value?.email || '')) {
updateData.email = profileForm.value.email || null;
}
if (profileForm.value.new_password) {
updateData.new_password = profileForm.value.new_password;
if (user.value?.has_password) {
updateData.current_password = profileForm.value.current_password;
}
}
// 如果没有要更新的字段
if (Object.keys(updateData).length === 0) {
message.info('没有需要更新的信息');
showProfileModal.value = false;
return;
}
await userAPI.updateProfile(updateData);
message.success('个人信息更新成功');
showProfileModal.value = false;
resetProfileForm();
// 重新加载用户信息
await loadUserInfo();
// 如果设置了密码,更新本地存储的用户信息
if (updateData.new_password) {
const currentUser = authStore.user;
if (currentUser) {
currentUser.has_password = true;
localStorage.setItem('user', JSON.stringify(currentUser));
}
}
} catch (error) {
console.error('更新个人信息失败:', error);
message.error(error.message || '更新失败,请重试');
} finally {
profileLoading.value = false;
}
};
const resetProfileForm = () => {
profileForm.value = {
email: user.value?.email || '',
new_password: '',
confirm_password: '',
current_password: '',
};
};
const logout = () => {
authStore.logout();
router.push('/login');
};
const formatDate = dateStr => {
if (!dateStr) return '未知';
const date = new Date(dateStr);
return date.toLocaleString('zh-CN');
};
onMounted(() => {
loadUserInfo();
checkStatus();
});
</script>
<style scoped>
.pending-container {
width: 100%;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.pending-card {
width: 100%;
max-width: 700px;
background: white;
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.card-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.card-header h2 {
margin: 0;
font-size: 28px;
font-weight: 600;
}
.pending-content {
padding: 40px;
}
.result-icon {
text-align: center;
margin-bottom: 20px;
}
.info-icon {
font-size: 64px;
display: inline-block;
}
.result-title {
text-align: center;
font-size: 24px;
font-weight: 600;
color: #303133;
margin: 0 0 10px 0;
}
.result-subtitle {
text-align: center;
color: #606266;
margin-bottom: 30px;
}
.mb-6 {
margin-bottom: 30px;
}
.tips-list {
text-align: left;
padding-left: 20px;
line-height: 1.8;
margin: 0;
color: #606266;
}
.tips-list li {
margin: 8px 0;
}
.actions {
display: flex;
gap: 15px;
justify-content: center;
flex-wrap: wrap;
}
.form-hint {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
</style>
+235
View File
@@ -0,0 +1,235 @@
<template>
<Layout>
<div class="records-container">
<a-card>
<template #title>
<div class="card-header">
<div>
<UnorderedListOutlined />
<span>我的打卡记录</span>
</div>
<a-button type="primary" @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
</template>
<!-- 统计信息 -->
<div class="stats-container">
<a-row :gutter="20">
<a-col :xs="24" :sm="8" :md="8">
<a-statistic title="总打卡次数" :value="total" />
</a-col>
<a-col :xs="24" :sm="8" :md="8">
<a-statistic
title="成功次数"
:value="successCount"
:value-style="{ color: '#67c23a' }"
/>
</a-col>
<a-col :xs="24" :sm="8" :md="8">
<a-statistic
title="成功率"
:value="parseFloat(checkInStore.successRate)"
suffix="%"
:precision="2"
/>
</a-col>
</a-row>
</div>
<a-divider />
<!-- 桌面端表格 -->
<a-table
v-if="!isMobile"
:data-source="checkInStore.myRecords"
:columns="columns"
:loading="checkInStore.loading"
:pagination="false"
:row-key="record => record.id"
:scroll="{ x: 'max-content' }"
bordered
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'check_in_time'">
{{ formatDateTime(record.check_in_time) }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag>
<a-tag v-else-if="record.status === 'out_of_time'" color="default"
>🕐 时间范围外</a-tag
>
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
<a-tag v-else color="error">❌ 打卡失败</a-tag>
</template>
<template v-else-if="column.key === 'trigger_type'">
<a-tag v-if="record.trigger_type === 'manual'" color="blue">手动</a-tag>
<a-tag v-else-if="record.trigger_type === 'scheduled'" color="default">定时</a-tag>
<a-tag v-else-if="record.trigger_type === 'admin'" color="orange">管理员</a-tag>
<a-tag v-else>{{ record.trigger_type }}</a-tag>
</template>
</template>
</a-table>
<!-- 移动端卡片视图 -->
<a-space v-else direction="vertical" :size="16" style="width: 100%">
<a-card
v-for="record in checkInStore.myRecords"
:key="record.id"
size="small"
:loading="checkInStore.loading"
>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="ID">{{ record.id }}</a-descriptions-item>
<a-descriptions-item label="打卡时间">
{{ formatDateTime(record.check_in_time) }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag>
<a-tag v-else-if="record.status === 'out_of_time'" color="default"
>🕐 时间范围外</a-tag
>
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
<a-tag v-else color="error">❌ 打卡失败</a-tag>
</a-descriptions-item>
<a-descriptions-item label="触发方式">
<a-tag v-if="record.trigger_type === 'manual'" color="blue">手动</a-tag>
<a-tag v-else-if="record.trigger_type === 'scheduled'" color="default">定时</a-tag>
<a-tag v-else-if="record.trigger_type === 'admin'" color="orange">管理员</a-tag>
<a-tag v-else>{{ record.trigger_type }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="消息">
{{ record.response_text || '-' }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-space>
<!-- 分页 -->
<div class="pagination-container">
<a-pagination
v-model:current="checkInStore.currentPage"
v-model:page-size="checkInStore.pageSize"
:total="total"
:page-size-options="['10', '20', '50', '100']"
show-size-changer
show-quick-jumper
:show-total="total => `${total} 条记录`"
@change="handlePageChange"
@show-size-change="handleSizeChange"
/>
</div>
</a-card>
</div>
</Layout>
</template>
<script setup>
import { computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { UnorderedListOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import { useBreakpoint } from '@/composables/useBreakpoint';
import { useCheckInStore } from '@/stores/checkIn';
import { formatDateTime } from '@/utils/helpers';
const checkInStore = useCheckInStore();
const { isMobile } = useBreakpoint();
const total = computed(() => checkInStore.total);
const successCount = computed(() => {
return checkInStore.myRecords.filter(r => r.status === 'success').length;
});
// 表格列配置
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '打卡时间',
dataIndex: 'check_in_time',
key: 'check_in_time',
width: 180,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 120,
},
{
title: '触发方式',
dataIndex: 'trigger_type',
key: 'trigger_type',
width: 120,
},
{
title: '消息',
dataIndex: 'response_text',
key: 'response_text',
ellipsis: true,
},
];
// 刷新数据
const handleRefresh = async () => {
try {
await checkInStore.fetchMyRecords();
message.success('刷新成功');
} catch (error) {
message.error(error.message || '刷新失败');
}
};
// 页码改变
const handlePageChange = () => {
checkInStore.fetchMyRecords();
};
// 每页数量改变
const handleSizeChange = () => {
checkInStore.currentPage = 1;
checkInStore.fetchMyRecords();
};
onMounted(() => {
checkInStore.fetchMyRecords();
});
</script>
<style scoped>
.records-container {
max-width: 1400px;
margin: 0 auto;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header > div {
display: flex;
align-items: center;
gap: 8px;
font-weight: bold;
}
.stats-container {
padding: 20px 0;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
+281
View File
@@ -0,0 +1,281 @@
<template>
<Layout>
<div class="settings-view">
<div class="max-w-4xl mx-auto">
<h1 class="text-3xl font-bold text-on-surface mb-6">个人设置</h1>
<!-- 基本信息卡片 -->
<a-card class="md3-card mb-6">
<h2 class="text-xl font-bold text-on-surface mb-4 flex items-center">
<UserOutlined class="mr-2" />
基本信息
</h2>
<a-descriptions :column="1" bordered>
<a-descriptions-item label="用户ID">{{ user?.id }}</a-descriptions-item>
<a-descriptions-item label="当前用户名">{{ user?.alias }}</a-descriptions-item>
<a-descriptions-item label="角色">
<a-tag :color="user?.role === 'admin' ? 'error' : 'success'">
{{ user?.role === 'admin' ? '管理员' : '普通用户' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="密码状态">
<a-tag :color="hasPassword ? 'success' : 'warning'">
{{ hasPassword ? '已设置' : '未设置' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatDate(user?.created_at) }}
</a-descriptions-item>
</a-descriptions>
</a-card>
<!-- 修改邮箱 -->
<a-card class="md3-card mb-6">
<h2 class="text-xl font-bold text-on-surface mb-4 flex items-center">
<EditOutlined class="mr-2" />
修改个人信息
</h2>
<a-form ref="profileFormRef" :model="profileForm" :rules="profileRules" layout="vertical">
<a-form-item label="邮箱" name="email">
<a-input
v-model:value="profileForm.email"
placeholder="请输入邮箱地址(可选)"
allow-clear
/>
</a-form-item>
<a-alert
message="用户名无法修改"
description="用户名只能由管理员修改,如需修改请联系管理员"
type="info"
:closable="false"
show-icon
style="margin-bottom: 24px"
/>
<a-form-item style="margin-top: 8px">
<a-space>
<a-button type="primary" :loading="profileLoading" @click="handleUpdateProfile">
保存
</a-button>
<a-button @click="resetProfileForm">重置</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<!-- 设置/修改密码 -->
<a-card class="md3-card">
<h2 class="text-xl font-bold text-on-surface mb-4 flex items-center">
<KeyOutlined class="mr-2" />
{{ hasPassword ? '修改密码' : '设置密码' }}
</h2>
<a-alert
v-if="!hasPassword"
message="您还未设置密码"
description="设置密码后,您可以使用用户名+密码的方式快速登录"
type="warning"
class="mb-4"
show-icon
:closable="false"
/>
<a-form :model="passwordForm" layout="vertical">
<a-form-item v-if="hasPassword" label="当前密码">
<a-input-password
v-model:value="passwordForm.currentPassword"
placeholder="请输入当前密码"
allow-clear
/>
</a-form-item>
<a-form-item label="新密码">
<a-input-password
v-model:value="passwordForm.newPassword"
placeholder="请输入新密码(至少6个字符)"
allow-clear
/>
</a-form-item>
<a-form-item label="确认新密码">
<a-input-password
v-model:value="passwordForm.confirmPassword"
placeholder="请再次输入新密码"
allow-clear
/>
</a-form-item>
<a-form-item style="margin-top: 8px">
<a-space>
<a-button type="primary" :loading="passwordLoading" @click="handleUpdatePassword">
{{ hasPassword ? '修改密码' : '设置密码' }}
</a-button>
<a-button @click="resetPasswordForm">重置</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
</div>
</div>
</Layout>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { UserOutlined, EditOutlined, KeyOutlined } from '@ant-design/icons-vue';
import { userAPI } from '@/api';
import Layout from '@/components/Layout.vue';
const profileFormRef = ref(null);
const profileLoading = ref(false);
const passwordLoading = ref(false);
const user = ref(null);
const hasPassword = ref(false);
// 个人信息表单
const profileForm = ref({
email: '',
});
const profileRules = {
email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
};
// 密码表单
const passwordForm = ref({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
// 加载用户信息
const loadUserInfo = async () => {
try {
user.value = await userAPI.getCurrentUser();
profileForm.value.email = user.value.email || '';
// 从后端返回的数据中获取密码状态
hasPassword.value = user.value.has_password || false;
} catch (error) {
message.error(error.message || '加载用户信息失败');
}
};
// 更新个人信息
const handleUpdateProfile = async () => {
if (!profileFormRef.value) return;
try {
await profileFormRef.value.validate();
profileLoading.value = true;
await userAPI.updateProfile({
email: profileForm.value.email || null,
});
message.success('个人信息修改成功');
await loadUserInfo();
} catch (error) {
if (error.errorFields) return; // 验证错误
const errorMsg = error.response?.data?.detail || error.message || '修改失败';
message.error(errorMsg);
} finally {
profileLoading.value = false;
}
};
// 重置个人信息表单
const resetProfileForm = () => {
profileForm.value.email = user.value?.email || '';
profileFormRef.value?.clearValidate();
};
// 更新密码
const handleUpdatePassword = async () => {
try {
// 手动验证
if (hasPassword.value && !passwordForm.value.currentPassword) {
message.error('请输入当前密码');
return;
}
if (!passwordForm.value.newPassword) {
message.error('请输入新密码');
return;
}
if (passwordForm.value.newPassword.length < 6) {
message.error('密码至少需要6个字符');
return;
}
if (!passwordForm.value.confirmPassword) {
message.error('请再次输入新密码');
return;
}
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
message.error('两次输入的密码不一致');
return;
}
passwordLoading.value = true;
const updateData = {
new_password: passwordForm.value.newPassword,
};
if (hasPassword.value) {
updateData.current_password = passwordForm.value.currentPassword;
}
await userAPI.updateProfile(updateData);
message.success(hasPassword.value ? '密码修改成功' : '密码设置成功');
hasPassword.value = true;
resetPasswordForm();
} catch (error) {
const errorMsg = error.response?.data?.detail || error.message || '操作失败';
message.error(errorMsg);
} finally {
passwordLoading.value = false;
}
};
// 重置密码表单
const resetPasswordForm = () => {
passwordForm.value = {
currentPassword: '',
newPassword: '',
confirmPassword: '',
};
};
// 格式化日期
const formatDate = dateString => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
onMounted(() => {
loadUserInfo();
});
</script>
<style scoped>
.settings-view {
min-height: 100%;
}
</style>
+421
View File
@@ -0,0 +1,421 @@
<template>
<Layout>
<div class="task-records-view">
<div class="max-w-7xl mx-auto">
<!-- Header -->
<div class="mb-8">
<a-button type="link" class="mb-4 flex items-center" @click="router.back()">
<template #icon><LeftOutlined /></template>
返回任务列表
</a-button>
<a-card v-if="currentTask" class="md3-card">
<div class="flex items-start justify-between">
<div class="flex-1">
<h1 class="text-3xl font-bold text-gradient mb-2">
{{ currentTask.name || '未命名任务' }}
</h1>
<div class="flex items-center gap-4 text-sm text-on-surface-variant">
<span class="flex items-center">
<NumberOutlined class="mr-1" />
接龙 ID: {{ getThreadId(currentTask) }}
</span>
<a-tag :color="currentTask.is_active ? 'success' : 'default'">
{{ currentTask.is_active ? '启用中' : '已禁用' }}
</a-tag>
</div>
</div>
<a-button type="primary" :loading="checkInLoading" @click="handleManualCheckIn">
{{ checkInLoading ? '打卡中...' : '立即打卡' }}
</a-button>
</div>
</a-card>
</div>
<!-- Stats Summary -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="12" :sm="8" :md="4">
<a-card class="md3-card animate-slide-up">
<p class="text-sm text-on-surface-variant mb-1">总打卡次数</p>
<p class="text-2xl font-bold text-on-surface">{{ recordStats.total }}</p>
</a-card>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.05s">
<p class="text-sm text-on-surface-variant mb-1">成功次数</p>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">
{{ recordStats.success }}
</p>
</a-card>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.1s">
<p class="text-sm text-on-surface-variant mb-1">时间范围外</p>
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">
{{ recordStats.outOfTime }}
</p>
</a-card>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.15s">
<p class="text-sm text-on-surface-variant mb-1">失败次数</p>
<p class="text-2xl font-bold text-red-600 dark:text-red-400">
{{ recordStats.failure }}
</p>
</a-card>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.2s">
<p class="text-sm text-on-surface-variant mb-1">异常次数</p>
<p class="text-2xl font-bold text-orange-600 dark:text-orange-400">
{{ recordStats.unknown }}
</p>
</a-card>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.25s">
<p class="text-sm text-on-surface-variant mb-1">成功率</p>
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400">
{{ recordStats.successRate }}%
</p>
</a-card>
</a-col>
</a-row>
<!-- Filters -->
<a-card class="md3-card mb-6">
<a-space wrap :size="[16, 16]">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-on-surface">状态筛选:</span>
<a-radio-group
v-model:value="filterStatus"
button-style="solid"
size="small"
@change="handleFilterChange"
>
<a-radio-button value="">全部</a-radio-button>
<a-radio-button value="success">成功</a-radio-button>
<a-radio-button value="out_of_time">时间范围外</a-radio-button>
<a-radio-button value="failure">失败</a-radio-button>
<a-radio-button value="unknown">异常</a-radio-button>
</a-radio-group>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-on-surface">触发方式:</span>
<a-radio-group
v-model:value="filterTrigger"
button-style="solid"
size="small"
@change="handleFilterChange"
>
<a-radio-button value="">全部</a-radio-button>
<a-radio-button value="scheduler">自动</a-radio-button>
<a-radio-button value="manual">手动</a-radio-button>
</a-radio-group>
</div>
<a-button size="small" @click="fetchRecords">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</a-card>
<!-- Records List -->
<div v-if="loading" class="space-y-4">
<a-card v-for="i in 5" :key="i">
<a-skeleton :active="true" :paragraph="{ rows: 3 }" />
</a-card>
</div>
<a-card
v-else-if="records.length === 0"
class="md3-card text-center"
style="padding: 48px 20px"
>
<FileTextOutlined class="text-8xl text-on-surface-variant opacity-30 mb-4" />
<h3 class="text-xl font-semibold text-on-surface mb-2">暂无打卡记录</h3>
<p class="text-on-surface-variant">当前筛选条件下没有找到任何打卡记录</p>
</a-card>
<div v-else class="space-y-4">
<a-card
v-for="record in records"
:key="record.id"
class="md3-card hover:shadow-xl transition-all animate-slide-up"
>
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2 flex-wrap">
<h3 class="text-lg font-semibold text-on-surface">打卡记录 #{{ record.id }}</h3>
<a-tag v-if="record.status === 'success'" color="success"> 打卡成功</a-tag>
<a-tag v-else-if="record.status === 'out_of_time'" color="default"
>🕐 时间范围外</a-tag
>
<a-tag v-else-if="record.status === 'unknown'" color="warning"> 打卡异常</a-tag>
<a-tag v-else color="error"> 打卡失败</a-tag>
<a-tag :color="record.trigger_type === 'scheduled' ? 'blue' : 'orange'">
{{ record.trigger_type === 'scheduled' ? '自动触发' : '手动触发' }}
</a-tag>
</div>
<div class="flex items-center text-sm text-on-surface-variant">
<ClockCircleOutlined class="mr-1" />
{{ formatDateTime(record.check_in_time) }}
</div>
</div>
</div>
<!-- Record Details -->
<div
class="bg-surface-container-high dark:bg-surface-container rounded-lg p-4 space-y-2"
>
<div v-if="record.response_text" class="flex items-start">
<span class="text-sm font-medium text-on-surface-variant w-20">响应:</span>
<span class="text-sm text-on-surface flex-1">{{ record.response_text }}</span>
</div>
<div v-if="record.error_message" class="flex items-start">
<span class="text-sm font-medium text-error w-20">错误:</span>
<span class="text-sm text-error flex-1">{{ record.error_message }}</span>
</div>
</div>
</a-card>
</div>
<!-- Pagination -->
<div v-if="!loading && records.length > 0" class="mt-6 flex justify-center">
<a-pagination
v-model:current="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-size-options="['10', '20', '50', '100']"
show-size-changer
show-quick-jumper
:show-total="total => `${total} 条记录`"
@change="handlePageChange"
@show-size-change="handleSizeChange"
/>
</div>
</div>
</div>
</Layout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
LeftOutlined,
NumberOutlined,
FileTextOutlined,
ClockCircleOutlined,
ReloadOutlined,
} from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import { useTaskStore } from '@/stores/task';
import { formatDateTime } from '@/utils/helpers';
import { usePollStatus } from '@/composables/usePollStatus';
const route = useRoute();
const router = useRouter();
const taskStore = useTaskStore();
// 使用轮询 composable
const { startPolling } = usePollStatus({
interval: 2000,
maxRetries: 15,
backoff: false,
});
const taskId = computed(() => parseInt(route.params.taskId));
const currentTask = ref(null);
const records = ref([]);
const loading = ref(false);
const checkInLoading = ref(false);
// Pagination
const currentPage = ref(1);
const pageSize = ref(20);
const total = ref(0);
// Filters
const filterStatus = ref('');
const filterTrigger = ref('');
// Stats
const recordStats = computed(() => {
const success = records.value.filter(r => r.status === 'success').length;
const outOfTime = records.value.filter(r => r.status === 'out_of_time').length;
const failure = records.value.filter(r => r.status === 'failure').length;
const unknown = records.value.filter(r => r.status === 'unknown').length;
const totalRecords = records.value.length;
const successRate = totalRecords > 0 ? Math.round((success / totalRecords) * 100) : 0;
return {
total: totalRecords,
success,
outOfTime,
failure,
unknown,
successRate,
};
});
// 从 payload_config 中提取 ThreadId
const getThreadId = task => {
if (!task || !task.payload_config) return '未知';
try {
const payload = JSON.parse(task.payload_config);
return payload.ThreadId || '未知';
} catch (e) {
console.error('解析 payload_config 失败:', e);
return '未知';
}
};
// 获取任务详情
const fetchTaskDetail = async () => {
try {
currentTask.value = await taskStore.fetchTask(taskId.value);
} catch (error) {
message.error(error.message || '获取任务详情失败');
router.push('/tasks');
}
};
// 获取打卡记录
const fetchRecords = async () => {
loading.value = true;
try {
const params = {
skip: (currentPage.value - 1) * pageSize.value,
limit: pageSize.value,
};
if (filterStatus.value) {
params.status = filterStatus.value;
}
if (filterTrigger.value) {
params.trigger_type = filterTrigger.value;
}
const response = await taskStore.fetchTaskRecords(taskId.value, params);
// 后端现在返回 { records, total, skip, limit }
if (response.records) {
records.value = response.records;
total.value = response.total || 0;
} else if (Array.isArray(response)) {
// 兼容旧格式
records.value = response;
total.value = response.length;
} else {
records.value = [];
total.value = 0;
}
} catch (error) {
message.error(error.message || '获取打卡记录失败');
} finally {
loading.value = false;
}
};
// 手动打卡
const handleManualCheckIn = async () => {
checkInLoading.value = true;
try {
// 调用异步打卡接口,立即返回 record_id
const result = await taskStore.checkInTask(taskId.value);
// 获取 record_id
const recordId = result.record_id;
if (!recordId) {
message.error('打卡请求失败:未获取到记录ID');
checkInLoading.value = false;
return;
}
// 如果初始状态就是失败,显示错误并刷新记录列表
if (result.status === 'failure') {
const errorMsg =
(result.error_message && result.error_message.trim()) ||
(result.response_text && result.response_text.trim()) ||
'打卡失败';
message.error(errorMsg);
checkInLoading.value = false;
await fetchRecords();
return;
}
// 显示提示消息
message.info('打卡任务已启动,正在后台处理...');
// 使用轮询 composable 检查打卡状态
startPolling(
async () => {
const status = await taskStore.getCheckInRecordStatus(recordId);
return {
completed: status.status !== 'pending',
success: status.status === 'success',
data: status,
};
},
{
onSuccess: async () => {
checkInLoading.value = false;
message.success('打卡成功!');
await fetchRecords();
},
onFailure: async statusData => {
checkInLoading.value = false;
// 优先使用 error_message,如果为空则使用 response_text,都为空则使用默认消息
const errorMsg =
(statusData.error_message && statusData.error_message.trim()) ||
(statusData.response_text && statusData.response_text.trim()) ||
'打卡失败';
message.error(errorMsg);
await fetchRecords();
},
onTimeout: () => {
checkInLoading.value = false;
message.warning('打卡处理时间较长,请稍后查看打卡记录');
},
}
);
} catch (error) {
console.error('启动打卡失败:', error);
checkInLoading.value = false;
message.error(error.message || '启动打卡任务失败');
}
};
// 筛选变化
const handleFilterChange = () => {
currentPage.value = 1;
fetchRecords();
};
// 分页变化
const handlePageChange = () => {
fetchRecords();
};
const handleSizeChange = () => {
currentPage.value = 1;
fetchRecords();
};
onMounted(async () => {
await fetchTaskDetail();
await fetchRecords();
});
</script>
<style scoped>
/* Additional component-specific styles if needed */
</style>
+838
View File
@@ -0,0 +1,838 @@
<template>
<Layout>
<div class="tasks-view">
<div class="max-w-7xl mx-auto">
<!-- Header Section -->
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-4xl font-bold text-gradient mb-2">任务管理</h1>
<p class="text-on-surface-variant">管理您的自动打卡任务</p>
</div>
<a-button type="primary" size="large" class="shadow-md3-3" @click="openCreateDialog">
<template #icon>
<PlusOutlined />
</template>
创建任务
</a-button>
</div>
<!-- Stats Cards -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="24" :sm="8" :md="8">
<a-card class="md3-card animate-slide-up">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-on-surface-variant mb-1">总任务数</p>
<p class="text-3xl font-bold text-primary">{{ taskStore.taskStats.total }}</p>
</div>
<div
class="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-md3 flex items-center justify-center"
>
<FileTextOutlined class="text-2xl text-primary" />
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="8" :md="8">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.1s">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-on-surface-variant mb-1">启用中</p>
<p class="text-3xl font-bold text-green-600 dark:text-green-400">
{{ taskStore.taskStats.active }}
</p>
</div>
<div
class="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-md3 flex items-center justify-center"
>
<CheckCircleOutlined class="text-2xl text-green-600 dark:text-green-400" />
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="8" :md="8">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.2s">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-on-surface-variant mb-1">已禁用</p>
<p class="text-3xl font-bold text-on-surface-variant">
{{ taskStore.taskStats.inactive }}
</p>
</div>
<div
class="w-12 h-12 bg-surface-container-high rounded-md3 flex items-center justify-center"
>
<StopOutlined class="text-2xl text-on-surface-variant" />
</div>
</div>
</a-card>
</a-col>
</a-row>
</div>
<!-- Tasks List -->
<div v-if="loading">
<a-row :gutter="[16, 16]">
<a-col v-for="i in 6" :key="i" :xs="24" :sm="12" :lg="8">
<a-card>
<a-skeleton :active="true" :paragraph="{ rows: 4 }" />
</a-card>
</a-col>
</a-row>
</div>
<a-card
v-else-if="taskStore.tasks.length === 0"
class="md3-card text-center"
style="padding: 48px 20px"
>
<FileTextOutlined class="text-8xl text-on-surface-variant opacity-30 mb-4" />
<h3 class="text-xl font-semibold text-on-surface mb-2">暂无任务</h3>
<p class="text-on-surface-variant mb-6">
点击右上角的"创建任务"按钮开始添加您的第一个打卡任务
</p>
<a-button type="primary" @click="openCreateDialog"> 创建第一个任务 </a-button>
</a-card>
<a-row v-else :gutter="[16, 16]">
<a-col v-for="task in taskStore.tasks" :key="task.id" :xs="24" :sm="12" :lg="8">
<a-card
class="md3-card hover:scale-105 transform transition-all cursor-pointer animate-slide-up"
@click="viewTask(task)"
>
<!-- Task Header -->
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-on-surface mb-1">
{{ task.name || '未命名任务' }}
</h3>
<a-divider style="margin: 8px 0" />
<p class="text-sm text-on-surface-variant">任务 ID: {{ task.id }}</p>
</div>
<a-tag :color="task.is_active ? 'success' : 'default'">
{{ task.is_active ? '启用' : '禁用' }}
</a-tag>
</div>
<!-- Task Details -->
<div class="space-y-2 mb-4">
<div class="flex items-center text-sm text-on-surface-variant">
<TagOutlined class="mr-2" />
接龙ID: {{ getThreadId(task) }}
</div>
<div class="flex items-center text-sm text-on-surface-variant">
<ClockCircleOutlined class="mr-2" />
最后打卡:
{{ task.last_check_in_time ? formatDateTime(task.last_check_in_time) : '未打卡' }}
</div>
<div class="flex items-center text-sm">
<CheckCircleOutlined class="mr-2 text-on-surface-variant" />
<span
v-if="task.last_check_in_status"
:class="{
'text-green-600 dark:text-green-400 font-medium':
task.last_check_in_status === 'success',
'text-blue-600 dark:text-blue-400 font-medium':
task.last_check_in_status === 'out_of_time',
'text-red-600 dark:text-red-400 font-medium':
task.last_check_in_status === 'failure',
'text-yellow-600 dark:text-yellow-400 font-medium':
task.last_check_in_status === 'unknown',
}"
>
{{
task.last_check_in_status === 'success'
? '✅ 打卡成功'
: task.last_check_in_status === 'out_of_time'
? '🕐 时间范围外'
: task.last_check_in_status === 'failure'
? '❌ 打卡失败'
: '❗ 打卡异常'
}}
</span>
<span v-else class="text-on-surface-variant">暂无打卡记录</span>
</div>
</div>
<!-- Task Actions -->
<div class="flex gap-2 pt-4 border-t border-outline-variant">
<a-button
type="primary"
size="small"
:loading="checkInLoading[task.id]"
class="flex-1"
@click.stop="handleCheckIn(task.id)"
>
{{ checkInLoading[task.id] ? '打卡中...' : '立即打卡' }}
</a-button>
<a-button size="small" class="flex-1" @click.stop="toggleTaskStatus(task)">
{{ task.is_active ? '禁用' : '启用' }}
</a-button>
<a-button
type="primary"
size="small"
ghost
class="icon-button"
@click.stop="editTask(task)"
>
<template #icon><EditOutlined /></template>
</a-button>
<a-button danger size="small" class="icon-button" @click.stop="deleteTask(task)">
<template #icon><DeleteOutlined /></template>
</a-button>
</div>
</a-card>
</a-col>
</a-row>
</div>
</div>
<!-- Create/Edit Task Dialog -->
<a-modal
v-model:open="showCreateDialog"
:title="editingTask ? '编辑任务' : '从模板创建任务'"
:width="isMobile ? '100%' : 700"
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
:mask-closable="false"
>
<!-- 只显示从模板创建 -->
<div v-if="!editingTask">
<div v-if="loadingTemplates" class="text-center py-8">
<a-spin size="large" />
<p class="text-on-surface-variant mt-2">加载模板中...</p>
</div>
<div v-else-if="activeTemplates.length === 0" class="text-center py-8">
<p class="text-on-surface-variant">暂无可用模板</p>
<p class="text-sm text-on-surface-variant opacity-70 mt-2">请联系管理员创建模板</p>
</div>
<div v-else>
<!-- Template Selection -->
<a-form-item v-if="!selectedTemplate" label="选择模板">
<div class="grid grid-cols-1 gap-3">
<div
v-for="template in activeTemplates"
:key="template.id"
class="border border-outline-variant rounded-lg p-4 cursor-pointer hover:border-primary hover:bg-primary-container/10 transition-all"
@click="selectTemplate(template)"
>
<h4 class="font-semibold text-on-surface mb-1">{{ template.name }}</h4>
<p class="text-sm text-on-surface-variant">
{{ template.description || '无描述' }}
</p>
</div>
</div>
</a-form-item>
<!-- Template Form -->
<a-form
v-if="selectedTemplate"
ref="templateFormRef"
:model="templateTaskForm"
layout="vertical"
>
<div class="mb-4 p-3 bg-blue-50 rounded-lg flex items-center justify-between">
<div class="flex items-center">
<FileTextOutlined class="text-blue-600 mr-2" />
<span class="text-sm font-medium text-blue-900"
>使用模板{{ selectedTemplate.name }}</span
>
</div>
<a-button size="small" type="link" @click="selectedTemplate = null"
>更换模板</a-button
>
</div>
<a-form-item label="任务名称" name="task_name">
<a-input
v-model:value="templateTaskForm.task_name"
placeholder="可选,留空则自动生成"
/>
</a-form-item>
<a-form-item label="接龙 ID" name="thread_id" required>
<a-input
v-model:value="templateTaskForm.thread_id"
placeholder="请输入接龙项目 ID(ThreadID) | 如果你不知道这是什么,请询问管理员"
/>
</a-form-item>
<a-form-item label="打卡时间表">
<CrontabEditor v-model="templateTaskForm.cron_expression" />
</a-form-item>
<a-divider orientation="left">填写字段信息</a-divider>
<!-- Dynamic Fields -->
<div v-for="(fieldConfig, key) in visibleFields" :key="key">
<a-form-item :label="fieldConfig.display_name" :required="fieldConfig.required">
<!-- Text Input -->
<a-input
v-if="fieldConfig.field_type === 'text'"
v-model:value="templateTaskForm.field_values[key]"
:placeholder="fieldConfig.placeholder || `请输入${fieldConfig.display_name}`"
/>
<!-- Textarea -->
<a-textarea
v-else-if="fieldConfig.field_type === 'textarea'"
v-model:value="templateTaskForm.field_values[key]"
:rows="3"
:placeholder="fieldConfig.placeholder || `请输入${fieldConfig.display_name}`"
/>
<!-- Number Input -->
<a-input-number
v-else-if="fieldConfig.field_type === 'number'"
v-model:value="templateTaskForm.field_values[key]"
:placeholder="fieldConfig.placeholder || `请输入${fieldConfig.display_name}`"
style="width: 100%"
/>
<!-- Select -->
<a-select
v-else-if="fieldConfig.field_type === 'select'"
v-model:value="templateTaskForm.field_values[key]"
:placeholder="fieldConfig.placeholder || `请选择${fieldConfig.display_name}`"
style="width: 100%"
>
<a-select-option
v-for="option in fieldConfig.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-select-option>
</a-select>
<span v-if="fieldConfig.default_value" class="text-xs text-on-surface-variant mt-1">
默认值: {{ fieldConfig.default_value }}
</span>
</a-form-item>
</div>
</a-form>
</div>
</div>
<!-- Edit Mode Form - 简化版只显示任务名称和启用状态 -->
<a-form
v-if="editingTask"
ref="taskFormRef"
:model="taskForm"
:rules="taskRules"
layout="vertical"
>
<a-form-item label="任务名称" name="name">
<a-input v-model:value="taskForm.name" placeholder="请输入任务名称(例如:公司打卡)" />
</a-form-item>
<a-form-item label="启用状态">
<a-switch v-model:checked="taskForm.is_active" />
<span class="ml-2 text-sm text-on-surface-variant">
{{ taskForm.is_active ? '启用自动打卡' : '禁用自动打卡(仍可手动打卡)' }}
</span>
</a-form-item>
<!-- 新增Crontab 编辑器 -->
<a-form-item label="打卡时间表">
<CrontabEditor v-model="taskForm.cron_expression" />
</a-form-item>
<a-divider orientation="left">任务 Payload 配置只读</a-divider>
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-on-surface-variant">完整的打卡请求配置</span>
<a-button size="small" type="primary" ghost @click="copyPayload">
<template #icon><CopyOutlined /></template>
复制
</a-button>
</div>
<a-textarea
v-model:value="formattedPayload"
:rows="12"
readonly
class="font-mono text-xs"
style="resize: vertical; min-height: 200px; max-height: 400px"
/>
<p class="text-xs text-on-surface-variant mt-1">
💡 此配置由模板自动生成如需修改请删除任务后从模板重新创建
</p>
</div>
</a-form>
<template #footer>
<div class="flex gap-3 justify-end">
<a-button @click="showCreateDialog = false">取消</a-button>
<a-button type="primary" :loading="submitting" @click="handleSubmit">
{{ submitting ? '提交中...' : editingTask ? '保存修改' : '创建任务' }}
</a-button>
</div>
</template>
</a-modal>
</Layout>
</template>
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { useRouter } from 'vue-router';
import {
PlusOutlined,
FileTextOutlined,
CheckCircleOutlined,
StopOutlined,
TagOutlined,
ClockCircleOutlined,
EditOutlined,
DeleteOutlined,
CopyOutlined,
} from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import CrontabEditor from '@/components/CrontabEditor.vue';
import { useBreakpoint } from '@/composables/useBreakpoint';
import { useTaskStore } from '@/stores/task';
import { useTemplateStore } from '@/stores/template';
import { copyToClipboard, formatDateTime } from '@/utils/helpers';
import { usePollStatus } from '@/composables/usePollStatus';
const router = useRouter();
const taskStore = useTaskStore();
const templateStore = useTemplateStore();
const { isMobile } = useBreakpoint();
// 使用轮询 composable
const { startPolling } = usePollStatus({
interval: 2000,
maxRetries: 15,
backoff: false,
});
const loading = ref(false);
const showCreateDialog = ref(false);
const submitting = ref(false);
const editingTask = ref(null);
const taskFormRef = ref(null);
const templateFormRef = ref(null);
const checkInLoading = ref({});
// Template mode
const loadingTemplates = ref(false);
const activeTemplates = ref([]);
const selectedTemplate = ref(null);
const templatePreview = ref(null); // 存储从 preview 接口获取的合并后配置
// Edit task form (仅用于编辑任务)
const taskForm = reactive({
name: '',
thread_id: '',
is_active: true,
payload_config: '',
cron_expression: '0 20 * * *',
});
// Template create form
const templateTaskForm = reactive({
task_name: '',
thread_id: '',
field_values: {},
cron_expression: '0 20 * * *',
});
const taskRules = {
name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
thread_id: [{ required: true, message: '请输入接龙 ID', trigger: 'blur' }],
};
// Compute visible fields from selected template (using merged config)
const visibleFields = computed(() => {
if (!templatePreview.value) return {};
// 使用合并后的完整字段配置(包含从父模板继承的字段)
const fieldConfig = templatePreview.value.field_config;
const visible = {};
// 递归函数:提取所有可见的普通字段
const extractVisibleFields = (config, parentPath = '') => {
for (const [key, value] of Object.entries(config)) {
const currentPath = parentPath ? `${parentPath}.${key}` : key;
// 判断是否为字段配置对象(包含 display_name
if (value && typeof value === 'object' && 'display_name' in value) {
// 这是一个普通字段配置
if (!value.hidden) {
visible[currentPath] = value;
}
}
// 判断是否为数组字段
else if (Array.isArray(value)) {
// 数组字段:遍历每个元素
if (value.length > 0) {
const firstElement = value[0];
// 如果数组元素是字段配置对象,直接提取
if (firstElement && typeof firstElement === 'object' && 'display_name' in firstElement) {
if (!firstElement.hidden) {
visible[`${currentPath}[0]`] = firstElement;
}
}
// 如果数组元素是对象(但不是字段配置),递归处理
else if (firstElement && typeof firstElement === 'object') {
extractVisibleFields(firstElement, `${currentPath}[0]`);
}
}
}
// 判断是否为对象字段(不包含 display_name 的对象)
else if (value && typeof value === 'object' && !('display_name' in value)) {
// 递归处理对象字段
extractVisibleFields(value, currentPath);
}
}
};
extractVisibleFields(fieldConfig);
return visible;
});
// Formatted payload for display in edit mode
const formattedPayload = computed(() => {
if (!taskForm.payload_config) return '{}';
try {
const payload = JSON.parse(taskForm.payload_config);
return JSON.stringify(payload, null, 2);
} catch {
return taskForm.payload_config;
}
});
// Copy payload to clipboard
const copyPayload = async () => {
const success = await copyToClipboard(formattedPayload.value);
if (success) {
message.success('Payload 已复制到剪贴板');
} else {
message.error('复制失败');
}
};
// Initialize field values with defaults when template is selected
watch(selectedTemplate, async newTemplate => {
if (!newTemplate) {
templatePreview.value = null;
return;
}
// 获取模板的合并后配置(包含父模板的字段)
try {
templatePreview.value = await templateStore.previewTemplate(newTemplate.id);
} catch {
message.error('获取模板配置失败');
templatePreview.value = null;
return;
}
const fieldConfig = templatePreview.value.field_config;
const fieldValues = {};
// 递归函数:提取所有字段的默认值
const extractDefaultValues = (config, parentPath = '') => {
for (const [key, value] of Object.entries(config)) {
const currentPath = parentPath ? `${parentPath}.${key}` : key;
// 判断是否为字段配置对象(包含 display_name
if (value && typeof value === 'object' && 'display_name' in value) {
fieldValues[currentPath] = value.default_value || '';
}
// 判断是否为数组字段
else if (Array.isArray(value)) {
// 数组字段:处理第一个元素的默认值
if (value.length > 0) {
const firstElement = value[0];
// 如果数组元素是字段配置对象,直接提取默认值
if (firstElement && typeof firstElement === 'object' && 'display_name' in firstElement) {
fieldValues[`${currentPath}[0]`] = firstElement.default_value || '';
}
// 如果数组元素是对象(但不是字段配置),递归处理
else if (firstElement && typeof firstElement === 'object') {
extractDefaultValues(firstElement, `${currentPath}[0]`);
}
}
}
// 判断是否为对象字段(不包含 display_name 的对象)
else if (value && typeof value === 'object' && !('display_name' in value)) {
// 递归处理对象字段
extractDefaultValues(value, currentPath);
}
}
};
extractDefaultValues(fieldConfig);
templateTaskForm.field_values = fieldValues;
});
// Load templates
const loadTemplates = async () => {
loadingTemplates.value = true;
try {
activeTemplates.value = await templateStore.fetchActiveTemplates();
} catch (error) {
message.error(error.message || '加载模板失败');
} finally {
loadingTemplates.value = false;
}
};
// Select template
const selectTemplate = template => {
selectedTemplate.value = template;
};
// 从 payload_config 中提取 ThreadId
const getThreadId = task => {
if (!task.payload_config) return '未知';
try {
const payload = JSON.parse(task.payload_config);
return payload.ThreadId || '未知';
} catch (e) {
console.error('解析 payload_config 失败:', e);
return '未知';
}
};
// 加载任务列表
const fetchTasks = async () => {
loading.value = true;
try {
await taskStore.fetchMyTasks();
} catch (error) {
message.error(error.message || '加载任务列表失败');
} finally {
loading.value = false;
}
};
// 查看任务详情
const viewTask = task => {
router.push(`/tasks/${task.id}/records`);
};
// 编辑任务
const editTask = task => {
editingTask.value = task;
// 从 payload_config 中提取 thread_id
let threadId = '';
try {
const payload = JSON.parse(task.payload_config || '{}');
threadId = payload.ThreadId || '';
} catch (e) {
console.error('解析 payload_config 失败:', e);
}
Object.assign(taskForm, {
name: task.name,
thread_id: threadId,
is_active: task.is_active,
payload_config: task.payload_config || '{}',
cron_expression: task.cron_expression || '0 20 * * *',
});
showCreateDialog.value = true;
};
// 删除任务
const deleteTask = task => {
Modal.confirm({
title: '删除确认',
content: `确定要删除任务"${task.name || task.id}"吗?此操作不可恢复。`,
okText: '确定删除',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
await taskStore.deleteTask(task.id);
message.success('任务删除成功');
await fetchTasks();
} catch (error) {
message.error(error.message || '删除任务失败');
}
},
});
};
// 切换任务状态
const toggleTaskStatus = async task => {
try {
await taskStore.toggleTask(task.id);
message.success(task.is_active ? '任务已禁用' : '任务已启用');
} catch (error) {
message.error(error.message || '切换任务状态失败');
}
};
// 手动打卡 (异步轮询方式)
const handleCheckIn = async taskId => {
checkInLoading.value[taskId] = true;
try {
// 调用异步打卡接口,立即返回 record_id
const result = await taskStore.checkInTask(taskId);
// 获取 record_id
const recordId = result.record_id;
if (!recordId) {
message.error('打卡请求失败:未获取到记录ID');
checkInLoading.value[taskId] = false;
return;
}
// 如果初始状态就是失败,显示错误并刷新任务列表
if (result.status === 'failure') {
message.error(result.message || '打卡失败');
checkInLoading.value[taskId] = false;
await fetchTasks();
return;
}
// 显示提示消息
message.info('打卡任务已启动,正在后台处理...');
// 使用轮询 composable 检查打卡状态
startPolling(
async () => {
const status = await taskStore.getCheckInRecordStatus(recordId);
return {
completed: status.status !== 'pending',
success: status.status === 'success',
data: status,
};
},
{
onSuccess: async () => {
checkInLoading.value[taskId] = false;
message.success('打卡成功!');
await fetchTasks();
},
onFailure: async statusData => {
checkInLoading.value[taskId] = false;
// 优先使用 error_message,如果为空则使用 response_text,都为空则使用默认消息
const errorMsg =
(statusData.error_message && statusData.error_message.trim()) ||
(statusData.response_text && statusData.response_text.trim()) ||
'打卡失败';
message.error(errorMsg);
await fetchTasks();
},
onTimeout: () => {
checkInLoading.value[taskId] = false;
message.warning('打卡处理时间较长,请稍后查看打卡记录');
},
}
);
} catch (error) {
console.error('启动打卡失败:', error);
checkInLoading.value[taskId] = false;
message.error(error.message || '启动打卡任务失败');
}
};
// 提交表单
const handleSubmit = async () => {
submitting.value = true;
try {
// Edit mode
if (editingTask.value) {
if (!taskFormRef.value) return;
await taskFormRef.value.validate();
await taskStore.updateTask(editingTask.value.id, taskForm);
message.success('任务更新成功');
}
// Create from template
else {
if (!selectedTemplate.value) {
message.warning('请选择一个模板');
return;
}
if (!templateTaskForm.thread_id) {
message.warning('请输入接龙 ID');
return;
}
await templateStore.createTaskFromTemplate(
selectedTemplate.value.id,
templateTaskForm.thread_id,
templateTaskForm.field_values,
templateTaskForm.task_name || null,
templateTaskForm.cron_expression || '0 20 * * *'
);
message.success('任务创建成功');
}
showCreateDialog.value = false;
resetForm();
await fetchTasks();
} catch (error) {
message.error(error.message || '操作失败');
} finally {
submitting.value = false;
}
};
// 重置表单
const resetForm = () => {
editingTask.value = null;
selectedTemplate.value = null;
Object.assign(taskForm, {
name: '',
thread_id: '',
is_active: true,
payload_config: '',
cron_expression: '0 20 * * *',
});
templateTaskForm.task_name = '';
templateTaskForm.thread_id = '';
templateTaskForm.field_values = {};
templateTaskForm.cron_expression = '0 20 * * *';
taskFormRef.value?.resetFields();
};
// 打开创建任务对话框
const openCreateDialog = () => {
resetForm(); // 重置表单状态,确保不会显示编辑界面
showCreateDialog.value = true;
};
// Watch dialog open to load templates
watch(showCreateDialog, isOpen => {
if (isOpen && !editingTask.value) {
loadTemplates();
}
});
onMounted(() => {
fetchTasks();
});
</script>
<style scoped>
.icon-button {
display: flex;
align-items: center;
justify-content: center;
min-width: 32px;
padding: 4px 8px;
}
</style>
+134
View File
@@ -0,0 +1,134 @@
<template>
<Layout>
<div class="admin-logs-container">
<a-card>
<template #title>
<div class="card-header">
<div>
<FileTextOutlined />
<span>系统日志</span>
</div>
<a-button type="primary" @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
</template>
<a-alert
message="日志查看"
description="显示最新的系统日志信息(默认显示最近 200 行)"
type="info"
:closable="false"
show-icon
style="margin-bottom: 20px"
/>
<div v-if="adminStore.loading" class="loading-container">
<a-skeleton :active="true" :paragraph="{ rows: 10 }" />
</div>
<div v-else class="logs-content">
<a-textarea
v-model:value="logContent"
:rows="25"
:readonly="true"
placeholder="暂无日志内容"
class="log-textarea"
/>
<div class="log-info">
<span> {{ logLines }} </span>
<span>最后更新: {{ lastUpdate }}</span>
</div>
</div>
</a-card>
</div>
</Layout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { FileTextOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import { useAdminStore } from '@/stores/admin';
import { formatDateTime } from '@/utils/helpers';
const adminStore = useAdminStore();
const logContent = ref('');
const lastUpdate = ref('');
const logLines = computed(() => {
if (!logContent.value) return 0;
const content =
typeof logContent.value === 'string' ? logContent.value : String(logContent.value);
return content.split('\n').length;
});
const handleRefresh = async () => {
try {
const data = await adminStore.fetchLogs({ lines: 200 });
if (data.logs) {
// 确保是字符串
logContent.value = typeof data.logs === 'string' ? data.logs : String(data.logs);
lastUpdate.value = formatDateTime(new Date());
message.success({ content: '刷新成功', duration: 2 });
} else {
logContent.value = '无日志内容';
}
} catch (error) {
message.error({ content: error.message || '刷新失败', duration: 4 });
}
};
onMounted(() => {
handleRefresh();
});
</script>
<style scoped>
.admin-logs-container {
max-width: 1400px;
margin: 0 auto;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header > div {
display: flex;
align-items: center;
gap: 8px;
}
.loading-container {
padding: 20px;
}
.logs-content {
font-family: 'Courier New', Courier, monospace;
}
.log-info {
display: flex;
justify-content: space-between;
margin-top: 10px;
font-size: 12px;
color: #909399;
}
.log-textarea :deep(textarea) {
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre;
overflow-x: auto;
word-break: normal;
overflow-wrap: normal;
}
</style>
@@ -0,0 +1,190 @@
<template>
<Layout>
<div class="admin-records-container">
<a-card>
<template #title>
<div class="card-header">
<div>
<UnorderedListOutlined />
<span>所有打卡记录</span>
</div>
<a-button type="primary" @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
</template>
<!-- Desktop table -->
<a-table
v-if="!isMobile"
:data-source="checkInStore.allRecords"
:columns="columns"
:loading="checkInStore.loading"
:pagination="false"
:row-key="record => record.id"
:scroll="{ x: 'max-content' }"
bordered
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'check_in_time'">
{{ formatDateTime(record.check_in_time) }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag>
<a-tag v-else-if="record.status === 'out_of_time'" color="default"
>🕐 时间范围外</a-tag
>
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
<a-tag v-else color="error">❌ 打卡失败</a-tag>
</template>
<template v-else-if="column.key === 'trigger_type'">
<a-tag v-if="record.trigger_type === 'manual'" color="blue">手动</a-tag>
<a-tag v-else-if="record.trigger_type === 'scheduled'" color="cyan">定时</a-tag>
<a-tag v-else-if="record.trigger_type === 'admin'" color="orange">管理员</a-tag>
<a-tag v-else>{{ record.trigger_type }}</a-tag>
</template>
</template>
</a-table>
<!-- Mobile card view -->
<a-space v-else direction="vertical" :size="16" style="width: 100%">
<a-card
v-for="record in checkInStore.allRecords"
:key="record.id"
size="small"
:loading="checkInStore.loading"
>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="ID">{{ record.id }}</a-descriptions-item>
<a-descriptions-item label="用户ID">{{ record.user_id }}</a-descriptions-item>
<a-descriptions-item label="用户邮箱">{{
record.user_email || '-'
}}</a-descriptions-item>
<a-descriptions-item label="任务名称">{{
record.task_name || '-'
}}</a-descriptions-item>
<a-descriptions-item label="接龙ID">{{
record.thread_id || '-'
}}</a-descriptions-item>
<a-descriptions-item label="打卡时间">{{
formatDateTime(record.check_in_time)
}}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag v-if="record.status === 'success'" color="success">✅ 打卡成功</a-tag>
<a-tag v-else-if="record.status === 'out_of_time'" color="default"
>🕐 时间范围外</a-tag
>
<a-tag v-else-if="record.status === 'unknown'" color="warning">❗ 打卡异常</a-tag>
<a-tag v-else color="error">❌ 打卡失败</a-tag>
</a-descriptions-item>
<a-descriptions-item label="触发方式">
<a-tag v-if="record.trigger_type === 'manual'" color="blue">手动</a-tag>
<a-tag v-else-if="record.trigger_type === 'scheduled'" color="cyan">定时</a-tag>
<a-tag v-else-if="record.trigger_type === 'admin'" color="orange">管理员</a-tag>
<a-tag v-else>{{ record.trigger_type }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="消息">{{
record.response_text || '-'
}}</a-descriptions-item>
</a-descriptions>
</a-card>
</a-space>
<!-- Empty state -->
<a-empty
v-if="!checkInStore.loading && checkInStore.allRecords.length === 0"
description="暂无打卡记录"
/>
<!-- Pagination -->
<div v-if="checkInStore.total > 0" class="pagination-container">
<a-pagination
v-model:current="checkInStore.currentPage"
v-model:page-size="checkInStore.pageSize"
:total="checkInStore.total"
:page-size-options="['10', '20', '50', '100']"
show-size-changer
show-quick-jumper
:show-total="total => `${total} 条记录`"
@change="handlePageChange"
@show-size-change="handleSizeChange"
/>
</div>
</a-card>
</div>
</Layout>
</template>
<script setup>
import { onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { UnorderedListOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import { useCheckInStore } from '@/stores/checkIn';
import { useBreakpoint } from '@/composables/useBreakpoint';
import { formatDateTime } from '@/utils/helpers';
const checkInStore = useCheckInStore();
const { isMobile } = useBreakpoint();
// Table columns configuration
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: '用户ID', dataIndex: 'user_id', key: 'user_id', width: 100 },
{ title: '用户邮箱', dataIndex: 'user_email', key: 'user_email', width: 180, ellipsis: true },
{ title: '任务名称', dataIndex: 'task_name', key: 'task_name', width: 150, ellipsis: true },
{ title: '接龙ID', dataIndex: 'thread_id', key: 'thread_id', width: 150, ellipsis: true },
{ title: '打卡时间', dataIndex: 'check_in_time', key: 'check_in_time', width: 180 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 120 },
{ title: '触发方式', dataIndex: 'trigger_type', key: 'trigger_type', width: 120 },
{ title: '消息', dataIndex: 'response_text', key: 'response_text', ellipsis: true },
];
const handleRefresh = async () => {
try {
await checkInStore.fetchAllRecords();
message.success('刷新成功');
} catch (error) {
message.error(error.message || '刷新失败');
}
};
const handlePageChange = () => {
checkInStore.fetchAllRecords();
};
const handleSizeChange = () => {
checkInStore.currentPage = 1;
checkInStore.fetchAllRecords();
};
onMounted(() => {
checkInStore.fetchAllRecords();
});
</script>
<style scoped>
.admin-records-container {
max-width: 1600px;
margin: 0 auto;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header > div {
display: flex;
align-items: center;
gap: 8px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
+181
View File
@@ -0,0 +1,181 @@
<template>
<Layout>
<div class="admin-stats-container">
<a-row :gutter="20">
<a-col :span="24">
<a-card>
<template #title>
<div class="card-header">
<BarChartOutlined />
<span>系统统计信息</span>
<a-button type="primary" @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
</template>
<div v-if="adminStore.loading" class="loading-container">
<a-skeleton :active="true" :paragraph="{ rows: 5 }" />
</div>
<div v-else-if="adminStore.stats" class="stats-content">
<a-row :gutter="[20, 20]">
<a-col :xs="24" :sm="12" :md="6">
<a-statistic title="总用户数" :value="adminStore.totalUsers">
<template #prefix>
<UserOutlined />
</template>
</a-statistic>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-statistic
title="已审批用户数"
:value="adminStore.activeUsers"
:value-style="{ color: '#52c41a' }"
>
<template #prefix>
<CheckOutlined />
</template>
</a-statistic>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-statistic title="总打卡次数" :value="adminStore.totalRecords">
<template #prefix>
<UnorderedListOutlined />
</template>
</a-statistic>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-statistic
title="今日打卡"
:value="adminStore.todayRecords"
:value-style="{ color: '#1890ff' }"
>
<template #prefix>
<CalendarOutlined />
</template>
</a-statistic>
</a-col>
</a-row>
<a-divider />
<a-descriptions title="详细信息" :column="{ xs: 1, sm: 1, md: 2 }" bordered>
<a-descriptions-item label="管理员数量">
{{ adminStore.stats?.users?.admin || 0 }}
</a-descriptions-item>
<a-descriptions-item label="普通用户数量">
{{ adminStore.stats?.users?.regular || 0 }}
</a-descriptions-item>
<a-descriptions-item label="今日成功打卡">
<a-tag color="success">{{
adminStore.stats?.check_in_records?.today_success || 0
}}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="今日失败打卡">
<a-tag color="error">{{
adminStore.stats?.check_in_records?.today_failure || 0
}}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="今日时间范围外">
<a-tag color="default">{{
adminStore.stats?.check_in_records?.today_out_of_time || 0
}}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="今日异常打卡">
<a-tag color="warning">{{
adminStore.stats?.check_in_records?.today_unknown || 0
}}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="总成功率" :span="2">
<a-progress
:percent="calculateSuccessRate()"
:stroke-color="getProgressColor(calculateSuccessRate())"
/>
</a-descriptions-item>
</a-descriptions>
</div>
</a-card>
</a-col>
</a-row>
</div>
</Layout>
</template>
<script setup>
import { onMounted } from 'vue';
import { message } from 'ant-design-vue';
import {
BarChartOutlined,
ReloadOutlined,
UserOutlined,
CheckOutlined,
UnorderedListOutlined,
CalendarOutlined,
} from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import { useAdminStore } from '@/stores/admin';
const adminStore = useAdminStore();
const getProgressColor = percentage => {
if (percentage >= 90) return '#52c41a';
if (percentage >= 70) return '#faad14';
return '#ff4d4f';
};
const calculateSuccessRate = () => {
const total = adminStore.stats?.check_in_records?.total || 0;
const todaySuccess = adminStore.stats?.check_in_records?.today_success || 0;
if (total === 0) return 0;
// Calculate success rate based on all records (not just today)
// We need to get success count from backend or calculate differently
// For now, use today's success rate as approximation
const todayTotal = adminStore.stats?.check_in_records?.today || 0;
if (todayTotal === 0) return 0;
return Math.round((todaySuccess / todayTotal) * 100);
};
const handleRefresh = async () => {
try {
await adminStore.fetchStats();
message.success('刷新成功');
} catch (error) {
message.error(error.message || '刷新失败');
}
};
onMounted(() => {
adminStore.fetchStats();
});
</script>
<style scoped>
.admin-stats-container {
max-width: 1200px;
margin: 0 auto;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.card-header :deep(.ant-btn) {
margin-left: auto;
}
.loading-container {
padding: 20px;
}
.stats-content {
padding: 20px 0;
}
</style>
@@ -0,0 +1,796 @@
<template>
<Layout>
<div class="templates-view">
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold text-gradient mb-2">任务模板管理</h1>
<p class="text-on-surface-variant">JSON 映射架构 - 配置即结构</p>
</div>
<button class="md3-button-filled" @click="showCreateDialog">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
新建模板
</button>
</div>
</div>
<!-- Templates List -->
<div v-if="loading && templates.length === 0" class="space-y-4">
<a-card v-for="i in 3" :key="i" class="md3-card">
<a-skeleton :active="true" :paragraph="{ rows: 2 }" />
</a-card>
</div>
<a-card
v-else-if="templates.length === 0"
class="md3-card text-center"
style="padding: 48px 20px"
>
<svg
class="w-20 h-20 mx-auto text-on-surface-variant opacity-30 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 class="text-xl font-semibold text-on-surface mb-2">暂无模板</h3>
<p class="text-on-surface-variant mb-4">创建第一个模板让用户更轻松地创建打卡任务</p>
<button class="md3-button-filled" @click="showCreateDialog">新建模板</button>
</a-card>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<a-card
v-for="template in templates"
:key="template.id"
class="md3-card hover:shadow-xl transition-all animate-slide-up"
>
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<h3 class="text-lg font-semibold text-on-surface mb-2">{{ template.name }}</h3>
<a-divider style="margin: 8px 0" />
<p class="text-sm text-on-surface-variant mb-2">
{{ template.description || '无描述' }}
</p>
<span :class="template.is_active ? 'md3-badge-success' : 'md3-badge-info'">
{{ template.is_active ? '已启用' : '已禁用' }}
</span>
</div>
</div>
<div class="mt-3 pt-3 border-t border-outline-variant space-y-2">
<!-- 第一行预览在左半部分居中编辑在右半部分居中 -->
<div class="grid grid-cols-2 gap-2">
<div class="flex justify-center">
<button
class="md3-button-outlined text-sm flex-shrink-0"
@click="previewTemplate(template)"
>
<svg
class="w-4 h-4 mr-1.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
预览
</button>
</div>
<div class="flex justify-center">
<button
class="md3-button-outlined text-sm flex-shrink-0"
@click="editTemplate(template)"
>
<svg
class="w-4 h-4 mr-1.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
编辑
</button>
</div>
</div>
<!-- 第二行删除在右半部分居中与编辑对齐 -->
<div class="grid grid-cols-2 gap-2">
<div></div>
<div class="flex justify-center">
<button
class="md3-button-outlined text-sm !text-red-600 dark:!text-red-500 !border-red-600 dark:!border-red-500 hover:!bg-red-50 dark:hover:!bg-red-900/20 flex-shrink-0"
@click="deleteTemplate(template)"
>
<svg
class="w-4 h-4 mr-1.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
删除
</button>
</div>
</div>
</div>
</a-card>
</div>
<!-- Create/Edit Dialog -->
<a-modal
v-model:open="dialogVisible"
:title="dialogMode === 'create' ? '新建模板' : '编辑模板'"
:width="dialogWidth"
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
:mask-closable="false"
class="template-editor-modal"
>
<a-form ref="formRef" :model="formData" layout="vertical">
<a-form-item label="模板名称" required>
<a-input
v-model:value="formData.name"
placeholder="请输入模板名称"
:maxlength="100"
show-count
/>
</a-form-item>
<a-form-item label="模板描述">
<a-textarea
v-model:value="formData.description"
:rows="2"
placeholder="请输入模板描述"
/>
</a-form-item>
<a-form-item label="父模板">
<a-select
v-model:value="formData.parent_id"
placeholder="可选,继承父模板的字段配置"
allow-clear
style="width: 100%"
>
<a-select-option
v-for="template in availableParentTemplates"
:key="template.id"
:value="template.id"
:disabled="template.id === currentTemplateId"
>
{{ template.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="是否启用">
<a-switch v-model:checked="formData.is_active" />
</a-form-item>
<a-divider orientation="left">
<span class="text-lg font-bold">Payload 配置 (JSON 映射)</span>
</a-divider>
<a-alert
message="💡 JSON 映射架构"
type="info"
:closable="false"
show-icon
class="mb-4"
>
<template #description>
<p class="text-sm mb-2">
<strong>配置即结构</strong>模板配置完全映射到生成的 Payload 结构
</p>
<p class="text-sm mb-2"><strong>字段名保持原样</strong>不进行任何大小写转换</p>
<p class="text-sm"><strong>ThreadId</strong> 由用户填写无需在模板中配置</p>
</template>
</a-alert>
<!-- 字段配置编辑器 -->
<div class="field-config-editor">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-on-surface">字段配置</h3>
<a-dropdown>
<a-button type="primary">
添加字段
<DownOutlined />
</a-button>
<template #overlay>
<a-menu @click="handleAddField">
<a-menu-item key="field">
<svg
class="w-4 h-4 inline mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
普通字段
</a-menu-item>
<a-menu-item key="array">
<svg
class="w-4 h-4 inline mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
数组字段
</a-menu-item>
<a-menu-item key="object">
<svg
class="w-4 h-4 inline mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
对象字段
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<!-- 递归渲染字段树 -->
<div
v-if="Object.keys(formData.field_config).length === 0"
class="text-center py-12 border-2 border-dashed border-outline-variant rounded-lg bg-surface-container"
>
<svg
class="w-16 h-16 mx-auto text-on-surface-variant opacity-40 mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 class="text-lg font-semibold text-on-surface mb-2">暂无字段配置</h3>
<p class="text-sm text-on-surface-variant">点击上方"添加字段"开始配置模板</p>
</div>
<div v-else class="space-y-3">
<FieldTreeNode
v-for="(config, key) in formData.field_config"
:key="`${fieldConfigVersion}-${key}`"
:field-key="key"
:field-config="config"
:path="[key]"
@update="event => updateField(event.path, event.value)"
@delete="path => deleteField(path)"
@move="event => moveField(event.path, event.direction)"
/>
</div>
</div>
<!-- JSON 预览 -->
<a-divider orientation="left">
<span class="text-lg font-bold">JSON 预览</span>
</a-divider>
<div
class="bg-surface-container text-green-400 p-4 rounded-lg font-mono text-sm overflow-auto max-h-96"
>
<pre>{{ JSON.stringify(formData.field_config, null, 2) }}</pre>
</div>
</a-form>
<template #footer>
<a-button @click="dialogVisible = false">取消</a-button>
<a-button type="primary" :loading="submitting" @click="handleSubmit">
{{ dialogMode === 'create' ? '创建' : '更新' }}
</a-button>
</template>
</a-modal>
<!-- Add Field Dialog -->
<a-modal
v-model:open="addFieldDialogVisible"
:title="`添加${fieldTypeLabel}`"
:width="isMobile ? '100%' : 500"
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
>
<a-form @submit.prevent="confirmAddField">
<a-form-item label="字段名">
<a-input
v-model:value="newFieldName"
placeholder="例如: Id, Group1, DateTarget"
@keyup.enter="confirmAddField"
/>
<span class="text-xs text-on-surface-variant mt-1 block">
💡 字段名将保持原样不会进行大小写转换
</span>
</a-form-item>
</a-form>
<template #footer>
<a-button @click="addFieldDialogVisible = false">取消</a-button>
<a-button type="primary" @click="confirmAddField">确定</a-button>
</template>
</a-modal>
<!-- Preview Dialog -->
<a-modal
v-model:open="previewDialogVisible"
title="模板预览"
:width="previewDialogWidth"
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
>
<div v-if="previewData" class="space-y-4">
<div class="bg-surface-container rounded p-4">
<h4 class="font-semibold mb-2 text-on-surface">生成的 Payload使用默认值</h4>
<pre
class="text-xs bg-surface text-on-surface p-3 rounded border border-outline-variant overflow-auto max-h-96"
>{{ JSON.stringify(previewData.preview_payload, null, 2) }}</pre
>
</div>
<div class="bg-surface-container rounded p-4">
<h4 class="font-semibold mb-2 text-on-surface">字段配置</h4>
<pre
class="text-xs bg-surface text-on-surface p-3 rounded border border-outline-variant overflow-auto max-h-96"
>{{ JSON.stringify(previewData.field_config, null, 2) }}</pre
>
</div>
</div>
<template #footer>
<a-button @click="previewDialogVisible = false">关闭</a-button>
</template>
</a-modal>
</div>
</div>
</Layout>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { DownOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import FieldTreeNode from '@/components/FieldTreeNode.vue';
import { useTemplateStore } from '@/stores/template';
import { useBreakpoint } from '@/composables/useBreakpoint';
const templateStore = useTemplateStore();
const { isMobile, isTablet } = useBreakpoint();
// 计算对话框宽度 - 响应式设计
const dialogWidth = computed(() => {
if (isMobile.value) return '100%';
if (isTablet.value) return 900;
return 1200;
});
const previewDialogWidth = computed(() => {
if (isMobile.value) return '100%';
if (isTablet.value) return 800;
return 1000;
});
const templates = ref([]);
const loading = ref(false);
const dialogVisible = ref(false);
const dialogMode = ref('create');
const currentTemplateId = ref(null);
const submitting = ref(false);
const previewDialogVisible = ref(false);
const previewData = ref(null);
const addFieldDialogVisible = ref(false);
const newFieldName = ref('');
const newFieldType = ref('field');
const fieldConfigVersion = ref(0); // 用于强制刷新字段列表
const formData = ref({
name: '',
description: '',
parent_id: null,
is_active: true,
field_config: {},
});
const availableParentTemplates = computed(() => {
if (dialogMode.value === 'create') {
return templates.value;
}
return templates.value.filter(t => t.id !== currentTemplateId.value);
});
const fieldTypeLabel = computed(() => {
const labels = {
field: '普通字段',
array: '数组字段',
object: '对象字段',
};
return labels[newFieldType.value] || '字段';
});
function createDefaultFieldConfig() {
return {
display_name: '',
field_type: 'text',
default_value: '',
required: false,
hidden: false,
placeholder: '',
value_type: 'string',
options: [],
};
}
const fetchTemplates = async () => {
loading.value = true;
try {
templates.value = await templateStore.fetchTemplates();
} catch (error) {
message.error(error.message || '获取模板列表失败');
} finally {
loading.value = false;
}
};
const showCreateDialog = () => {
dialogMode.value = 'create';
currentTemplateId.value = null;
formData.value = {
name: '',
description: '',
parent_id: null,
is_active: true,
field_config: {},
};
dialogVisible.value = true;
};
const editTemplate = template => {
dialogMode.value = 'edit';
currentTemplateId.value = template.id;
const fieldConfig = JSON.parse(template.field_config);
formData.value = {
name: template.name,
description: template.description || '',
parent_id: template.parent_id || null,
is_active: template.is_active,
field_config: fieldConfig,
};
dialogVisible.value = true;
};
const handleSubmit = async () => {
if (!formData.value.name) {
message.warning('请输入模板名称');
return;
}
submitting.value = true;
try {
const templateData = {
name: formData.value.name,
description: formData.value.description,
parent_id: formData.value.parent_id,
is_active: formData.value.is_active,
field_config: JSON.stringify(formData.value.field_config),
};
if (dialogMode.value === 'create') {
await templateStore.createTemplate(templateData);
message.success('模板创建成功');
} else {
await templateStore.updateTemplate(currentTemplateId.value, templateData);
message.success('模板更新成功');
}
dialogVisible.value = false;
await fetchTemplates();
} catch (error) {
message.error(error.message || '操作失败');
} finally {
submitting.value = false;
}
};
const deleteTemplate = template => {
Modal.confirm({
title: '确认删除',
content: `确定要删除模板"${template.name}"吗?此操作不可撤销。`,
okText: '删除',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
await templateStore.deleteTemplate(template.id);
message.success('模板删除成功');
await fetchTemplates();
} catch (error) {
message.error(error.message || '删除失败');
}
},
});
};
const previewTemplate = async template => {
try {
previewData.value = await templateStore.previewTemplate(template.id);
previewDialogVisible.value = true;
} catch (error) {
message.error(error.message || '预览失败');
}
};
const handleAddField = ({ key }) => {
newFieldType.value = key;
newFieldName.value = '';
addFieldDialogVisible.value = true;
};
const confirmAddField = () => {
if (!newFieldName.value) {
message.warning('请输入字段名');
return;
}
if (formData.value.field_config[newFieldName.value]) {
message.warning('该字段已存在');
return;
}
// 创建一个新对象,确保新字段被添加到末尾
const newConfig = { ...formData.value.field_config };
// 创建对应类型的字段
if (newFieldType.value === 'field') {
newConfig[newFieldName.value] = createDefaultFieldConfig();
} else if (newFieldType.value === 'array') {
newConfig[newFieldName.value] = [];
} else if (newFieldType.value === 'object') {
newConfig[newFieldName.value] = {};
}
// 替换整个 field_config 以确保顺序和响应性
formData.value.field_config = newConfig;
fieldConfigVersion.value++; // 强制刷新
addFieldDialogVisible.value = false;
message.success('字段添加成功');
};
const updateField = (path, newValue) => {
// 通过路径更新嵌套字段
let target = formData.value.field_config;
for (let i = 0; i < path.length - 1; i++) {
target = target[path[i]];
}
target[path[path.length - 1]] = newValue;
};
const deleteField = path => {
// 通过路径删除嵌套字段
if (!path || path.length === 0) return;
// 创建一个新的 field_config 副本以触发响应性
const newConfig = JSON.parse(JSON.stringify(formData.value.field_config));
let target = newConfig;
// 导航到父对象/数组
for (let i = 0; i < path.length - 1; i++) {
if (!target || typeof target !== 'object') {
console.error('❌ 删除失败:路径无效', path, 'at index', i);
return;
}
target = target[path[i]];
}
if (!target || typeof target !== 'object') {
console.error('❌ 删除失败:父对象不存在', path);
return;
}
const lastKey = path[path.length - 1];
// 如果父容器是数组,使用 splice;如果是对象,使用 delete
if (Array.isArray(target)) {
target.splice(lastKey, 1);
} else {
delete target[lastKey];
}
// 替换整个 field_config 以触发 Vue 响应性
formData.value.field_config = newConfig;
fieldConfigVersion.value++; // 强制刷新
};
const moveField = (path, direction) => {
// 通过路径移动字段
if (!path || path.length === 0) return;
// 如果是根级别字段,直接重建整个 field_config
if (path.length === 1) {
const fieldKey = path[0];
const keys = Object.keys(formData.value.field_config);
const currentIndex = keys.indexOf(fieldKey);
if (currentIndex === -1) {
console.error('❌ 字段不存在:', fieldKey);
return;
}
let targetIndex = currentIndex;
if (direction === 'up' && currentIndex > 0) {
targetIndex = currentIndex - 1;
} else if (direction === 'down' && currentIndex < keys.length - 1) {
targetIndex = currentIndex + 1;
} else {
return;
}
// 交换键的位置
const temp = keys[currentIndex];
keys[currentIndex] = keys[targetIndex];
keys[targetIndex] = temp;
// 重建整个 field_config - 使用深拷贝确保完全新的对象
const newConfig = {};
keys.forEach(key => {
// 深拷贝每个字段配置
newConfig[key] = JSON.parse(JSON.stringify(formData.value.field_config[key]));
});
// 替换整个 formData,而不只是 field_config
formData.value = {
...formData.value,
field_config: newConfig,
};
fieldConfigVersion.value++;
return;
}
// 嵌套字段的情况(保留原有逻辑)
const newConfig = JSON.parse(JSON.stringify(formData.value.field_config));
// 导航到目标的父容器
let parent = newConfig;
for (let i = 0; i < path.length - 1; i++) {
parent = parent[path[i]];
if (!parent) {
console.error('❌ 路径无效:', path);
return;
}
}
const fieldKey = path[path.length - 1];
if (Array.isArray(parent)) {
// 数组情况:直接交换元素
const index = Number(fieldKey);
if (direction === 'up' && index > 0) {
const temp = parent[index];
parent[index] = parent[index - 1];
parent[index - 1] = temp;
} else if (direction === 'down' && index < parent.length - 1) {
const temp = parent[index];
parent[index] = parent[index + 1];
parent[index + 1] = temp;
} else {
return;
}
} else {
// 对象情况:重建对象以改变键顺序
const keys = Object.keys(parent);
const currentIndex = keys.indexOf(fieldKey);
if (currentIndex === -1) {
console.error('❌ 字段不存在:', fieldKey);
return;
}
let targetIndex = currentIndex;
if (direction === 'up' && currentIndex > 0) {
targetIndex = currentIndex - 1;
} else if (direction === 'down' && currentIndex < keys.length - 1) {
targetIndex = currentIndex + 1;
} else {
return;
}
// 交换键数组中的位置
const temp = keys[currentIndex];
keys[currentIndex] = keys[targetIndex];
keys[targetIndex] = temp;
// 重建父对象
const reorderedParent = {};
keys.forEach(key => {
reorderedParent[key] = parent[key];
});
// 替换父容器的所有属性
Object.keys(parent).forEach(key => delete parent[key]);
Object.assign(parent, reorderedParent);
}
// 强制触发响应性更新
formData.value.field_config = newConfig;
fieldConfigVersion.value++;
};
onMounted(() => {
fetchTemplates();
});
</script>
<style scoped>
.field-config-editor {
min-height: 200px;
}
.template-editor-modal :deep(.ant-modal-body) {
max-height: 70vh;
overflow-y: auto;
}
</style>
+607
View File
@@ -0,0 +1,607 @@
<template>
<Layout>
<div class="admin-users-container">
<a-card>
<template #title>
<div class="card-header">
<div>
<UserOutlined />
<span>用户管理</span>
</div>
<a-space class="actions">
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
创建用户
</a-button>
<a-button @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
</template>
<!-- Tab 切换 -->
<a-tabs v-model:active-key="activeTab" @change="handleTabChange">
<!-- 待审批用户 Tab -->
<a-tab-pane key="pending" tab="待审批用户">
<!-- 桌面端表格 -->
<a-table
v-if="!isMobile"
:data-source="pendingUsers"
:columns="pendingColumns"
:loading="loading"
:row-key="record => record.id"
:scroll="{ x: 'max-content' }"
bordered
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button type="primary" size="small" @click="handleApprove(record)">
通过
</a-button>
<a-button danger size="small" @click="handleReject(record)"> 拒绝 </a-button>
</a-space>
</template>
</template>
</a-table>
<!-- 移动端卡片视图 -->
<a-space v-else direction="vertical" :size="16" style="width: 100%">
<a-card v-for="user in pendingUsers" :key="user.id" size="small" :loading="loading">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="ID">{{ user.id }}</a-descriptions-item>
<a-descriptions-item label="用户名">{{ user.alias }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ user.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="注册时间">{{
formatDateTime(user.created_at)
}}</a-descriptions-item>
</a-descriptions>
<a-space class="mt-3" style="width: 100%">
<a-button type="primary" size="small" block @click="handleApprove(user)"
>通过</a-button
>
<a-button danger size="small" block @click="handleReject(user)">拒绝</a-button>
</a-space>
</a-card>
<a-empty v-if="!loading && pendingUsers.length === 0" description="暂无数据" />
</a-space>
</a-tab-pane>
<!-- 所有用户 Tab -->
<a-tab-pane key="all" tab="所有用户">
<!-- 桌面端表格 -->
<a-table
v-if="!isMobile"
:data-source="userStore.users"
:columns="allColumns"
:loading="loading"
:row-key="record => record.id"
:row-selection="rowSelection"
:scroll="{ x: 'max-content' }"
bordered
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'role'">
<a-tag :color="record.role === 'admin' ? 'error' : 'blue'">
{{ record.role === 'admin' ? '管理员' : '用户' }}
</a-tag>
</template>
<template v-else-if="column.key === 'is_approved'">
<a-tag :color="record.is_approved ? 'success' : 'warning'">
{{ record.is_approved ? '已审批' : '待审批' }}
</a-tag>
</template>
<template v-else-if="column.key === 'jwt_exp'">
{{
record.jwt_exp && record.jwt_exp !== '0'
? formatDateTime(parseInt(record.jwt_exp) * 1000)
: '-'
}}
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button type="primary" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-button danger size="small" @click="handleDelete(record)"> 删除 </a-button>
</a-space>
</template>
</template>
</a-table>
<!-- 移动端卡片视图 -->
<a-space v-else direction="vertical" :size="16" style="width: 100%">
<a-card
v-for="user in userStore.users"
:key="user.id"
size="small"
:loading="loading"
>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="ID">{{ user.id }}</a-descriptions-item>
<a-descriptions-item label="用户名">{{ user.alias }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ user.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="角色">
<a-tag :color="user.role === 'admin' ? 'error' : 'blue'">
{{ user.role === 'admin' ? '管理员' : '用户' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="审批状态">
<a-tag :color="user.is_approved ? 'success' : 'warning'">
{{ user.is_approved ? '已审批' : '待审批' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="Token过期">
{{
user.jwt_exp && user.jwt_exp !== '0'
? formatDateTime(parseInt(user.jwt_exp) * 1000)
: '-'
}}
</a-descriptions-item>
<a-descriptions-item label="创建时间">{{
formatDateTime(user.created_at)
}}</a-descriptions-item>
</a-descriptions>
<a-space class="mt-3" style="width: 100%">
<a-button type="primary" size="small" block @click="handleEdit(user)"
>编辑</a-button
>
<a-button danger size="small" block @click="handleDelete(user)">删除</a-button>
</a-space>
</a-card>
</a-space>
<!-- 批量操作 -->
<div v-if="selectedUsers.length > 0" class="batch-actions">
<a-alert
:message="`已选择 ${selectedUsers.length} 个用户`"
type="info"
:closable="false"
>
<template #description>
<a-space style="margin-top: 10px">
<a-button type="primary" size="small" @click="handleBatchApprove">
批量审批
</a-button>
<a-button danger size="small" @click="handleBatchDelete"> 批量删除 </a-button>
</a-space>
</template>
</a-alert>
</div>
</a-tab-pane>
</a-tabs>
</a-card>
<!-- 创建/编辑用户对话框 -->
<a-modal
v-model:open="dialogVisible"
:title="dialogMode === 'create' ? '创建用户' : '编辑用户'"
:width="isMobile ? '100%' : 600"
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
>
<a-form ref="formRef" :model="formData" :rules="formRules" layout="vertical">
<a-form-item label="用户名" name="alias">
<a-input v-model:value="formData.alias" placeholder="请输入用户名" />
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="formData.email" placeholder="请输入邮箱" />
</a-form-item>
<a-form-item label="角色" name="role">
<a-select v-model:value="formData.role" placeholder="请选择角色">
<a-select-option value="user">用户</a-select-option>
<a-select-option value="admin">管理员</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="审批状态" name="is_approved">
<a-switch v-model:checked="formData.is_approved" />
<span class="form-hint">是否已审批通过</span>
</a-form-item>
<a-form-item label="密码" name="password">
<a-input-password
v-model:value="formData.password"
:placeholder="dialogMode === 'create' ? '请输入密码' : '留空则不修改密码'"
/>
<span v-if="dialogMode === 'edit'" class="form-hint"> 留空则不修改密码 </span>
</a-form-item>
<a-form-item v-if="dialogMode === 'edit'" label="重置密码">
<a-switch v-model:checked="formData.reset_password" />
<span v-if="formData.reset_password" class="form-hint-danger">
⚠️ 将重置为默认密码
</span>
</a-form-item>
</a-form>
<template #footer>
<a-button @click="dialogVisible = false">取消</a-button>
<a-button type="primary" :loading="submitting" @click="handleSubmit"> 确定 </a-button>
</template>
</a-modal>
</div>
</Layout>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { UserOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import Layout from '@/components/Layout.vue';
import { useBreakpoint } from '@/composables/useBreakpoint';
import { useUserStore } from '@/stores/user';
import { adminAPI } from '@/api/index';
const userStore = useUserStore();
const { isMobile } = useBreakpoint();
// 状态
const loading = ref(false);
const activeTab = ref('all'); // 默认展示所有用户
const pendingUsers = ref([]);
const selectedUsers = ref([]);
const selectedRowKeys = ref([]);
const dialogVisible = ref(false);
const dialogMode = ref('create');
const submitting = ref(false);
// 表单
const formRef = ref(null);
const formData = ref({
alias: '',
role: 'user',
is_approved: true,
email: '',
password: '',
reset_password: false,
});
// 表单验证规则
const formRules = {
alias: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
],
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
};
// 时间格式化
const formatDateTime = timestamp => {
if (!timestamp) return '-';
const date = new Date(timestamp);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
// 待审批用户表格列
const pendingColumns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: '用户名', dataIndex: 'alias', key: 'alias', ellipsis: true },
{ title: '邮箱', dataIndex: 'email', key: 'email', ellipsis: true },
{ title: '注册时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 200, fixed: 'right' },
];
// 所有用户表格列
const allColumns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: '用户名', dataIndex: 'alias', key: 'alias', ellipsis: true },
{ title: '邮箱', dataIndex: 'email', key: 'email', ellipsis: true },
{ title: '角色', dataIndex: 'role', key: 'role', width: 100 },
{ title: '审批状态', dataIndex: 'is_approved', key: 'is_approved', width: 100 },
{ title: 'Token 过期时间', dataIndex: 'jwt_exp', key: 'jwt_exp', width: 180 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 200, fixed: 'right' },
];
// 行选择配置
const rowSelection = {
selectedRowKeys: selectedRowKeys,
onChange: (keys, rows) => {
selectedRowKeys.value = keys;
selectedUsers.value = rows;
},
};
// 获取待审批用户
const fetchPendingUsers = async () => {
loading.value = true;
try {
pendingUsers.value = await adminAPI.getPendingUsers();
} catch (error) {
message.error(error.message || '获取待审批用户失败');
} finally {
loading.value = false;
}
};
// Tab 切换
const handleTabChange = tab => {
if (tab === 'pending') {
fetchPendingUsers();
} else {
handleRefresh();
}
};
// 审批通过用户
const handleApprove = async user => {
Modal.confirm({
title: '审批确认',
content: `确认通过用户 "${user.alias}" 的审批吗?`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
try {
await adminAPI.approveUser(user.id);
message.success('审批成功');
fetchPendingUsers();
} catch (error) {
message.error(error.message || '审批失败');
}
},
});
};
// 拒绝用户
const handleReject = async user => {
Modal.confirm({
title: '拒绝确认',
content: `确认拒绝用户 "${user.alias}" 的申请吗?拒绝后将删除该用户。`,
okText: '确认',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
await adminAPI.rejectUser(user.id);
message.success('已拒绝并删除用户');
fetchPendingUsers();
} catch (error) {
message.error(error.message || '操作失败');
}
},
});
};
// 刷新数据
const handleRefresh = async () => {
if (activeTab.value === 'pending') {
await fetchPendingUsers();
} else {
loading.value = true;
try {
await userStore.fetchUsers();
message.success('刷新成功');
} catch (error) {
message.error(error.message || '刷新失败');
} finally {
loading.value = false;
}
}
};
// 创建用户
const handleCreate = () => {
dialogMode.value = 'create';
formData.value = {
alias: '',
role: 'user',
is_approved: true,
email: '',
password: '',
reset_password: false,
};
dialogVisible.value = true;
};
// 编辑用户
const handleEdit = user => {
dialogMode.value = 'edit';
formData.value = {
id: user.id,
alias: user.alias,
role: user.role,
is_approved: user.is_approved,
email: user.email || '',
password: '',
reset_password: false,
};
dialogVisible.value = true;
};
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
submitting.value = true;
// 检查密码设置冲突
if (dialogMode.value === 'edit' && formData.value.password && formData.value.reset_password) {
message.warning('不能同时设置新密码和重置密码,请选择其一');
submitting.value = false;
return;
}
if (dialogMode.value === 'create') {
// 创建用户时,只发送后端 UserCreate schema 接受的字段
const createData = {
alias: formData.value.alias,
role: formData.value.role,
is_approved: formData.value.is_approved,
};
// 如果有邮箱,添加邮箱字段(空字符串转为 null)
if (formData.value.email && formData.value.email.trim()) {
createData.email = formData.value.email.trim();
}
// 如果有密码,添加密码字段
if (formData.value.password && formData.value.password.trim()) {
createData.password = formData.value.password.trim();
}
await userStore.createUser(createData);
message.success('创建成功');
} else {
// 编辑用户时,处理空字符串字段
const updateData = {
...formData.value,
// 将空字符串的邮箱转为 null
email:
formData.value.email && formData.value.email.trim() ? formData.value.email.trim() : null,
// 将空字符串的密码转为 null
password:
formData.value.password && formData.value.password.trim()
? formData.value.password.trim()
: null,
};
await userStore.updateUser(formData.value.id, updateData);
message.success('更新成功');
}
dialogVisible.value = false;
await handleRefresh();
} catch (error) {
message.error(error.message || '操作失败');
} finally {
submitting.value = false;
}
};
// 删除用户
const handleDelete = user => {
Modal.confirm({
title: '警告',
content: `确定要删除用户 "${user.alias}" `,
okText: '确定',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
await userStore.deleteUser(user.id);
message.success('删除成功');
await handleRefresh();
} catch (error) {
message.error(error.message || '删除失败');
}
},
});
};
// 批量审批
const handleBatchApprove = () => {
Modal.confirm({
title: '批量审批确认',
content: `确认批量审批 ${selectedUsers.value.length} 个用户吗`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
const userIds = selectedUsers.value.map(u => u.id);
let successCount = 0;
let failureCount = 0;
for (const userId of userIds) {
try {
await adminAPI.approveUser(userId);
successCount++;
} catch {
failureCount++;
}
}
message.success(`批量审批完成成功 ${successCount}失败 ${failureCount}`);
await handleRefresh();
},
});
};
// 批量删除
const handleBatchDelete = () => {
Modal.confirm({
title: '批量删除警告',
content: `确定要删除选中的 ${selectedUsers.value.length} 个用户吗此操作不可恢复`,
okText: '确定',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
const userIds = selectedUsers.value.map(u => u.id);
let successCount = 0;
let failureCount = 0;
for (const userId of userIds) {
try {
await userStore.deleteUser(userId);
successCount++;
} catch {
failureCount++;
}
}
message.success(`批量删除完成成功 ${successCount}失败 ${failureCount}`);
await handleRefresh();
},
});
};
onMounted(() => {
// 默认加载所有用户
handleRefresh();
});
</script>
<style scoped>
.admin-users-container {
max-width: 1600px;
margin: 0 auto;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header > div {
display: flex;
align-items: center;
gap: 8px;
}
.batch-actions {
margin-top: 15px;
}
.form-hint {
margin-left: 10px;
font-size: 12px;
color: #909399;
}
.form-hint-danger {
color: #f56c6c;
font-weight: 500;
display: block;
margin-left: 0;
margin-top: 4px;
}
.mt-3 {
margin-top: 12px;
}
</style>
+98
View File
@@ -0,0 +1,98 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class', // 启用 class 模式的暗色模式
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
// Material Design 3 color palette
primary: {
50: '#e8f5e9',
100: '#c8e6c9',
200: '#a5d6a7',
300: '#81c784',
400: '#66bb6a',
500: '#4caf50',
600: '#43a047',
700: '#388e3c',
800: '#2e7d32',
900: '#1b5e20',
},
secondary: {
50: '#e3f2fd',
100: '#bbdefb',
200: '#90caf9',
300: '#64b5f6',
400: '#42a5f5',
500: '#2196f3',
600: '#1e88e5',
700: '#1976d2',
800: '#1565c0',
900: '#0d47a1',
},
accent: {
50: '#fff3e0',
100: '#ffe0b2',
200: '#ffcc80',
300: '#ffb74d',
400: '#ffa726',
500: '#ff9800',
600: '#fb8c00',
700: '#f57c00',
800: '#ef6c00',
900: '#e65100',
},
surface: {
50: '#fafafa',
100: '#f5f5f5',
200: '#eeeeee',
300: '#e0e0e0',
400: '#bdbdbd',
500: '#9e9e9e',
600: '#757575',
700: '#616161',
800: '#424242',
900: '#212121',
},
},
borderRadius: {
// Material Design 3 Shape System
'md3-xs': '4px', // Extra Small - chips, small tags
'md3-sm': '8px', // Small - text fields, small components
md3: '12px', // Medium - cards, buttons (default)
'md3-lg': '16px', // Large - large cards, dialogs
'md3-xl': '28px', // Extra Large - fully rounded buttons
'md3-full': '9999px', // Full - pill shape
},
boxShadow: {
// Material Design 3 Elevation System (official spec)
'md3-0': 'none',
'md3-1': '0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15)',
'md3-2': '0px 1px 2px 0px rgba(0, 0, 0, 0.3), 0px 2px 6px 2px rgba(0, 0, 0, 0.15)',
'md3-3': '0px 1px 3px 0px rgba(0, 0, 0, 0.3), 0px 4px 8px 3px rgba(0, 0, 0, 0.15)',
'md3-4': '0px 2px 3px 0px rgba(0, 0, 0, 0.3), 0px 6px 10px 4px rgba(0, 0, 0, 0.15)',
'md3-5': '0px 4px 4px 0px rgba(0, 0, 0, 0.3), 0px 8px 12px 6px rgba(0, 0, 0, 0.15)',
},
animation: {
'fade-in': 'fadeIn 0.3s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
slideDown: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [],
};
+45
View File
@@ -0,0 +1,45 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'path';
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: '0.0.0.0', // Listen on all network interfaces for LAN access
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks(id) {
// Manual chunking for better dependency management
if (id.includes('node_modules')) {
// Ant Design Vue
if (id.includes('ant-design-vue')) {
return 'ant-design-vue';
}
// Group all other vendor code together
return 'vendor';
}
},
},
},
},
});