feat(webui): totally convert to new webui

This commit is contained in:
2026-05-04 20:33:22 +08:00
parent 741d328430
commit fa07b340e7
129 changed files with 1938 additions and 17824 deletions
-1
View File
@@ -60,4 +60,3 @@ TOKEN_CHECK_INTERVAL_MINUTES=30
# 会话数据清理间隔(小时)
SESSION_CLEANUP_INTERVAL_HOURS=24
+4 -3
View File
@@ -53,9 +53,10 @@ Thumbs.db
apps/frontend/node_modules/
apps/frontend/dist/
apps/frontend/.vite/
apps/new-frontend/node_modules/
apps/new-frontend/dist/
apps/new-frontend/.vite/
# 本地验证产物
.playwright-cli/
output/
.claude
.codex
+5 -4
View File
@@ -19,7 +19,7 @@
## 技术栈
**后端**: FastAPI + SQLAlchemy + APScheduler + Selenium
**前端**: Vue 3 + Ant Design Vue + Pinia
**前端**: Vue 3 + TypeScript + shadcn-vue + Tailwind
**数据库**: SQLite
## 快速开始
@@ -28,7 +28,8 @@
- Python 3.9+
- uv
- Node.js 16+
- Node.js 20+
- pnpm
- Chrome 浏览器
### 安装运行
@@ -40,8 +41,8 @@ uv run python main.py backend
# 前端
cd apps/frontend
npm install
npm run dev
pnpm install
pnpm dev
# 创建管理员
uv run python apps/backend/scripts/create_admin.py
-2
View File
@@ -1,2 +0,0 @@
# API Base URL (Development)
VITE_API_BASE_URL=http://localhost:8000
-3
View File
@@ -1,3 +0,0 @@
# API Base URL (Production)
# 留空,让 API 请求使用相对路径(由 Nginx 转发)
VITE_API_BASE_URL=
+3 -3
View File
@@ -1,4 +1,4 @@
node_modules
dist
.DS_Store
*.local
node_modules
pnpm-lock.yaml
components.json
-9
View File
@@ -1,9 +0,0 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"arrowParens": "avoid",
"endOfLine": "auto"
}
+26
View File
@@ -0,0 +1,26 @@
# CheckIn App Frontend
Vue 3 + TypeScript frontend for CheckIn App. This is the supported frontend source tree.
## Commands
```bash
pnpm install
pnpm dev
pnpm lint:check
pnpm test
pnpm build
```
The development server runs on port `3000` and proxies `/api` to `http://127.0.0.1:8000`.
## Structure
- `src/app/`: auth state, router, theme state
- `src/api/`: typed API helpers and fetch client
- `src/components/ui/`: shadcn-vue primitives
- `src/components/templates/`: structured template field editor
- `src/views/`: route-owned pages
- `src/style.css`: Tailwind v4 and shadcn token setup
Prefer shadcn-vue primitives, lucide icons, semantic tokens, and the local helpers in `src/components/ui.ts` for repeated surfaces. Keep route-local Tailwind where it expresses one-off layout or dense operational structure.
+54 -27
View File
@@ -1,38 +1,65 @@
import js from '@eslint/js';
import pluginVue from 'eslint-plugin-vue';
import prettierConfig from '@vue/eslint-config-prettier';
import js from '@eslint/js'
import prettierConfig from '@vue/eslint-config-prettier'
import tsParser from '@typescript-eslint/parser'
import pluginVue from 'eslint-plugin-vue'
import vueParser from 'vue-eslint-parser'
const browserGlobals = {
AbortController: 'readonly',
DOMException: 'readonly',
Headers: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
console: 'readonly',
document: 'readonly',
fetch: 'readonly',
localStorage: 'readonly',
window: 'readonly',
}
export default [
{
ignores: ['node_modules', 'dist', '*.local'],
ignores: ['dist/**', 'node_modules/**', 'coverage/**', '*.local', 'pnpm-lock.yaml'],
},
js.configs.recommended,
...pluginVue.configs['flat/recommended'],
prettierConfig,
{
files: ['**/*.ts'],
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',
},
ecmaVersion: 'latest',
sourceType: 'module',
parser: tsParser,
globals: browserGlobals,
},
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',
'no-undef': 'off',
},
},
];
...pluginVue.configs['flat/recommended'],
{
files: ['**/*.vue'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parser: vueParser,
parserOptions: {
parser: tsParser,
ecmaVersion: 'latest',
sourceType: 'module',
},
globals: browserGlobals,
},
rules: {
'no-undef': 'off',
},
},
prettierConfig,
{
files: ['**/*.{js,ts,vue}'],
rules: {
'no-console': 'warn',
'no-unused-vars': 'off',
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'warn',
},
},
]
+3 -4
View File
@@ -1,14 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<html lang="en">
<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="接龙自动打卡系统 - 轻松管理您的打卡任务" />
<title>CheckIn App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-4314
View File
File diff suppressed because it is too large Load Diff
+26 -15
View File
@@ -1,33 +1,44 @@
{
"name": "frontend",
"name": "checkin-app-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix",
"format": "prettier --write ."
"test": "node --test --experimental-strip-types src/app/theme.test.ts src/components/templates/template-config.test.ts",
"typecheck": "vue-tsc -b",
"lint": "eslint . --fix",
"lint:check": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"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"
"@tailwindcss/vite": "^4.2.4",
"@vueuse/core": "^14.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^1.0.0",
"reka-ui": "^2.9.6",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"vue": "^3.5.33"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@vitejs/plugin-vue": "^6.0.3",
"@types/node": "^25.6.0",
"@typescript-eslint/parser": "^8.59.1",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.4.23",
"@vue/tsconfig": "^0.9.1",
"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"
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.3",
"vite": "^8.0.10",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^3.2.7"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 650 B

After

Width:  |  Height:  |  Size: 9.3 KiB

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

-1
View File
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+67 -80
View File
@@ -1,85 +1,72 @@
<template>
<a-config-provider :theme="antdTheme" :locale="zhCN">
<router-view />
</a-config-provider>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import AppLayout from '@/components/AppLayout.vue'
import { useAuth } from '@/app/auth'
import { useRouter } from '@/app/router'
import LoginView from '@/views/LoginView.vue'
import PendingApprovalView from '@/views/PendingApprovalView.vue'
import DashboardView from '@/views/DashboardView.vue'
import TasksView from '@/views/TasksView.vue'
import TaskRecordsView from '@/views/TaskRecordsView.vue'
import RecordsView from '@/views/RecordsView.vue'
import SettingsView from '@/views/SettingsView.vue'
import NotFoundView from '@/views/NotFoundView.vue'
import AdminUsersView from '@/views/admin/AdminUsersView.vue'
import AdminTemplatesView from '@/views/admin/AdminTemplatesView.vue'
import AdminRecordsView from '@/views/admin/AdminRecordsView.vue'
import AdminLogsView from '@/views/admin/AdminLogsView.vue'
import AdminStatsView from '@/views/admin/AdminStatsView.vue'
<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 router = useRouter()
const auth = useAuth()
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();
}
const view = computed(() => {
switch (router.current.value.key) {
case 'login':
return LoginView
case 'pending':
return PendingApprovalView
case 'dashboard':
return DashboardView
case 'tasks':
return TasksView
case 'task-records':
return TaskRecordsView
case 'records':
return RecordsView
case 'settings':
return SettingsView
case 'admin-users':
return AdminUsersView
case 'admin-templates':
return AdminTemplatesView
case 'admin-records':
return AdminRecordsView
case 'admin-logs':
return AdminLogsView
case 'admin-stats':
return AdminStatsView
default:
return NotFoundView
}
});
})
const wrappedView = computed(() => {
if (['login', 'pending', 'not-found'].includes(router.current.value.key)) return view.value
return AppLayout
})
const usesLayout = computed(() => wrappedView.value === AppLayout)
onMounted(() => {
void auth.refreshCurrentUser().catch(() => undefined)
void router.guardCurrent()
})
</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>
<template>
<AppLayout v-if="usesLayout">
<component :is="view" />
</AppLayout>
<component :is="view" v-else />
</template>
-248
View File
@@ -1,248 +0,0 @@
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
@@ -1,75 +0,0 @@
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
@@ -1,258 +0,0 @@
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 新增
};

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

-1
View File
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 496 B

@@ -1,509 +0,0 @@
<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>
@@ -1,277 +0,0 @@
<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>
@@ -1,630 +0,0 @@
<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
@@ -1,41 +0,0 @@
<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
@@ -1,473 +0,0 @@
<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>
@@ -1,323 +0,0 @@
<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>
@@ -1,110 +0,0 @@
<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>
@@ -1,87 +0,0 @@
<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>
@@ -1,188 +0,0 @@
<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>
@@ -1,84 +0,0 @@
/**
* 通用异步操作 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,
};
}
@@ -1,65 +0,0 @@
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,
};
}
@@ -1,124 +0,0 @@
/**
* 状态轮询 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
@@ -1,106 +0,0 @@
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'),
};
}
@@ -1,190 +0,0 @@
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
@@ -1,53 +0,0 @@
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
@@ -1,162 +0,0 @@
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
@@ -1,62 +0,0 @@
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
@@ -1,133 +0,0 @@
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
@@ -1,96 +0,0 @@
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
@@ -1,164 +0,0 @@
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
@@ -1,169 +0,0 @@
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
@@ -1,94 +0,0 @@
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 || '删除用户失败');
}
},
},
});
+172 -1169
View File
File diff suppressed because it is too large Load Diff
-145
View File
@@ -1,145 +0,0 @@
/**
* 格式化日期时间
* @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;
}
}
+414 -529
View File
@@ -1,547 +1,432 @@
<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>
<script setup lang="ts">
import {
Activity,
AlertTriangle,
CalendarDays,
CheckCircle2,
Clock,
KeyRound,
QrCode,
UserRound,
} from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import {
checkInApi,
taskApi,
userApi,
type CheckInRecord,
type CheckInRecordStatus,
type Task,
type TokenStatus,
} from '@/api'
import { useAuth } from '@/app/auth'
import { useRouter } from '@/app/router'
import StateBlock from '@/components/StateBlock.vue'
import { alertClass, cardClass, inputClass, sectionHeaderClass, toneClass } from '@/components/ui'
import { Button } from '@/components/ui/button'
import {
cronLabel,
extractErrorMessage,
formatDateTime,
statusLabel,
statusTone,
} from '@/utils/format'
<!-- 密码未设置提醒 -->
<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>
const router = useRouter()
const auth = useAuth()
const loading = ref(true)
const error = ref('')
const message = ref('')
const tasks = ref<Task[]>([])
const records = ref<CheckInRecord[]>([])
const tokenStatus = ref<TokenStatus | null>(null)
const selectedTaskId = ref<number | null>(null)
const checkInLoading = ref(false)
const latestStatus = ref<CheckInRecordStatus | null>(null)
let pollTimer: number | undefined
<!-- 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>
const activeTasks = computed(() => tasks.value.filter((task) => task.is_active).length)
const inactiveTasks = computed(() => Math.max(0, tasks.value.length - activeTasks.value))
const selectedTask = computed(() => tasks.value.find((task) => task.id === selectedTaskId.value))
const lastRecord = computed(() => records.value[0] ?? null)
const nextActiveTask = computed(() => tasks.value.find((task) => task.is_active) ?? null)
const successToday = computed(
() => records.value.filter((record) => record.status === 'success').length,
)
const tokenTone = computed(() =>
tokenStatus.value?.is_valid
? tokenStatus.value.expiring_soon
? 'warning'
: 'success'
: 'danger',
)
const tokenLabel = computed(() => {
if (!tokenStatus.value) return '未知'
if (!tokenStatus.value.is_valid) return '无效'
return tokenStatus.value.expiring_soon ? '即将过期' : '有效'
})
const tokenDetail = computed(() => {
if (!tokenStatus.value) return '未获取到业务 Token 状态。'
if (!tokenStatus.value.is_valid) return '打卡凭证已过期,无法自动打卡。请使用扫码登录刷新授权。'
if (tokenStatus.value.expiring_soon) return 'Token 即将过期,建议准备刷新授权。'
return `业务 Token 正常,${tokenStatus.value.days_until_expiry ?? '未知'} 天后过期。`
})
const needsEmail = computed(() => !auth.state.user?.email)
const needsPassword = computed(() => auth.state.user?.has_password === false)
<!-- 没有打卡任务提醒 -->
<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;
async function load() {
loading.value = true
error.value = ''
try {
await userStore.fetchTokenStatus();
} catch (error) {
message.error(error.message || '获取 Token 状态失败');
const [taskList, token, recordPage] = await Promise.all([
taskApi.list(),
userApi.tokenStatus().catch(() => null),
checkInApi.myRecords({ limit: 6 }),
])
tasks.value = taskList
tokenStatus.value = token
records.value = recordPage.records
if (!selectedTaskId.value || !taskList.some((task) => task.id === selectedTaskId.value)) {
selectedTaskId.value = taskList.find((task) => task.is_active)?.id ?? taskList[0]?.id ?? null
}
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
tokenStatusLoading.value = false;
loading.value = false
}
};
// 手动打卡
const handleCheckIn = async () => {
if (!selectedTaskId.value) {
message.warning('请先选择要打卡的任务');
return;
}
checkInLoading.value = true;
}
async function manualCheckIn() {
if (!selectedTaskId.value) return
checkInLoading.value = true
error.value = ''
message.value = ''
latestStatus.value = null
try {
// 调用异步打卡接口,立即返回 record_id
const result = await taskStore.checkInTask(selectedTaskId.value);
const result = await checkInApi.manual(selectedTaskId.value)
const recordId = result.record_id ?? result.id
message.value = result.message || '已启动打卡任务'
if (recordId) startRecordPolling(recordId)
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
checkInLoading.value = false
}
}
// 获取 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('打卡处理时间较长,请稍后查看打卡记录');
},
function startRecordPolling(recordId: number) {
window.clearInterval(pollTimer)
pollTimer = window.setInterval(async () => {
try {
const status = await checkInApi.status(recordId)
latestStatus.value = status
if (!['pending', 'running'].includes(status.status)) {
window.clearInterval(pollTimer)
await load()
}
);
} 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 {
window.clearInterval(pollTimer)
}
} catch (error) {
message.error(error.message || '加载任务列表失败');
}
});
}, 1800)
}
onMounted(load)
</script>
<style scoped>
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
}
<template>
<StateBlock v-if="loading" title="正在加载仪表盘" type="loading" />
<StateBlock
v-else-if="error && tasks.length === 0"
title="仪表盘加载失败"
:description="error"
type="error"
action-label="重试"
@action="load"
/>
<div v-else class="grid gap-5">
<div
v-if="
needsEmail || needsPassword || (tokenStatus && !tokenStatus.is_valid) || tasks.length === 0
"
class="grid gap-2"
>
<div
v-if="needsEmail"
:class="[alertClass.info, 'flex flex-wrap items-center justify-between gap-2']"
>
<span>未设置邮箱</span>
<Button
variant="ghost"
class="font-semibold"
type="button"
@click="router.navigate('/settings')"
>
设置
</Button>
</div>
<div
v-if="needsPassword"
:class="[alertClass.info, 'flex flex-wrap items-center justify-between gap-2']"
>
<span>未设置登录密码</span>
<Button
variant="ghost"
class="font-semibold"
type="button"
@click="router.navigate('/settings')"
>
设置
</Button>
</div>
<div
v-if="tokenStatus && !tokenStatus.is_valid"
:class="[alertClass.warning, 'flex flex-wrap items-center justify-between gap-2']"
>
<span class="inline-flex items-center gap-2">
<AlertTriangle class="size-4 shrink-0" />
打卡凭证已过期
</span>
<Button
variant="ghost"
class="font-semibold"
type="button"
@click="router.navigate('/login')"
>
刷新
</Button>
</div>
<div
v-if="tasks.length === 0"
:class="[alertClass.info, 'flex flex-wrap items-center justify-between gap-2']"
>
<span>暂无打卡任务</span>
<Button
variant="ghost"
class="font-semibold"
type="button"
@click="router.navigate('/tasks')"
>
创建
</Button>
</div>
</div>
.card-header {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 500;
}
<section class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
<div :class="[cardClass, 'overflow-hidden']">
<div :class="sectionHeaderClass">
<div class="flex items-center gap-2">
<CalendarDays class="size-4 text-[var(--tone-success-fg)]" />
<h2 class="font-semibold">手动打卡</h2>
</div>
<span class="text-sm text-muted-foreground">{{ activeTasks }} 个启用</span>
</div>
<div class="p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<select v-model.number="selectedTaskId" :class="[inputClass, 'sm:max-w-md']">
<option v-for="task in tasks" :key="task.id" :value="task.id">
{{ task.name || `任务 #${task.id}` }} · {{ task.is_active ? '启用' : '停用' }}
</option>
</select>
<Button
:disabled="!selectedTaskId || checkInLoading"
type="button"
@click="manualCheckIn"
>
<CalendarDays class="size-4" />
{{ checkInLoading ? '打卡中' : '立即打卡' }}
</Button>
</div>
<div
v-if="selectedTask"
class="mt-4 rounded-lg border border-border bg-muted p-4 text-sm"
>
<div class="font-medium text-foreground">
{{ selectedTask.name || `任务 #${selectedTask.id}` }}
</div>
<div class="mt-1 text-muted-foreground">
ThreadId: {{ selectedTask.thread_id || '未解析' }} ·
{{ cronLabel(selectedTask.cron_expression) }}
</div>
</div>
<div v-if="message" :class="[alertClass.success, 'mt-4']">{{ message }}</div>
<div v-if="error" :class="[alertClass.danger, 'mt-4']">{{ error }}</div>
<div
v-if="latestStatus"
class="mt-4 rounded-lg border border-border bg-background p-4 text-sm"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-semibold text-foreground">本次打卡</span>
<span :class="toneClass(statusTone(latestStatus.status))">{{
statusLabel(latestStatus.status)
}}</span>
</div>
<p class="mt-2 text-muted-foreground">
{{
latestStatus.response_text || latestStatus.error_message || '正在等待后端返回结果。'
}}
</p>
</div>
<div
v-else-if="lastRecord"
class="mt-4 rounded-lg border border-border bg-background p-4 text-sm"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-semibold text-foreground">上次打卡</span>
<span :class="toneClass(statusTone(lastRecord.status))">{{
statusLabel(lastRecord.status)
}}</span>
</div>
<p class="mt-2 text-muted-foreground">
{{ formatDateTime(lastRecord.check_in_time) }} ·
{{ lastRecord.response_text || lastRecord.error_message || '无响应内容' }}
</p>
</div>
</div>
</div>
.loading-container {
padding: 20px;
}
<div :class="[cardClass, 'overflow-hidden']">
<div :class="sectionHeaderClass">
<div class="flex items-center gap-2">
<KeyRound class="size-4 text-[var(--tone-success-fg)]" />
<h2 class="font-semibold">授权</h2>
</div>
<span :class="toneClass(tokenTone)">{{ tokenLabel }}</span>
</div>
<div class="grid gap-3 p-4 text-sm">
<div class="flex items-center justify-between">
<span class="text-muted-foreground">剩余</span>
<span class="font-medium">
{{
tokenStatus?.days_until_expiry == null
? '未知'
: `${tokenStatus.days_until_expiry} 天`
}}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground">预警</span>
<span>{{ tokenStatus?.expiring_soon ? '是' : '否' }}</span>
</div>
<div class="text-sm text-muted-foreground">{{ tokenDetail }}</div>
<Button
:variant="tokenStatus?.is_valid ? 'outline' : 'default'"
type="button"
@click="router.navigate('/login')"
>
<QrCode class="size-4" />
扫码刷新
</Button>
</div>
</div>
</section>
.token-status {
padding: 0;
}
<section :class="[cardClass, 'overflow-hidden']">
<div :class="sectionHeaderClass">
<div class="flex items-center gap-2">
<UserRound class="size-4 text-[var(--tone-success-fg)]" />
<h2 class="font-semibold">个人信息</h2>
</div>
<Button variant="outline" type="button" @click="router.navigate('/settings')">
个人设置
</Button>
</div>
<div class="grid gap-3 p-4 text-sm md:grid-cols-4">
<div class="rounded-lg border border-border bg-muted px-3 py-2">
<div class="text-muted-foreground">用户名</div>
<div class="mt-1 font-medium text-foreground">
{{ auth.state.user?.alias || '未登录' }}
</div>
</div>
<div class="rounded-lg border border-border bg-muted px-3 py-2">
<div class="text-muted-foreground">角色</div>
<div class="mt-1">
<span :class="toneClass(auth.state.user?.role === 'admin' ? 'danger' : 'info')">
{{ auth.state.user?.role === 'admin' ? '管理员' : '普通用户' }}
</span>
</div>
</div>
<div class="rounded-lg border border-border bg-muted px-3 py-2">
<div class="text-muted-foreground">邮箱</div>
<div class="mt-1 font-medium text-foreground">
{{ auth.state.user?.email || '未设置' }}
</div>
</div>
<div class="rounded-lg border border-border bg-muted px-3 py-2">
<div class="text-muted-foreground">注册时间</div>
<div class="mt-1 font-medium text-foreground">
{{ formatDateTime(auth.state.user?.created_at) }}
</div>
</div>
</div>
</section>
.token-status .ant-descriptions {
margin-bottom: 0;
}
<section :class="[cardClass, 'overflow-hidden']">
<div :class="sectionHeaderClass">
<div>
<h2 class="font-semibold">任务概览</h2>
</div>
<Button variant="outline" type="button" @click="router.navigate('/tasks')">
管理任务
</Button>
</div>
<div class="grid gap-3 p-4 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-lg border border-border bg-muted p-3">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">任务总数</span>
<CheckCircle2 class="size-4 text-[var(--tone-success-fg)]" />
</div>
<div class="mt-3 text-3xl font-semibold">{{ tasks.length }}</div>
<p class="mt-1 text-sm text-muted-foreground">
{{ activeTasks }} 启用 · {{ inactiveTasks }} 停用
</p>
</div>
<div class="rounded-lg border border-border bg-muted p-3">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">最近成功</span>
<Activity class="size-4 text-foreground" />
</div>
<div class="mt-3 text-3xl font-semibold">{{ successToday }}</div>
<p class="mt-1 text-sm text-muted-foreground">最近记录</p>
</div>
<div class="rounded-lg border border-border bg-muted p-3 md:col-span-2">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">下次定时</span>
<Clock class="size-4 text-[var(--tone-warning-fg)]" />
</div>
<div class="mt-3 text-lg font-semibold">
{{ cronLabel(nextActiveTask?.cron_expression) }}
</div>
<p class="mt-1 text-sm text-muted-foreground">
{{ nextActiveTask?.name || '无启用任务' }}
</p>
</div>
</div>
</section>
.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>
<section :class="[cardClass, 'overflow-hidden']">
<div :class="sectionHeaderClass">
<div>
<h2 class="font-semibold">最近记录</h2>
</div>
<Button variant="outline" type="button" @click="router.navigate('/records')">
查看全部
</Button>
</div>
<StateBlock v-if="records.length === 0" title="暂无记录" />
<div v-else class="divide-y divide-border">
<div v-for="record in records" :key="record.id" class="px-4 py-3">
<div class="flex items-center justify-between gap-3">
<span class="font-medium">{{ record.task_name || `任务 #${record.task_id}` }}</span>
<span :class="toneClass(statusTone(record.status))">{{
statusLabel(record.status)
}}</span>
</div>
<div class="mt-1 text-sm text-muted-foreground">
{{ formatDateTime(record.check_in_time) }} ·
{{ record.trigger_type ? statusLabel(record.trigger_type) : '未注明触发' }}
</div>
</div>
</div>
</section>
</div>
</template>
+232 -500
View File
@@ -1,524 +1,256 @@
<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>
<script setup lang="ts">
import { KeyRound, QrCode, RotateCw, UserRound } from 'lucide-vue-next'
import { computed, onBeforeUnmount, ref } from 'vue'
import { authApi } from '@/api'
import { useAuth } from '@/app/auth'
import { useRouter } from '@/app/router'
import { alertClass, cardClass, inputClass, labelClass } from '@/components/ui'
import { Button } from '@/components/ui/button'
import { extractErrorMessage } from '@/utils/format'
<!-- 登录模式切换 -->
<div class="mode-switch">
<a-segmented v-model:value="loginMode" :options="loginModeOptions" block />
</div>
const router = useRouter()
const auth = useAuth()
<!-- 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>
const alias = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
const info = ref('')
const qrImage = ref('')
const qrSessionId = ref('')
const loginMode = ref<'qrcode' | 'password'>('qrcode')
let pollTimer: number | undefined
<a-form-item>
<a-button
type="primary"
size="large"
block
:loading="loading"
@click="handleQRCodeLogin"
>
{{ loading ? '正在登录...' : '扫码登录/注册' }}
</a-button>
</a-form-item>
</a-form>
const canSubmitPassword = computed(
() => Boolean(alias.value.trim()) && Boolean(password.value) && !loading.value,
)
const canRequestQr = computed(() => Boolean(alias.value.trim()) && !loading.value)
<!-- 别名+密码登录表单 -->
<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>
function switchMode(mode: 'qrcode' | 'password') {
loginMode.value = mode
error.value = ''
info.value = ''
if (mode === 'password' && qrSessionId.value) void cancelQr()
}
<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;
function loginRedirect() {
const redirect = router.query.value.get('redirect') || '/dashboard'
void auth.refreshCurrentUser().finally(() => router.replace(redirect))
}
async function loginWithPassword() {
if (!canSubmitPassword.value) return
error.value = ''
info.value = ''
loading.value = true
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);
const result = await authApi.aliasLogin(alias.value.trim(), password.value)
auth.applyLogin(result)
loginRedirect()
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
loading.value = false;
loading.value = false
}
};
}
// 处理密码登录错误
const handlePasswordLoginError = msg => {
if (!msg) {
message.error('登录失败,请稍后重试');
return;
async function requestQrCode() {
if (!canRequestQr.value) return
error.value = ''
info.value = '正在创建扫码会话'
loading.value = true
try {
if (qrSessionId.value) await authApi.cancelQRCodeSession(qrSessionId.value)
const result = await authApi.requestQRCode(alias.value.trim())
if (result.status === 'error') throw new Error(result.message || '创建扫码会话失败')
qrSessionId.value = result.session_id
qrImage.value = result.qrcode_image ?? result.qrcode_base64 ?? result.qr_code ?? ''
info.value = '请使用 QQ 扫码完成授权'
startPolling()
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
loading.value = false
}
}
// 用户不存在或密码错误
if (msg.includes('用户名或密码错误')) {
message.error('用户名或密码错误');
return;
}
function startPolling() {
window.clearInterval(pollTimer)
pollTimer = window.setInterval(async () => {
if (!qrSessionId.value) return
try {
const status = await authApi.getQRCodeStatus(qrSessionId.value)
qrImage.value = status.qrcode_image ?? qrImage.value
if (status.status === 'success') {
window.clearInterval(pollTimer)
auth.applyLogin(status)
loginRedirect()
} else if (status.status === 'error') {
error.value = status.message || '扫码登录失败'
window.clearInterval(pollTimer)
} else {
info.value = status.message || '等待扫码确认'
}
} catch (err) {
error.value = extractErrorMessage(err)
window.clearInterval(pollTimer)
}
}, 2200)
}
// 未设置密码
if (msg.includes('未设置密码')) {
message.warning('该账户未设置密码,请使用扫码登录');
return;
}
async function cancelQr() {
window.clearInterval(pollTimer)
if (qrSessionId.value) await authApi.cancelQRCodeSession(qrSessionId.value).catch(() => undefined)
qrSessionId.value = ''
qrImage.value = ''
info.value = ''
}
// 用户不存在
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 || '登录失败');
};
onBeforeUnmount(() => {
void cancelQr()
})
</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;
}
<template>
<main
class="flex min-h-[100dvh] items-center justify-center bg-background px-4 py-8 text-foreground"
>
<section class="w-full max-w-md">
<div :class="[cardClass, 'overflow-hidden']">
<div class="border-b border-border px-4 py-3 text-center">
<div
class="mx-auto mb-3 flex size-10 items-center justify-center rounded-lg bg-[var(--tone-info-strong)] text-background shadow-sm"
>
<QrCode class="size-5" />
</div>
<h1 class="text-xl font-semibold tracking-normal text-foreground">接龙自动打卡系统</h1>
</div>
/* 暗色模式背景 */
.dark .login-container {
background: linear-gradient(135deg, #1a237e 0%, #4a148c 100%);
}
<div class="p-4">
<div class="grid grid-cols-2 rounded-lg border border-border bg-muted p-1 text-sm">
<button
type="button"
class="rounded-md px-3 py-2 text-center font-medium transition"
:class="
loginMode === 'qrcode'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
"
@click="switchMode('qrcode')"
>
扫码登录
</button>
<button
type="button"
class="rounded-md px-3 py-2 text-center font-medium transition"
:class="
loginMode === 'password'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
"
@click="switchMode('password')"
>
密码登录
</button>
</div>
.login-card {
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
width: 100%;
margin: 20px 0;
}
<form
v-if="loginMode === 'password'"
class="mt-5 grid gap-4"
@submit.prevent="loginWithPassword"
>
<label class="grid gap-2">
<span :class="labelClass">用户名</span>
<div class="relative">
<UserRound
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<input
v-model="alias"
:class="[inputClass, 'pl-9']"
autocomplete="username"
required
placeholder="请输入您的用户名"
/>
</div>
</label>
<label class="grid gap-2">
<span :class="labelClass">密码</span>
<div class="relative">
<KeyRound
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<input
v-model="password"
:class="[inputClass, 'pl-9']"
autocomplete="current-password"
type="password"
placeholder="请输入密码"
/>
</div>
</label>
/* 暗色模式卡片阴影 */
.dark .login-card {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
<div v-if="error" :class="alertClass.danger">
{{ error }}
</div>
<div v-if="info" :class="alertClass.info">
{{ info }}
</div>
.card-header {
text-align: center;
}
<Button class="w-full" type="submit" :disabled="!canSubmitPassword">
<KeyRound class="size-4" />
{{ loading ? '登录中' : '登录' }}
</Button>
</form>
.card-header h2 {
margin: 0;
font-size: 24px;
color: #303133;
transition: color 0.3s ease;
}
<div v-else class="mt-5 grid gap-4">
<label class="grid gap-2">
<span :class="labelClass">用户名</span>
<div class="relative">
<UserRound
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<input
v-model="alias"
:class="[inputClass, 'pl-9']"
autocomplete="username"
required
placeholder="请输入您的用户名"
@keyup.enter="requestQrCode"
/>
</div>
</label>
/* 暗色模式标题 */
.dark .card-header h2 {
color: #e6e1e5;
}
<div v-if="error" :class="alertClass.danger">
{{ error }}
</div>
<div v-if="info" :class="alertClass.info">
{{ info }}
</div>
.subtitle {
margin: 10px 0 0 0;
font-size: 14px;
color: #909399;
transition: color 0.3s ease;
}
<Button class="w-full" type="button" :disabled="!canRequestQr" @click="requestQrCode">
<QrCode class="size-4" />
{{ loading ? '正在登录' : '扫码登录/注册' }}
</Button>
/* 暗色模式副标题 */
.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>
<div v-if="qrImage" class="rounded-lg border border-border bg-muted p-4 text-center">
<img
:src="qrImage.startsWith('data:') ? qrImage : `data:image/png;base64,${qrImage}`"
alt="QQ 登录二维码"
class="mx-auto size-48 rounded-md bg-background object-contain"
/>
<button
class="mt-3 inline-flex items-center gap-2 text-sm font-medium text-muted-foreground transition hover:text-foreground"
type="button"
@click="requestQrCode"
>
<RotateCw class="size-4" />
刷新会话
</button>
</div>
</div>
</div>
</div>
</section>
</main>
</template>
+13 -27
View File
@@ -1,30 +1,16 @@
<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 lang="ts">
import { useRouter } from '@/app/router'
import { Button } from '@/components/ui/button'
<script setup>
import { useRouter } from 'vue-router';
const router = useRouter();
const goHome = () => {
router.push('/');
};
const router = useRouter()
</script>
<style scoped>
.not-found-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
}
</style>
<template>
<section
class="rounded-lg border border-border bg-card p-8 text-center text-card-foreground shadow-sm dark:shadow-none"
>
<h2 class="text-xl font-semibold">页面不存在</h2>
<p class="mt-2 text-sm text-muted-foreground">当前地址没有对应的新前端页面</p>
<Button type="button" class="mt-5" @click="router.navigate('/dashboard')"> 返回仪表盘 </Button>
</section>
</template>
+57 -352
View File
@@ -1,360 +1,65 @@
<template>
<div class="pending-container">
<div class="pending-card">
<div class="card-header">
<h2>🕐 等待审批</h2>
</div>
<script setup lang="ts">
import { RefreshCw } from 'lucide-vue-next'
import { ref } from 'vue'
import { userApi } from '@/api'
import { useAuth } from '@/app/auth'
import { useRouter } from '@/app/router'
import { alertClass, cardClass, toneClass } from '@/components/ui'
import { Button } from '@/components/ui/button'
import { extractErrorMessage, formatFullDateTime } from '@/utils/format'
<div class="pending-content">
<div class="result-icon">
<span class="info-icon"></span>
</div>
const auth = useAuth()
const router = useRouter()
const loading = ref(false)
const error = ref('')
<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 () => {
async function refresh() {
loading.value = true
error.value = ''
try {
const response = await userAPI.getUserStatus();
user.value = response;
if (response.is_approved) {
message.success('恭喜!您的账户已通过审批');
router.push('/dashboard');
} else {
message.info('仍在等待审批中');
const status = await userApi.status()
if (status.is_approved) {
await auth.refreshCurrentUser()
await router.replace('/dashboard')
}
} 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 || '更新失败,请重试');
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
profileLoading.value = false;
loading.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>
<template>
<section :class="[cardClass, 'mx-auto max-w-2xl overflow-hidden']">
<div class="border-b border-[var(--tone-warning-border)] bg-[var(--tone-warning-bg)] p-6">
<span :class="toneClass('warning')">待审批</span>
<h2 class="mt-3 text-xl font-semibold">账号等待审批</h2>
<p class="mt-2 text-sm text-muted-foreground">
当前账号
{{ auth.state.user?.alias ?? '未知用户' }} 已完成登录但还需要管理员审批后才能访问工作台
</p>
</div>
<div class="p-6">
<dl class="mt-5 grid gap-3 sm:grid-cols-2">
<div class="rounded-md border border-border bg-background p-3">
<dt class="text-xs text-muted-foreground">创建时间</dt>
<dd class="mt-1 text-sm font-medium">
{{ formatFullDateTime(auth.state.user?.created_at) }}
</dd>
</div>
<div class="rounded-md border border-border bg-background p-3">
<dt class="text-xs text-muted-foreground">审批状态</dt>
<dd class="mt-1 text-sm font-medium">待审批</dd>
</div>
</dl>
<div v-if="error" :class="[alertClass.danger, 'mt-4']">
{{ error }}
</div>
<Button class="mt-5" :disabled="loading" type="button" @click="refresh">
<RefreshCw class="size-4" :class="{ 'animate-spin': loading }" />
刷新审批状态
</Button>
</div>
</section>
</template>
+118 -226
View File
@@ -1,235 +1,127 @@
<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>
<script setup lang="ts">
import { Search } from 'lucide-vue-next'
import { onMounted, reactive, ref } from 'vue'
import { checkInApi, type CheckInRecord } from '@/api'
import StateBlock from '@/components/StateBlock.vue'
import { cardClass, inputClass, sectionHeaderClass, toneClass } from '@/components/ui'
import { Button } from '@/components/ui/button'
import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format'
<!-- 统计信息 -->
<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>
const loading = ref(true)
const error = ref('')
const records = ref<CheckInRecord[]>([])
const total = ref(0)
const filters = reactive({ status: '', trigger_type: '', skip: 0, limit: 20 })
<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 () => {
async function load() {
loading.value = true
error.value = ''
try {
await checkInStore.fetchMyRecords();
message.success('刷新成功');
} catch (error) {
message.error(error.message || '刷新失败');
const page = await checkInApi.myRecords(filters)
records.value = page.records
total.value = page.total
filters.skip = page.skip
filters.limit = page.limit
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
loading.value = false
}
};
}
// 页码改变
const handlePageChange = () => {
checkInStore.fetchMyRecords();
};
function page(delta: number) {
filters.skip = Math.max(0, filters.skip + delta * filters.limit)
void load()
}
// 每页数量改变
const handleSizeChange = () => {
checkInStore.currentPage = 1;
checkInStore.fetchMyRecords();
};
onMounted(() => {
checkInStore.fetchMyRecords();
});
onMounted(load)
</script>
<style scoped>
.records-container {
max-width: 1400px;
margin: 0 auto;
}
<template>
<section :class="[cardClass, 'overflow-hidden']">
<div :class="[sectionHeaderClass, 'md:grid-cols-[1fr_180px_180px_auto]']">
<div>
<h2 class="font-semibold">个人打卡记录</h2>
</div>
<select v-model="filters.status" :class="inputClass">
<option value="">全部状态</option>
<option value="success">成功</option>
<option value="failure">失败</option>
<option value="out_of_time">超出时间</option>
</select>
<select v-model="filters.trigger_type" :class="inputClass">
<option value="">全部触发</option>
<option value="manual">手动</option>
<option value="scheduler">定时</option>
<option value="admin">管理员</option>
</select>
<Button variant="outline" type="button" @click="load">
<Search class="size-4" />
筛选
</Button>
</div>
.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>
<StateBlock v-if="loading" title="正在加载记录" type="loading" />
<StateBlock
v-else-if="error"
title="记录加载失败"
:description="error"
type="error"
action-label="重试"
@action="load"
/>
<StateBlock v-else-if="records.length === 0" title="暂无记录" />
<div v-else>
<div class="divide-y divide-border">
<article
v-for="record in records"
:key="record.id"
class="grid gap-3 p-3 lg:grid-cols-[180px_minmax(0,1fr)_auto] lg:items-center"
>
<div class="min-w-0">
<div class="truncate text-sm font-semibold">
{{ record.task_name || `任务 #${record.task_id}` }}
</div>
<div class="mt-1 text-xs text-muted-foreground">
{{ formatFullDateTime(record.check_in_time) }}
</div>
</div>
<div class="min-w-0">
<p class="truncate text-sm text-foreground">
{{ record.response_text || record.error_message || '无响应内容' }}
</p>
<p class="mt-1 text-xs text-muted-foreground">
触发方式{{ statusLabel(record.trigger_type) }}
</p>
</div>
<div class="lg:text-right">
<span :class="toneClass(statusTone(record.status))">{{
statusLabel(record.status)
}}</span>
</div>
</article>
</div>
<div
class="flex flex-wrap items-center justify-between gap-3 border-t border-border bg-muted/55 px-4 py-3 text-sm text-muted-foreground"
>
<span
> {{ total }} 当前 {{ filters.skip + 1 }} -
{{ Math.min(filters.skip + filters.limit, total) }}</span
>
<div class="flex gap-2">
<Button variant="outline" :disabled="filters.skip === 0" type="button" @click="page(-1)">
上一页
</Button>
<Button
variant="outline"
:disabled="filters.skip + filters.limit >= total"
type="button"
@click="page(1)"
>
下一页
</Button>
</div>
</div>
</div>
</section>
</template>
+155 -268
View File
@@ -1,281 +1,168 @@
<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>
<script setup lang="ts">
import { Save } from 'lucide-vue-next'
import { onMounted, reactive, ref } from 'vue'
import { userApi, type TokenStatus } from '@/api'
import { useAuth } from '@/app/auth'
import StateBlock from '@/components/StateBlock.vue'
import {
alertClass,
cardClass,
inputClass,
labelClass,
sectionHeaderClass,
toneClass,
} from '@/components/ui'
import { Button } from '@/components/ui/button'
import { extractErrorMessage } from '@/utils/format'
<!-- 基本信息卡片 -->
<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({
const auth = useAuth()
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const message = ref('')
const token = ref<TokenStatus | null>(null)
const form = reactive({
alias: '',
email: '',
});
current_password: '',
new_password: '',
})
const profileRules = {
email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
};
// 密码表单
const passwordForm = ref({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
// 加载用户信息
const loadUserInfo = async () => {
async function load() {
loading.value = true
error.value = ''
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);
const [user, tokenStatus] = await Promise.all([
userApi.me(),
userApi.tokenStatus().catch(() => null),
])
auth.state.user = user
token.value = tokenStatus
form.alias = user.alias
form.email = user.email ?? ''
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
profileLoading.value = false;
loading.value = false
}
};
}
// 重置个人信息表单
const resetProfileForm = () => {
profileForm.value.email = user.value?.email || '';
profileFormRef.value?.clearValidate();
};
// 更新密码
const handleUpdatePassword = async () => {
async function save() {
saving.value = true
error.value = ''
message.value = ''
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);
const user = await userApi.updateProfile({
alias: form.alias,
email: form.email || undefined,
current_password: form.current_password || undefined,
new_password: form.new_password || undefined,
})
auth.state.user = user
form.current_password = ''
form.new_password = ''
message.value = '个人信息已更新'
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
passwordLoading.value = false;
saving.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();
});
onMounted(load)
</script>
<style scoped>
.settings-view {
min-height: 100%;
}
</style>
<template>
<StateBlock v-if="loading" title="正在加载设置" type="loading" />
<StateBlock
v-else-if="error && !auth.state.user"
title="设置加载失败"
:description="error"
type="error"
action-label="重试"
@action="load"
/>
<div v-else class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_340px]">
<form :class="[cardClass, 'overflow-hidden']" @submit.prevent="save">
<div :class="sectionHeaderClass">
<h2 class="font-semibold">个人资料</h2>
</div>
<div class="grid gap-4 p-4">
<label class="grid gap-2">
<span :class="labelClass">别名</span>
<input v-model="form.alias" :class="inputClass" required />
</label>
<label class="grid gap-2">
<span :class="labelClass">邮箱</span>
<input v-model="form.email" :class="inputClass" type="email" placeholder="用于打卡通知" />
</label>
<div class="grid gap-4 md:grid-cols-2">
<label class="grid gap-2">
<span :class="labelClass">当前密码</span>
<input
v-model="form.current_password"
:class="inputClass"
type="password"
placeholder="修改密码时填写"
/>
</label>
<label class="grid gap-2">
<span :class="labelClass">新密码</span>
<input
v-model="form.new_password"
:class="inputClass"
type="password"
placeholder="至少 6 位"
/>
</label>
</div>
<div v-if="error" :class="alertClass.danger">
{{ error }}
</div>
<div v-if="message" :class="alertClass.success">
{{ message }}
</div>
<Button class="w-fit" :disabled="saving" type="submit">
<Save class="size-4" />
{{ saving ? '保存中' : '保存设置' }}
</Button>
</div>
</form>
<aside :class="[cardClass, 'h-fit overflow-hidden']">
<div
class="grid gap-2 border-b px-4 py-3"
:class="
token?.is_valid
? 'border-[var(--tone-success-border)] bg-[var(--tone-success-bg)]'
: 'border-[var(--tone-danger-border)] bg-[var(--tone-danger-bg)]'
"
>
<div class="flex items-center justify-between gap-2">
<h2 class="font-semibold">授权状态</h2>
<span :class="toneClass(token?.is_valid ? 'success' : 'danger')">{{
token?.is_valid ? '可用' : '不可用'
}}</span>
</div>
</div>
<div class="p-4">
<div class="grid gap-3 text-sm">
<div class="flex items-center justify-between">
<span class="text-muted-foreground">状态</span>
<span :class="toneClass(token?.is_valid ? 'success' : 'danger')">{{
token?.is_valid ? '可用' : '不可用'
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground">即将过期</span>
<span>{{ token?.expiring_soon ? '是' : '否' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground">剩余天数</span>
<span>{{ token?.days_until_expiry ?? '未知' }}</span>
</div>
</div>
</div>
</aside>
</div>
</template>
+101 -413
View File
@@ -1,421 +1,109 @@
<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>
<script setup lang="ts">
import { ArrowLeft, Search } from 'lucide-vue-next'
import { onMounted, reactive, ref } from 'vue'
import { checkInApi, taskApi, type CheckInRecord, type Task } from '@/api'
import { useRouter } from '@/app/router'
import StateBlock from '@/components/StateBlock.vue'
import { cardClass, inputClass, sectionHeaderClass, toneClass } from '@/components/ui'
import { Button } from '@/components/ui/button'
import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format'
<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 '未知';
const router = useRouter()
const taskId = Number(router.params.value.taskId)
const task = ref<Task | null>(null)
const records = ref<CheckInRecord[]>([])
const total = ref(0)
const loading = ref(true)
const error = ref('')
const filters = reactive({ status: '', trigger_type: '', skip: 0, limit: 20 })
async function load() {
loading.value = true
error.value = ''
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 || '获取打卡记录失败');
const [taskDetail, page] = await Promise.all([
taskApi.detail(taskId),
checkInApi.taskRecords(taskId, filters),
])
task.value = taskDetail
records.value = page.records
total.value = page.total
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
loading.value = false;
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();
});
onMounted(load)
</script>
<style scoped>
/* Additional component-specific styles if needed */
</style>
<template>
<section :class="[cardClass, 'overflow-hidden']">
<div :class="[sectionHeaderClass, 'lg:grid-cols-[1fr_180px_180px_auto]']">
<div>
<button
class="mb-2 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
type="button"
@click="router.navigate('/tasks')"
>
<ArrowLeft class="size-4" />
返回任务
</button>
<h2 class="font-semibold">{{ task?.name || `任务 #${taskId}` }} 的打卡记录</h2>
</div>
<select v-model="filters.status" :class="inputClass">
<option value="">全部状态</option>
<option value="success">成功</option>
<option value="failure">失败</option>
</select>
<select v-model="filters.trigger_type" :class="inputClass">
<option value="">全部触发</option>
<option value="manual">手动</option>
<option value="scheduler">定时</option>
</select>
<Button variant="outline" type="button" @click="load">
<Search class="size-4" />
筛选
</Button>
</div>
<StateBlock v-if="loading" title="正在加载任务记录" type="loading" />
<StateBlock
v-else-if="error"
title="任务记录加载失败"
:description="error"
type="error"
action-label="重试"
@action="load"
/>
<StateBlock v-else-if="records.length === 0" title="暂无记录" />
<div v-else class="divide-y divide-border">
<article
v-for="record in records"
:key="record.id"
class="grid gap-3 p-3 md:grid-cols-[180px_minmax(0,1fr)_auto] md:items-center"
>
<div class="text-sm text-muted-foreground">
{{ formatFullDateTime(record.check_in_time) }}
</div>
<div class="min-w-0">
<div class="truncate text-sm text-foreground">
{{ record.response_text || record.error_message || '无响应内容' }}
</div>
<div class="mt-1 text-xs text-muted-foreground">
触发{{ statusLabel(record.trigger_type) }}
</div>
</div>
<div class="md:text-right">
<span :class="toneClass(statusTone(record.status))">{{
statusLabel(record.status)
}}</span>
</div>
</article>
<div class="border-t border-border bg-muted/55 px-4 py-3 text-sm text-muted-foreground">
{{ total }} 条记录
</div>
</div>
</section>
</template>
File diff suppressed because it is too large Load Diff
-134
View File
@@ -1,134 +0,0 @@
<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>
@@ -1,190 +0,0 @@
<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
@@ -1,181 +0,0 @@
<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>

Some files were not shown because too many files have changed in this diff Show More