first commit

This commit is contained in:
2025-07-08 00:52:10 +08:00
commit aa2416c5d6
69 changed files with 16628 additions and 0 deletions

35
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
FROM node:18-alpine
# 设置工作目录
WORKDIR /app
# 安装系统依赖
RUN apk add --no-cache \
curl
# 复制 package.json 和 package-lock.json
COPY package*.json ./
# 安装依赖
RUN npm ci
# 复制源代码
COPY . .
# 创建非root用户
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
# 更改文件所有权
RUN chown -R nodejs:nodejs /app
USER nodejs
# 暴露端口
EXPOSE 3000
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000 || exit 1
# 启动命令 - 支持环境变量
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

108
frontend/README.md Normal file
View File

@@ -0,0 +1,108 @@
# Pandora 前端应用
基于 Vue 3 + TypeScript + Vite 构建的现代化前端应用。
## 技术栈
- **Vue 3**: 现代化的前端框架
- **TypeScript**: 类型安全的JavaScript
- **Vite**: 快速的构建工具
- **Tailwind CSS**: 实用优先的CSS框架
- **Vue Router**: 客户端路由
- **Pinia**: 状态管理
- **VueUse**: 组合式工具库
- **VeeValidate**: 表单验证
- **Vue Toastification**: 通知组件
## 项目结构
```
src/
├── components/ # 可复用组件
├── views/ # 页面组件
├── stores/ # Pinia状态管理
├── utils/ # 工具函数
├── types/ # TypeScript类型定义
├── assets/ # 静态资源
├── router/ # 路由配置
├── App.vue # 根组件
├── main.ts # 应用入口
└── style.css # 全局样式
```
## 开发指南
### 安装依赖
```bash
npm install
```
### 启动开发服务器
```bash
npm run dev
```
### 构建生产版本
```bash
npm run build
```
### 代码检查
```bash
npm run lint
```
### 运行测试
```bash
npm test
```
## 主要功能
1. **Claude风格首页** - 现代化的AI助手风格界面集成登录和注册功能
2. **用户仪表板** - 显示可用账号和统计信息
3. **管理后台** - 用户和权限管理界面
4. **响应式设计** - 适配各种设备尺寸
5. **深色模式** - 支持深色/浅色主题切换
## 组件说明
### 页面组件
- `Home.vue` - 首页,包含登录和注册功能
- `Dashboard.vue` - 用户仪表板
- `Admin.vue` - 管理后台
- `NotFound.vue` - 404页面
### 工具组件
- `icons/` - SVG图标组件
- 更多组件开发中...
## 样式系统
使用 Tailwind CSS 构建,包含:
- 响应式设计
- 深色模式支持
- 自定义组件类
- 动画效果
## 状态管理
使用 Pinia 进行状态管理主要store
- 用户认证状态
- 应用配置
- 主题设置
## 路由配置
- `/` - 首页(包含登录/注册功能)
- `/dashboard` - 用户仪表板
- `/admin` - 管理后台
- `/*` - 404页面
## 开发规范
- 使用 TypeScript 严格模式
- 遵循 Vue 3 Composition API
- 使用 ESLint 和 Prettier
- 组件命名使用 PascalCase
- 文件命名使用 kebab-case

17
frontend/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pandora - 网站账号共享系统</title>
<meta name="description" content="现代化的网站账号共享管理系统" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

50
frontend/package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "pandora-frontend",
"version": "1.0.0",
"description": "Pandora 前端应用",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"vue": "^3.3.8",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"@vueuse/core": "^10.4.1",
"vee-validate": "^4.10.5",
"vue-toastification": "^2.0.0-rc.5",
"axios": "^1.5.0",
"@headlessui/vue": "^1.7.16",
"@heroicons/vue": "^2.0.18",
"clsx": "^2.0.0",
"tailwind-merge": "^1.14.0"
},
"devDependencies": {
"@types/node": "^20.6.3",
"@vitejs/plugin-vue": "^4.4.0",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/test-utils": "^2.4.2",
"autoprefixer": "^10.4.15",
"eslint": "^8.49.0",
"eslint-plugin-vue": "^9.17.0",
"jsdom": "^22.1.0",
"postcss": "^8.4.29",
"prettier": "^3.0.3",
"tailwindcss": "^3.3.3",
"typescript": "~5.2.0",
"vite": "^4.4.11",
"vitest": "^0.34.4",
"vue-tsc": "^1.8.15"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

15
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,15 @@
<template>
<div id="app" class="min-h-screen bg-gray-50 dark:bg-gray-900">
<router-view />
</div>
</template>
<script setup lang="ts">
// 根组件逻辑
</script>
<style>
#app {
font-family: 'Inter', system-ui, sans-serif;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,35 @@
// 用户图标
export const UserIcon = {
template: `
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
`
}
// 钥匙图标
export const KeyIcon = {
template: `
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4l-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
`
}
// 盾牌图标
export const ShieldIcon = {
template: `
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
`
}
// 图表图标
export const ChartIcon = {
template: `
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
`
}

View File

@@ -0,0 +1,62 @@
// 网站URL配置
// 支持通过Docker环境变量配置
export interface WebsiteConfig {
name: string
url: string
icon: string
}
// 从环境变量获取URL如果没有配置则使用默认值
const getWebsiteUrls = () => {
// 检查是否在浏览器环境中
if (typeof window !== 'undefined') {
// 在客户端使用Vite定义的全局变量
return {
claude: (window as any).__CLAUDE_URL__ || 'https://chat.micar9.com:8443',
chatgpt: (window as any).__CHATGPT_URL__ || 'https://chat.openai.com',
grok: (window as any).__GROK_URL__ || 'https://grok-mirror.micar9.com:8443'
}
}
// 在服务端,使用环境变量
return {
claude: process.env.CLAUDE_TARGET_URL || 'https://chat.micar9.com:8443',
chatgpt: process.env.CHATGPT_TARGET_URL || 'https://chat.openai.com',
grok: process.env.GROK_TARGET_URL || 'https://grok-mirror.micar9.com:8443'
}
}
// 网站配置
export const websiteConfigs: Record<string, WebsiteConfig> = {
claude: {
name: 'Claude',
url: getWebsiteUrls().claude,
icon: '/src/assets/claude.png'
},
chatgpt: {
name: 'ChatGPT',
url: getWebsiteUrls().chatgpt,
icon: '/src/assets/ChatGPT.png'
},
grok: {
name: 'Grok',
url: getWebsiteUrls().grok,
icon: '/src/assets/grok.png'
}
}
// 获取网站URL的函数
export const getWebsiteUrl = (site: string): string => {
return websiteConfigs[site]?.url || ''
}
// 获取网站配置的函数
export const getWebsiteConfig = (site: string): WebsiteConfig | null => {
return websiteConfigs[site] || null
}
// 获取所有网站配置
export const getAllWebsiteConfigs = (): WebsiteConfig[] => {
return Object.values(websiteConfigs)
}

11
frontend/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_APP_TITLE: string
readonly VITE_APP_VERSION: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

44
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,44 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Toast from 'vue-toastification'
import 'vue-toastification/dist/index.css'
import App from './App.vue'
import router from './router'
import './style.css'
import { useAuthStore } from './stores/auth'
import { useAdminStore } from './stores/admin'
const app = createApp(App)
// 配置 Pinia 状态管理
const pinia = createPinia()
app.use(pinia)
// 初始化认证状态
const authStore = useAuthStore()
const adminStore = useAdminStore()
authStore.initAuth()
adminStore.initAuth()
// 配置路由
app.use(router)
// 配置 Toast 通知
app.use(Toast, {
position: 'top-right',
timeout: 5000,
closeOnClick: true,
pauseOnFocusLoss: true,
pauseOnHover: true,
draggable: true,
draggablePercent: 0.6,
showCloseButtonOnHover: false,
hideProgressBar: false,
closeButton: 'button',
icon: true,
rtl: false,
})
app.mount('#app')

View File

@@ -0,0 +1,105 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useAdminStore } from '@/stores/admin'
import { adminAuth } from '@/utils/auth'
import { useToast } from 'vue-toastification'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: { title: '首页' }
},
{
path: '/admin/login',
name: 'AdminLogin',
component: () => import('@/views/AdminLogin.vue'),
meta: { title: '管理员登录' }
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '仪表板', requiresAuth: true }
},
{
path: '/admin',
name: 'Admin',
component: () => import('@/views/Admin.vue'),
meta: { title: '管理后台', requiresAdminAuth: true }
},
{
path: '/admin/users',
name: 'AdminUsers',
component: () => import('@/views/AdminUsers.vue'),
meta: { title: '用户管理', requiresAdminAuth: true }
},
{
path: '/admin/accounts',
name: 'AdminAccounts',
component: () => import('@/views/AdminAccounts.vue'),
meta: { title: '账号管理', requiresAdminAuth: true }
},
{
path: '/admin/permissions',
name: 'AdminPermissions',
component: () => import('@/views/AdminPermissions.vue'),
meta: { title: '权限管理', requiresAdminAuth: true }
},
{
path: '/admin/monitor',
name: 'AdminMonitor',
component: () => import('@/views/AdminMonitor.vue'),
meta: { title: '系统监控', requiresAdminAuth: true }
},
{
path: '/test',
name: 'Test',
component: () => import('@/views/Test.vue'),
meta: { title: 'API测试' }
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue'),
meta: { title: '页面未找到' }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach((to: any, from: any, next: any) => {
// 设置页面标题
document.title = `${to.meta.title} - Pandora`
// 获取认证状态
const authStore = useAuthStore()
const adminStore = useAdminStore()
const toast = useToast()
// 检查是否需要用户认证
if (to.meta.requiresAuth) {
if (!authStore.isLoggedIn) {
next('/')
return
}
}
// 检查是否需要管理员认证
if (to.meta.requiresAdminAuth) {
if (!adminAuth.isLoggedIn()) {
next('/admin/login')
return
}
}
next()
})
export default router

View File

@@ -0,0 +1,248 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { adminAPI } from '@/utils/api'
import { adminAuth } from '@/utils/auth'
import type { User, PaginatedResponse } from '@/types'
export const useAdminStore = defineStore('admin', () => {
// 状态
const admin = ref<User | null>(null)
const token = ref<string | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const users = ref<User[]>([])
const pagination = ref({
page: 1,
limit: 10,
total: 0,
totalPages: 0
})
// 计算属性
const isLoggedIn = computed(() => !!token.value && !!admin.value)
// 初始化认证状态
const initAuth = () => {
const storedToken = adminAuth.getToken()
const storedAdmin = adminAuth.getAdminInfo()
if (storedToken && storedAdmin) {
token.value = storedToken
admin.value = storedAdmin
}
}
// 管理员登录
const login = async (data: { username: string; password: string }) => {
loading.value = true
error.value = null
try {
const response = await adminAPI.login(data)
// 保存认证信息
token.value = response.token
admin.value = response.admin
adminAuth.setLogin(response.token, response.admin)
return response
} catch (err: any) {
error.value = err.response?.data?.message || '登录失败'
throw err
} finally {
loading.value = false
}
}
// 管理员登出
const logout = async () => {
loading.value = true
error.value = null
try {
// 清除本地状态
token.value = null
admin.value = null
users.value = []
adminAuth.logout()
} catch (err) {
console.warn('Admin logout failed:', err)
} finally {
loading.value = false
}
}
// 获取用户列表
const loadUsers = async (params?: { page?: number; limit?: number; search?: string }) => {
loading.value = true
error.value = null
try {
const response = await adminAPI.getUsers(params)
users.value = response.users
pagination.value = response.pagination
return response
} catch (err: any) {
error.value = err.response?.data?.message || '获取用户列表失败'
throw err
} finally {
loading.value = false
}
}
// 创建用户
const createUser = async (data: {
username: string
password: string
role: string
}) => {
loading.value = true
error.value = null
try {
const response = await adminAPI.createUser(data)
// 重新加载用户列表
await loadUsers()
return response
} catch (err: any) {
error.value = err.response?.data?.message || '创建用户失败'
throw err
} finally {
loading.value = false
}
}
// 更新用户
const updateUser = async (userId: string, data: any) => {
loading.value = true
error.value = null
try {
const response = await adminAPI.updateUser(userId, data)
// 重新加载用户列表
await loadUsers()
return response
} catch (err: any) {
error.value = err.response?.data?.message || '更新用户失败'
throw err
} finally {
loading.value = false
}
}
// 更新用户账号权限
const updateUserAccounts = async (userId: string, accountIds: string[]) => {
loading.value = true
error.value = null
try {
const response = await adminAPI.updateUserAccounts(userId, accountIds)
// 重新加载用户列表
await loadUsers()
return response
} catch (err: any) {
error.value = err.response?.data?.message || '更新用户账号权限失败'
throw err
} finally {
loading.value = false
}
}
// 删除用户
const deleteUser = async (userId: string) => {
loading.value = true
error.value = null
try {
const response = await adminAPI.deleteUser(userId)
// 重新加载用户列表
await loadUsers()
return response
} catch (err: any) {
error.value = err.response?.data?.message || '删除用户失败'
throw err
} finally {
loading.value = false
}
}
// 获取统计数据
const getStats = async () => {
loading.value = true
error.value = null
try {
const response = await adminAPI.getStats()
return response
} catch (err: any) {
error.value = err.response?.data?.message || '获取统计数据失败'
throw err
} finally {
loading.value = false
}
}
// 获取最近活动
const getRecentActivities = async () => {
loading.value = true
error.value = null
try {
const response = await adminAPI.getRecentActivities()
return response
} catch (err: any) {
error.value = err.response?.data?.message || '获取最近活动失败'
throw err
} finally {
loading.value = false
}
}
// 获取账号列表
const getAccounts = async (params?: { page?: number; limit?: number; search?: string; status?: string }) => {
loading.value = true
error.value = null
try {
const response = await adminAPI.getAccounts(params)
return response
} catch (err: any) {
error.value = err.response?.data?.message || '获取账号列表失败'
throw err
} finally {
loading.value = false
}
}
// 清除错误
const clearError = () => {
error.value = null
}
return {
// 状态
admin,
token,
loading,
error,
users,
pagination,
// 计算属性
isLoggedIn,
// 方法
initAuth,
login,
logout,
loadUsers,
createUser,
updateUser,
updateUserAccounts,
deleteUser,
getStats,
getRecentActivities,
getAccounts,
clearError
}
})

247
frontend/src/stores/auth.ts Normal file
View File

@@ -0,0 +1,247 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authAPI } from '@/utils/api'
import { userAuth } from '@/utils/auth'
import type { User } from '@/types'
export const useAuthStore = defineStore('auth', () => {
// 状态
const user = ref<User | null>(null)
const token = ref<string | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// 计算属性
const isLoggedIn = computed(() => !!token.value && !!user.value)
// 初始化认证状态
const initAuth = () => {
const storedToken = userAuth.getToken()
const storedUser = userAuth.getUserInfo()
if (storedToken && storedUser) {
token.value = storedToken
user.value = storedUser
} else {
}
}
// 用户注册
const register = async (data: {
username: string
password: string
confirmPassword: string
}) => {
loading.value = true
error.value = null
try {
const response = await authAPI.register(data)
return response
} catch (err: any) {
error.value = err.response?.data?.message || '注册失败'
throw err
} finally {
loading.value = false
}
}
// 用户登录
const login = async (data: {
username: string
password: string
}) => {
loading.value = true
error.value = null
try {
const response = await authAPI.login(data)
// 保存认证信息
token.value = response.token
user.value = response.user
userAuth.setLogin(response.token, response.user)
return response
} catch (err: any) {
throw err
} finally {
loading.value = false
}
}
// 邮箱验证
const verifyEmail = async (token: string) => {
loading.value = true
error.value = null
try {
const response = await authAPI.verifyEmail(token)
return response
} catch (err: any) {
error.value = err.response?.data?.message || '邮箱验证失败'
throw err
} finally {
loading.value = false
}
}
// 重新发送验证邮件
const resendVerification = async (email: string) => {
loading.value = true
error.value = null
try {
const response = await authAPI.resendVerification(email)
return response
} catch (err: any) {
error.value = err.response?.data?.message || '发送验证邮件失败'
throw err
} finally {
loading.value = false
}
}
// 忘记密码
const forgotPassword = async (email: string) => {
loading.value = true
error.value = null
try {
const response = await authAPI.forgotPassword(email)
return response
} catch (err: any) {
error.value = err.response?.data?.message || '发送重置邮件失败'
throw err
} finally {
loading.value = false
}
}
// 重置密码
const resetPassword = async (data: {
token: string
password: string
}) => {
loading.value = true
error.value = null
try {
const response = await authAPI.resetPassword(data)
return response
} catch (err: any) {
error.value = err.response?.data?.message || '重置密码失败'
throw err
} finally {
loading.value = false
}
}
// 设置TOTP
const setupTOTP = async () => {
loading.value = true
error.value = null
try {
const response = await authAPI.setupTOTP()
return response
} catch (err: any) {
error.value = err.response?.data?.message || '设置二步验证失败'
throw err
} finally {
loading.value = false
}
}
// 验证TOTP
const verifyTOTP = async (totpToken: string) => {
loading.value = true
error.value = null
try {
const response = await authAPI.verifyTOTP(totpToken)
// 更新用户信息
if (user.value && token.value) {
user.value.totpEnabled = true
userAuth.setLogin(token.value, user.value)
}
return response
} catch (err: any) {
error.value = err.response?.data?.message || '验证失败'
throw err
} finally {
loading.value = false
}
}
// 获取用户信息
const getProfile = async () => {
loading.value = true
error.value = null
try {
const response = await authAPI.getProfile()
user.value = response.user
if (token.value) {
userAuth.setLogin(token.value, response.user)
}
return response
} catch (err: any) {
error.value = err.response?.data?.message || '获取用户信息失败'
throw err
} finally {
loading.value = false
}
}
// 用户登出
const logout = async () => {
loading.value = true
error.value = null
try {
await authAPI.logout()
} catch (err: any) {
console.error('登出API调用失败:', err)
} finally {
// 清除本地状态
token.value = null
user.value = null
userAuth.logout()
loading.value = false
}
}
// 清除错误
const clearError = () => {
error.value = null
}
return {
// 状态
user,
token,
loading,
error,
// 计算属性
isLoggedIn,
// 方法
initAuth,
register,
login,
verifyEmail,
resendVerification,
forgotPassword,
resetPassword,
setupTOTP,
verifyTOTP,
getProfile,
logout,
clearError
}
})

39
frontend/src/style.css Normal file
View File

@@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: 'Inter', system-ui, sans-serif;
}
body {
@apply antialiased;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-200;
}
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
}
.btn-secondary {
@apply btn bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500;
}
.btn-outline {
@apply btn border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:ring-primary-500;
}
.input {
@apply block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm;
}
.card {
@apply bg-white dark:bg-gray-800 shadow rounded-lg;
}
}

119
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,119 @@
// 用户相关类型
export interface User {
id: string
username: string
role: string
isActive: boolean
emailVerified?: boolean
totpEnabled: boolean
createdAt: string
updatedAt: string
}
// 网站账号相关类型
export interface WebsiteAccount {
id: string
website: string
username: string
token?: string
isActive: boolean
createdAt: string
updatedAt: string
assignedUsers?: User[]
}
// 账号分配相关类型
export interface AccountAssignment {
id: string
userId: string
accountId: string
assignedAt: string
expiresAt?: string
isActive: boolean
account: WebsiteAccount
user: User
}
// 会话相关类型
export interface Session {
id: string
userId: string
token: string
expiresAt: string
createdAt: string
user: User
}
// 审计日志相关类型
export interface AuditLog {
id: string
userId: string
action: string
resource: string
resourceId?: string
details?: any
ipAddress?: string
userAgent?: string
createdAt: string
user: User
}
// API响应类型
export interface ApiResponse<T = any> {
success: boolean
message: string
data?: T
error?: string
}
// 分页响应类型
export interface PaginatedResponse<T> {
data: T[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}
// 认证相关类型
export interface LoginRequest {
username: string
password: string
}
export interface RegisterRequest {
username: string
password: string
confirmPassword: string
}
export interface LoginResponse {
token: string
user: User
}
export interface RegisterResponse {
message: string
user: User
}
// 表单验证类型
export interface ValidationError {
field: string
message: string
}
// 主题类型
export type Theme = 'light' | 'dark' | 'system'
// 通知类型
export interface Notification {
id: string
type: 'success' | 'error' | 'warning' | 'info'
title: string
message: string
duration?: number
createdAt: string
}

13
frontend/src/types/vue.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module 'vue-router' {
interface RouteMeta {
title?: string
requiresAuth?: boolean
requiresAdmin?: boolean
}
}

294
frontend/src/utils/api.ts Normal file
View File

@@ -0,0 +1,294 @@
import axios, { AxiosInstance, AxiosResponse } from 'axios'
import { userAuth, adminAuth } from './auth'
// API基础配置
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
// 创建axios实例
const api: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器 - 添加认证token
api.interceptors.request.use(
(config) => {
// 优先使用管理员token如果没有则使用用户token
let token = adminAuth.getToken()
if (!token) {
token = userAuth.getToken()
}
if (token) {
config.headers.Authorization = `Bearer ${token}`
} else {
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器 - 处理认证错误
api.interceptors.response.use(
(response: AxiosResponse) => {
return response
},
(error) => {
if (error.response?.status === 401) {
// Token过期或无效清除所有认证状态
userAuth.logout()
adminAuth.logout()
// 自动重定向到登录页面(如果不是已经在登录页面)
if (window.location.pathname !== '/' && window.location.pathname !== '/login') {
window.location.href = '/'
}
}
return Promise.reject(error)
}
)
// 用户认证API
export const authAPI = {
// 用户注册
async register(data: {
username: string
password: string
confirmPassword: string
}) {
// 只发送后端需要的字段
const requestData = {
username: data.username,
password: data.password,
confirmPassword: data.confirmPassword
}
const response = await api.post('/auth/register', requestData)
return response.data
},
// 用户登录
async login(data: {
username: string
password: string
}) {
try {
const response = await api.post('/auth/login', data)
return response.data
} catch (error: any) {
throw error
}
},
// 邮箱验证
async verifyEmail(token: string) {
const response = await api.post('/auth/verify-email', { token })
return response.data
},
// 重新发送验证邮件
async resendVerification(email: string) {
const response = await api.post('/auth/resend-verification', { email })
return response.data
},
// 忘记密码
async forgotPassword(email: string) {
const response = await api.post('/auth/forgot-password', { email })
return response.data
},
// 重置密码
async resetPassword(data: {
token: string
password: string
}) {
const response = await api.post('/auth/reset-password', data)
return response.data
},
// 设置TOTP
async setupTOTP() {
const response = await api.post('/auth/setup-totp')
return response.data
},
// 验证TOTP
async verifyTOTP(token: string) {
const response = await api.post('/auth/verify-totp', { token })
return response.data
},
// 获取用户信息
async getProfile() {
const response = await api.get('/auth/me')
return response.data
},
// 更新用户信息
async updateProfile(data: {
username?: string
email?: string
currentPassword?: string
newPassword?: string
}) {
const response = await api.put('/auth/profile', data)
return response.data
},
// 用户登出
async logout() {
const response = await api.post('/auth/logout')
return response.data
},
// 获取TOTP二维码
async getTOTPQRCode() {
const response = await api.get('/auth/totp/qr-code')
return response.data
},
// 启用TOTP
async enableTOTP(token: string) {
const response = await api.post('/auth/totp/enable', { token })
return response.data
},
// 禁用TOTP
async disableTOTP(password: string) {
const response = await api.post('/auth/totp/disable', { password })
return response.data
}
}
// 账号管理API
export const accountAPI = {
// 获取用户可用账号
async getUserAccounts() {
const response = await api.get('/accounts/user/assigned')
return response.data
},
// 获取账号详情
async getAccountDetails(accountId: string) {
const response = await api.get(`/accounts/${accountId}`)
return response.data
},
// 登录到网站
async loginToWebsite(accountId: string, userId: string) {
const response = await api.post(`/accounts/${accountId}/login`, { userId })
return response.data
}
}
// 管理员API
export const adminAPI = {
// 管理员登录
async login(data: { username: string; password: string }) {
const response = await api.post('/admin/login', data)
return response.data
},
// 获取用户列表
async getUsers(params?: { page?: number; limit?: number; search?: string }) {
const response = await api.get('/admin/users', { params })
return response.data
},
// 创建用户
async createUser(data: {
username: string
password: string
role: string
}) {
const response = await api.post('/admin/users', data)
return response.data
},
// 更新用户
async updateUser(userId: string, data: any) {
const response = await api.put(`/admin/users/${userId}`, data)
return response.data
},
// 更新用户账号权限
async updateUserAccounts(userId: string, accountIds: string[]) {
const response = await api.put(`/admin/users/${userId}/accounts`, { accountIds })
return response.data
},
// 删除用户
async deleteUser(userId: string) {
const response = await api.delete(`/admin/users/${userId}`)
return response.data
},
// 获取统计数据
async getStats() {
console.log('发送获取统计数据请求...')
const response = await api.get('/admin/stats')
console.log('统计数据API响应:', response.data)
return response.data
},
// 获取最近活动
async getRecentActivities() {
console.log('发送获取最近活动请求...')
const response = await api.get('/admin/activities')
console.log('最近活动API响应:', response.data)
return response.data
},
// 获取账号列表
async getAccounts(params?: { page?: number; limit?: number; search?: string; status?: string }) {
const response = await api.get('/admin/accounts', { params })
return response.data
},
// 创建账号
async createAccount(data: {
website: string
username: string
token?: string
isActive: boolean
}) {
const requestData = {
website: data.website,
accountName: data.username,
token: data.token,
isActive: data.isActive
}
const response = await api.post('/admin/accounts', requestData)
return response.data
},
// 更新账号
async updateAccount(accountId: string, data: any) {
const response = await api.put(`/admin/accounts/${accountId}`, data)
return response.data
},
// 删除账号
async deleteAccount(accountId: string) {
const response = await api.delete(`/admin/accounts/${accountId}`)
return response.data
}
}
// 路径API
export const pathAPI = {
// 获取路径
async getPaths() {
const response = await api.get('/paths')
return response.data
}
}
export default api

View File

@@ -0,0 +1,96 @@
// 认证工具函数
// 用户认证相关
export const userAuth = {
// 检查用户是否已登录
isLoggedIn(): boolean {
return !!localStorage.getItem('userToken')
},
// 获取用户token
getToken(): string | null {
return localStorage.getItem('userToken')
},
// 获取用户信息
getUserInfo(): any {
const userInfo = localStorage.getItem('userInfo')
return userInfo ? JSON.parse(userInfo) : null
},
// 设置用户登录状态
setLogin(token: string, userInfo: any): void {
localStorage.setItem('userToken', token)
localStorage.setItem('userInfo', JSON.stringify(userInfo))
},
// 清除用户登录状态
logout(): void {
localStorage.removeItem('userToken')
localStorage.removeItem('userInfo')
}
}
// 管理员认证相关
export const adminAuth = {
// 检查管理员是否已登录
isLoggedIn(): boolean {
return !!localStorage.getItem('adminToken')
},
// 获取管理员token
getToken(): string | null {
return localStorage.getItem('adminToken')
},
// 获取管理员信息
getAdminInfo(): any {
try {
const adminInfo = localStorage.getItem('adminUser')
return adminInfo ? JSON.parse(adminInfo) : null
} catch (error) {
console.error('解析管理员信息失败:', error)
// 清除可能损坏的数据
localStorage.removeItem('adminUser')
return null
}
},
// 设置管理员登录状态
setLogin(token: string, adminInfo: any): void {
try {
if (!token || !adminInfo) {
console.error('设置管理员登录状态失败token 或 adminInfo 为空')
return
}
if (!adminInfo.username) {
console.error('设置管理员登录状态失败adminInfo 缺少 username 字段')
return
}
localStorage.setItem('adminToken', token)
localStorage.setItem('adminUser', JSON.stringify(adminInfo))
console.log('管理员登录状态设置成功:', { token, adminInfo })
} catch (error) {
console.error('设置管理员登录状态失败:', error)
}
},
// 清除管理员登录状态
logout(): void {
localStorage.removeItem('adminToken')
localStorage.removeItem('adminUser')
}
}
// 验证管理员凭据
export const validateAdminCredentials = (username: string, password: string): boolean => {
// 这里可以扩展为从API验证
const validCredentials = {
username: 'admin',
password: 'admin123'
}
return username === validCredentials.username && password === validCredentials.password
}

View File

@@ -0,0 +1,472 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- 导航栏 -->
<nav class="bg-white dark:bg-gray-800 shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<h1 class="text-2xl font-bold text-primary-600 dark:text-primary-400">
Pandora 管理后台
</h1>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-600 dark:text-gray-300">
管理员{{ adminUser?.username || '未知用户' }}
</span>
<button class="btn-outline" @click="handleLogout">
退出登录
</button>
</div>
</div>
</div>
</nav>
<!-- 主要内容 -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 页面标题 -->
<div class="mb-8">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">
系统管理
</h2>
<p class="text-gray-600 dark:text-gray-300">
管理用户权限和系统配置
</p>
</div>
<!-- 管理菜单 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div
v-for="menu in adminMenus"
:key="menu.id"
class="card p-6 cursor-pointer hover:shadow-lg transition-shadow"
@click="navigateToMenu(menu)"
>
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="menu.icon === 'UserIcon'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
<path v-else-if="menu.icon === 'KeyIcon'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
<path v-else-if="menu.icon === 'ShieldIcon'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
<path v-else-if="menu.icon === 'ChartIcon'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
</div>
<div class="ml-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
{{ menu.name }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ menu.description }}
</p>
</div>
</div>
</div>
</div>
<!-- 系统统计 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="card p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">总用户数</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-white">{{ stats.totalUsers }}</p>
</div>
</div>
</div>
<div class="card p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9v-9m0-9v9" />
</svg>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">总账号数</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-white">{{ stats.totalAccounts }}</p>
</div>
</div>
</div>
<div class="card p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-yellow-100 dark:bg-yellow-900 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">今日访问</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-white">{{ stats.todayVisits }}</p>
</div>
</div>
</div>
<div class="card p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-red-100 dark:bg-red-900 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">系统告警</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-white">{{ stats.alerts }}</p>
</div>
</div>
</div>
</div>
<!-- 快速操作 -->
<div class="mb-8">
<!-- 快速操作卡片 -->
<div class="card">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
快速操作
</h3>
</div>
<div class="p-6">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<button
@click="router.push('/admin/users')"
class="flex items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div class="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center mr-3">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
</div>
<div class="text-left">
<p class="text-sm font-medium text-gray-900 dark:text-white">添加用户</p>
<p class="text-xs text-gray-500 dark:text-gray-400">创建新用户账号</p>
</div>
</button>
<button
@click="router.push('/admin/accounts')"
class="flex items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div class="w-8 h-8 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center mr-3">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<div class="text-left">
<p class="text-sm font-medium text-gray-900 dark:text-white">添加账号</p>
<p class="text-xs text-gray-500 dark:text-gray-400">管理网站账号</p>
</div>
</button>
<button
@click="router.push('/admin/permissions')"
class="flex items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div class="w-8 h-8 bg-purple-100 dark:bg-purple-900 rounded-lg flex items-center justify-center mr-3">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div class="text-left">
<p class="text-sm font-medium text-gray-900 dark:text-white">权限设置</p>
<p class="text-xs text-gray-500 dark:text-gray-400">配置访问权限</p>
</div>
</button>
<button
@click="router.push('/admin/monitor')"
class="flex items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div class="w-8 h-8 bg-orange-100 dark:bg-orange-900 rounded-lg flex items-center justify-center mr-3">
<svg class="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div class="text-left">
<p class="text-sm font-medium text-gray-900 dark:text-white">系统监控</p>
<p class="text-xs text-gray-500 dark:text-gray-400">查看运行状态</p>
</div>
</button>
</div>
</div>
</div>
</div>
<!-- 最近活动 -->
<div class="card">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
最近活动
</h3>
</div>
<div class="p-6">
<div v-if="recentActivities.length === 0" class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">暂无活动</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
系统运行正常暂无异常活动
</p>
</div>
<div v-else class="space-y-4">
<div
v-for="activity in recentActivities"
:key="activity.id"
class="flex items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg"
:class="{
'border-red-200 dark:border-red-700 bg-red-50 dark:bg-red-900/20':
activity.action === 'USER_LOGIN_FAILED' || activity.action === 'ADMIN_LOGIN_FAILED'
}"
>
<div class="flex-shrink-0">
<div
class="w-8 h-8 rounded-full flex items-center justify-center"
:class="{
'bg-red-100 dark:bg-red-900': activity.action === 'USER_LOGIN_FAILED' || activity.action === 'ADMIN_LOGIN_FAILED',
'bg-gray-100 dark:bg-gray-800': !(activity.action === 'USER_LOGIN_FAILED' || activity.action === 'ADMIN_LOGIN_FAILED')
}"
>
<svg
class="w-4 h-4"
:class="{
'text-red-600 dark:text-red-400': activity.action === 'USER_LOGIN_FAILED' || activity.action === 'ADMIN_LOGIN_FAILED',
'text-gray-600 dark:text-gray-400': !(activity.action === 'USER_LOGIN_FAILED' || activity.action === 'ADMIN_LOGIN_FAILED')
}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
v-if="activity.action === 'USER_LOGIN_FAILED' || activity.action === 'ADMIN_LOGIN_FAILED'"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
<path
v-else
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
</div>
<div class="ml-4 flex-1">
<p
class="text-sm"
:class="{
'text-red-900 dark:text-red-100': activity.action === 'USER_LOGIN_FAILED' || activity.action === 'ADMIN_LOGIN_FAILED',
'text-gray-900 dark:text-white': !(activity.action === 'USER_LOGIN_FAILED' || activity.action === 'ADMIN_LOGIN_FAILED')
}"
>
{{ activity.description }}
</p>
<div class="flex items-center space-x-4 mt-1">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ formatTime(activity.time) }}
</p>
<p v-if="activity.ipAddress" class="text-xs text-blue-600 dark:text-blue-400">
IP: {{ activity.ipAddress }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAdminStore } from '@/stores/admin'
import { adminAuth } from '@/utils/auth'
import { useToast } from 'vue-toastification'
const router = useRouter()
const adminStore = useAdminStore()
const toast = useToast()
// 管理菜单
const adminMenus = ref([
{
id: 1,
name: '用户管理',
description: '管理用户账号和权限',
icon: 'UserIcon',
route: '/admin/users'
},
{
id: 2,
name: '账号管理',
description: '管理网站账号和token',
icon: 'KeyIcon',
route: '/admin/accounts'
},
{
id: 3,
name: '权限管理',
description: '配置用户访问权限',
icon: 'ShieldIcon',
route: '/admin/permissions'
},
{
id: 4,
name: '系统监控',
description: '查看系统运行状态',
icon: 'ChartIcon',
route: '/admin/monitor'
}
])
// 统计数据
const stats = ref({
totalUsers: 0,
totalAccounts: 0,
todayVisits: 0,
alerts: 0
})
// 最近活动
const recentActivities = ref([])
// 导航到菜单
const navigateToMenu = (menu: any) => {
router.push(menu.route)
}
// 管理员用户信息
const adminUser = ref<any>(null)
// 退出登录
const handleLogout = () => {
adminAuth.logout()
router.push('/admin/login')
}
// 格式化时间
const formatTime = (time: string) => {
const date = new Date(time)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) { // 1分钟内
return '刚刚'
} else if (diff < 3600000) { // 1小时内
return `${Math.floor(diff / 60000)}分钟前`
} else if (diff < 86400000) { // 1天内
return `${Math.floor(diff / 3600000)}小时前`
} else {
return date.toLocaleDateString('zh-CN')
}
}
// 加载统计数据
const loadStats = async () => {
try {
console.log('开始加载统计数据...')
const response = await adminStore.getStats()
console.log('统计数据响应:', response)
if (response.success && response.data) {
stats.value = response.data
console.log('统计数据加载成功:', stats.value)
} else {
console.error('统计数据格式错误:', response)
toast.error('统计数据格式错误')
// 设置默认值
stats.value = {
totalUsers: 0,
totalAccounts: 0,
todayVisits: 0,
alerts: 0
}
}
} catch (error) {
console.error('加载统计数据失败:', error)
toast.error('加载统计数据失败')
// 设置默认值
stats.value = {
totalUsers: 0,
totalAccounts: 0,
todayVisits: 0,
alerts: 0
}
}
}
// 加载最近活动
const loadRecentActivities = async () => {
try {
console.log('开始加载最近活动...')
const response = await adminStore.getRecentActivities()
console.log('最近活动响应:', response)
if (response.success && response.data) {
recentActivities.value = response.data.activities || []
console.log('最近活动加载成功:', recentActivities.value)
} else {
console.error('最近活动数据格式错误:', response)
toast.error('最近活动数据格式错误')
recentActivities.value = []
}
} catch (error) {
console.error('加载最近活动失败:', error)
toast.error('加载最近活动失败')
recentActivities.value = []
}
}
// 组件挂载时获取数据
onMounted(async () => {
// 检查管理员登录状态
if (!adminAuth.isLoggedIn()) {
router.push('/admin/login')
return
}
// 获取管理员用户信息
const adminInfo = adminAuth.getAdminInfo()
if (!adminInfo) {
// 如果没有管理员信息,清除登录状态并跳转到登录页
adminAuth.logout()
router.push('/admin/login')
return
}
adminUser.value = adminInfo
// 加载数据
try {
await Promise.all([
loadStats(),
loadRecentActivities()
])
} catch (error) {
console.error('加载数据失败:', error)
}
})
</script>

View File

@@ -0,0 +1,422 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- 导航栏 -->
<nav class="bg-white dark:bg-gray-800 shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<button @click="$router.back()" class="mr-4">
<svg class="w-6 h-6 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 class="text-2xl font-bold text-primary-600 dark:text-primary-400">
账号管理
</h1>
</div>
</div>
</div>
</nav>
<!-- 主要内容 -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 页面标题和操作 -->
<div class="flex justify-between items-center mb-8">
<div>
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">
网站账号
</h2>
<p class="text-gray-600 dark:text-gray-300">
管理系统中的所有网站账号和token
</p>
</div>
<button @click="showCreateModal = true" class="btn-primary">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
添加账号
</button>
</div>
<!-- 搜索和筛选 -->
<div class="card mb-6">
<div class="p-6">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<input
v-model="searchQuery"
type="text"
placeholder="搜索网站类型或用户名..."
class="input w-full"
@input="handleSearch"
/>
</div>
<div class="flex gap-2">
<select v-model="statusFilter" class="input" @change="handleSearch">
<option value="">所有状态</option>
<option value="active">活跃</option>
<option value="inactive">非活跃</option>
</select>
<button @click="handleSearch" class="btn-outline">
搜索
</button>
</div>
</div>
</div>
</div>
<!-- 账号列表 -->
<div class="card">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
账号信息
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
状态
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
分配用户
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
创建时间
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="account in accounts" :key="account.id" class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10">
<div class="h-10 w-10 rounded-lg bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
<!-- Claude 图标 -->
<img v-if="account.website === 'claude'" :src="claudeIcon" alt="Claude" class="w-6 h-6">
<!-- ChatGPT 图标 -->
<img v-else-if="account.website === 'chatgpt'" :src="chatgptIcon" alt="ChatGPT" class="w-6 h-6">
<!-- Grok 图标 -->
<img v-else-if="account.website === 'grok'" :src="grokIcon" alt="Grok" class="w-6 h-6">
<!-- 默认占位符 -->
<div v-else class="w-6 h-6 bg-gray-300 dark:bg-gray-600 rounded"></div>
</div>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ getWebsiteTypeName(account.website) }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
用户名: {{ account.username }}
</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
:class="account.isActive ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'">
{{ account.isActive ? '活跃' : '非活跃' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{{ account.assignedUsers?.length || 0 }} 个用户
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{{ formatDate(account.createdAt) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div class="flex space-x-2">
<button @click="editAccount(account)" class="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300">
编辑
</button>
<button @click="deleteAccount(account)" class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">
删除
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
<!-- 创建账号模态框 -->
<div v-if="showCreateModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white dark:bg-gray-800">
<div class="mt-3">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">添加网站账号</h3>
<form @submit.prevent="handleCreateAccount">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">网站类型</label>
<select v-model="createForm.website" required class="input mt-1 w-full">
<option value="">请选择网站类型</option>
<option value="claude">Claude</option>
<option value="chatgpt">ChatGPT</option>
<option value="grok">Grok</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">用户名</label>
<input v-model="createForm.username" type="text" required class="input mt-1 w-full" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Token</label>
<textarea v-model="createForm.token" rows="3" class="input mt-1 w-full" placeholder="输入API token或访问凭证"></textarea>
</div>
<div>
<label class="flex items-center">
<input v-model="createForm.isActive" type="checkbox" class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded" />
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">账号活跃</span>
</label>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button type="button" @click="showCreateModal = false" class="btn-outline">
取消
</button>
<button type="submit" :disabled="loading" class="btn-primary">
{{ loading ? '创建中...' : '创建' }}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 编辑账号模态框 -->
<div v-if="showEditModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white dark:bg-gray-800">
<div class="mt-3">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">编辑网站账号</h3>
<form @submit.prevent="handleUpdateAccount">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">网站类型</label>
<select v-model="editForm.website" required class="input mt-1 w-full">
<option value="">请选择网站类型</option>
<option value="claude">Claude</option>
<option value="chatgpt">ChatGPT</option>
<option value="grok">Grok</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">用户名</label>
<input v-model="editForm.username" type="text" required class="input mt-1 w-full" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Token</label>
<textarea v-model="editForm.token" rows="3" class="input mt-1 w-full" placeholder="输入API token或访问凭证"></textarea>
</div>
<div>
<label class="flex items-center">
<input v-model="editForm.isActive" type="checkbox" class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded" />
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">账号活跃</span>
</label>
</div>
<!-- 显示当前分配用户信息 -->
<div class="bg-gray-50 dark:bg-gray-700 p-3 rounded-lg">
<p class="text-sm text-gray-600 dark:text-gray-300">
当前分配用户: <span class="font-medium text-gray-900 dark:text-white">{{ selectedAccount?.assignedUsers?.length || 0 }} </span>
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
创建时间: {{ selectedAccount ? formatDate(selectedAccount.createdAt) : '' }}
</p>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button type="button" @click="closeEditModal" class="btn-outline">
取消
</button>
<button type="submit" :disabled="loading" class="btn-primary">
{{ loading ? '更新中...' : '更新' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import { adminAPI } from '@/utils/api'
import type { WebsiteAccount } from '@/types'
import claudeIcon from '@/assets/claude.png'
import chatgptIcon from '@/assets/ChatGPT.png'
import grokIcon from '@/assets/grok.png'
const toast = useToast()
// 账号列表
const accounts = ref<WebsiteAccount[]>([])
const loading = ref(false)
// 搜索和筛选
const searchQuery = ref('')
const statusFilter = ref('')
// 创建账号模态框
const showCreateModal = ref(false)
const createForm = reactive({
website: '',
username: '',
token: '',
isActive: true
})
// 编辑账号模态框
const showEditModal = ref(false)
const editForm = reactive({
id: '',
website: '',
username: '',
token: '',
isActive: true
})
const selectedAccount = ref<WebsiteAccount | null>(null)
// 格式化日期
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('zh-CN')
}
// 获取网站类型名称
const getWebsiteTypeName = (website: string) => {
switch (website) {
case 'claude':
return 'Claude'
case 'chatgpt':
return 'ChatGPT'
case 'grok':
return 'Grok'
default:
return website
}
}
// 搜索处理
const handleSearch = () => {
loadAccounts()
}
// 加载账号列表
const loadAccounts = async () => {
try {
loading.value = true
const response = await adminAPI.getAccounts({
search: searchQuery.value,
status: statusFilter.value
})
accounts.value = response.accounts || []
} catch (error) {
console.error('加载账号列表失败:', error)
toast.error('加载账号列表失败')
} finally {
loading.value = false
}
}
// 创建账号
const handleCreateAccount = async () => {
try {
loading.value = true
await adminAPI.createAccount(createForm)
toast.success('账号创建成功')
showCreateModal.value = false
// 重置表单
createForm.website = ''
createForm.username = ''
createForm.token = ''
createForm.isActive = true
// 重新加载列表
await loadAccounts()
} catch (error: any) {
toast.error(error.message || '创建账号失败')
} finally {
loading.value = false
}
}
// 关闭编辑模态框
const closeEditModal = () => {
showEditModal.value = false
selectedAccount.value = null
// 重置编辑表单
editForm.id = ''
editForm.website = ''
editForm.username = ''
editForm.token = ''
editForm.isActive = true
}
// 编辑账号
const editAccount = (account: WebsiteAccount) => {
// 设置选中的账号(用于显示用户信息)
selectedAccount.value = account
// 填充编辑表单
editForm.id = account.id
editForm.website = account.website
editForm.username = account.username
editForm.token = account.token || ''
editForm.isActive = account.isActive
// 显示编辑模态框
showEditModal.value = true
}
// 更新账号
const handleUpdateAccount = async () => {
try {
loading.value = true
// 准备更新数据
const updateData = {
website: editForm.website,
accountName: editForm.username,
token: editForm.token,
isActive: editForm.isActive
}
await adminAPI.updateAccount(editForm.id, updateData)
toast.success('账号更新成功')
closeEditModal()
// 重新加载列表
await loadAccounts()
} catch (error: any) {
toast.error(error.message || '更新账号失败')
} finally {
loading.value = false
}
}
// 删除账号
const deleteAccount = async (account: WebsiteAccount) => {
if (!confirm(`确定要删除账号 ${account.website} 吗?`)) {
return
}
try {
await adminAPI.deleteAccount(account.id)
toast.success('账号删除成功')
await loadAccounts()
} catch (error: any) {
toast.error(error.message || '删除账号失败')
}
}
// 组件挂载时加载数据
onMounted(() => {
loadAccounts()
})
</script>

View File

@@ -0,0 +1,208 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-red-50 to-red-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<!-- 标题 -->
<div class="text-center">
<div class="mx-auto h-16 w-16 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">
管理员登录
</h2>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">
仅限管理员账号访问
</p>
</div>
<!-- 登录表单 -->
<form class="mt-8 space-y-6" @submit.prevent="handleLogin">
<div class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
管理员用户名
</label>
<input
id="username"
v-model="form.username"
type="text"
required
class="input mt-1"
placeholder="请输入管理员用户名"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
管理员密码
</label>
<input
id="password"
v-model="form.password"
type="password"
required
class="input mt-1"
placeholder="请输入管理员密码"
/>
</div>
<!-- 二步验证 -->
<div v-if="showTwoFactor" class="space-y-4">
<div>
<label for="verificationCode" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
验证码
</label>
<input
id="verificationCode"
v-model="form.verificationCode"
type="text"
required
class="input mt-1"
placeholder="请输入验证码"
/>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember-me"
v-model="form.rememberMe"
type="checkbox"
class="h-4 w-4 text-red-600 focus:ring-red-500 border-gray-300 rounded"
/>
<label for="remember-me" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
记住我
</label>
</div>
<div class="text-sm">
<a href="#" class="font-medium text-red-600 hover:text-red-500">
忘记密码
</a>
</div>
</div>
<!-- 错误提示 -->
<div v-if="errorMessage" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-800 dark:text-red-200">
{{ errorMessage }}
</p>
</div>
</div>
</div>
<div>
<button
type="submit"
:disabled="loading"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="loading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
登录中...
</span>
<span v-else>管理员登录</span>
</button>
</div>
</form>
<!-- 返回首页 -->
<div class="text-center">
<router-link
to="/"
class="text-sm text-red-600 hover:text-red-500"
>
返回首页
</router-link>
</div>
<!-- 安全提示 -->
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-800 dark:text-yellow-200">
此页面仅限管理员访问请确保您有相应的权限
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { adminAuth } from '@/utils/auth'
import { adminAPI } from '@/utils/api'
import { useToast } from 'vue-toastification'
const router = useRouter()
const toast = useToast()
// 表单数据
const form = reactive({
username: '',
password: '',
verificationCode: '',
rememberMe: false
})
// 状态
const loading = ref(false)
const showTwoFactor = ref(false)
const errorMessage = ref('')
// 处理登录
const handleLogin = async () => {
loading.value = true
errorMessage.value = ''
try {
console.log('开始管理员登录:', { username: form.username })
// 调用后端管理员登录 API
const response = await adminAPI.login({
username: form.username,
password: form.password
})
console.log('管理员登录响应:', response)
if (response.success) {
// 登录成功,设置管理员状态
adminAuth.setLogin(response.token, response.admin)
toast.success('管理员登录成功')
// 跳转到管理后台
router.push('/admin')
} else {
errorMessage.value = response.message || '登录失败'
}
} catch (error: any) {
console.error('管理员登录失败:', error)
errorMessage.value = error.response?.data?.message || '登录失败,请稍后重试'
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- 导航栏 -->
<nav class="bg-white dark:bg-gray-800 shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<button @click="$router.back()" class="mr-4">
<svg class="w-6 h-6 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 class="text-2xl font-bold text-primary-600 dark:text-primary-400">
系统监控
</h1>
</div>
</div>
</div>
</nav>
<!-- 主要内容 -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">系统监控</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
查看系统运行状态功能正在开发中...
</p>
</div>
</main>
</div>
</template>
<script setup lang="ts">
// 系统监控页面 - 开发中
</script>

View File

@@ -0,0 +1,388 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- 导航栏 -->
<nav class="bg-white dark:bg-gray-800 shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<button @click="$router.back()" class="mr-4">
<svg class="w-6 h-6 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 class="text-2xl font-bold text-primary-600 dark:text-primary-400">
权限管理
</h1>
</div>
</div>
</div>
</nav>
<!-- 主要内容 -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 页面标题 -->
<div class="mb-8">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">
权限管理
</h2>
<p class="text-gray-600 dark:text-gray-300">
管理用户访问权限和账号配置
</p>
</div>
<!-- 用户账号权限管理 -->
<div class="space-y-6">
<!-- 搜索和筛选 -->
<div class="card">
<div class="p-6">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<input
v-model="searchQuery"
type="text"
placeholder="搜索用户名或邮箱..."
class="input w-full"
@input="handleSearch"
/>
</div>
<div class="flex gap-2">
<select v-model="accountFilter" class="input" @change="handleSearch">
<option value="">所有账号</option>
<option v-for="account in availableAccounts" :key="account.id" :value="account.id">
{{ account.username }}
</option>
</select>
<button @click="handleSearch" class="btn-outline">
搜索
</button>
</div>
</div>
</div>
</div>
<!-- 用户权限列表 -->
<div class="card">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
用户信息
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
账号权限
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="user in filteredUsers" :key="user.id" class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10">
<div class="h-10 w-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
<span class="text-sm font-medium text-primary-600 dark:text-primary-400">
{{ user.username.charAt(0).toUpperCase() }}
</span>
</div>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ user.username }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ user.email }}
</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex space-x-2">
<span
v-for="account in user.accounts || []"
:key="account"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
>
{{ getAccountName(account) }}
</span>
<span v-if="!user.accounts || user.accounts.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
无访问权限
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
@click="editUserAccounts(user)"
class="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
>
编辑权限
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 系统权限设置 -->
<div class="space-y-6 mt-8">
<div class="card">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
系统权限设置
</h3>
</div>
<div class="p-6">
<div class="space-y-6">
<!-- 注册设置 -->
<div>
<h4 class="text-md font-medium text-gray-900 dark:text-white mb-4">注册设置</h4>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">允许用户注册</p>
<p class="text-sm text-gray-500 dark:text-gray-400">是否允许新用户注册账号</p>
</div>
<button
@click="toggleSystemSetting('allowRegistration')"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
:class="systemSettings.allowRegistration ? 'bg-primary-600' : 'bg-gray-200 dark:bg-gray-700'"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="systemSettings.allowRegistration ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
</div>
</div>
<!-- 安全设置 -->
<div>
<h4 class="text-md font-medium text-gray-900 dark:text-white mb-4">安全设置</h4>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">二步验证</p>
<p class="text-sm text-gray-500 dark:text-gray-400">是否启用TOTP二步验证</p>
</div>
<button
@click="toggleSystemSetting('enableTOTP')"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
:class="systemSettings.enableTOTP ? 'bg-primary-600' : 'bg-gray-200 dark:bg-gray-700'"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="systemSettings.enableTOTP ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">会话超时</p>
<p class="text-sm text-gray-500 dark:text-gray-400">用户会话超时时间小时</p>
</div>
<input
v-model="systemSettings.sessionTimeout"
type="number"
min="1"
max="72"
class="input w-20"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- 编辑用户账号权限模态框 -->
<div v-if="showAccountModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white dark:bg-gray-800">
<div class="mt-3">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">
编辑 {{ selectedUser?.username }} 的账号权限
</h3>
<div class="space-y-4">
<div v-for="account in availableAccounts" :key="account.id" class="flex items-center">
<input
:id="`account-${account.id}`"
type="checkbox"
:checked="selectedUserAccounts.includes(account.id)"
@change="toggleUserAccount(account.id)"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label :for="`account-${account.id}`" class="ml-3 text-sm text-gray-900 dark:text-white">
{{ account.username }}
</label>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button @click="showAccountModal = false" class="btn-outline">
取消
</button>
<button @click="saveUserAccounts" class="btn-primary">
保存
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAdminStore } from '@/stores/admin'
import { useToast } from 'vue-toastification'
const adminStore = useAdminStore()
const toast = useToast()
// 搜索和筛选
const searchQuery = ref('')
const accountFilter = ref('')
// 用户列表
const users = ref([])
const filteredUsers = computed(() => {
let filtered = users.value
if (searchQuery.value) {
filtered = filtered.filter((user: any) =>
user.username.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
user.email.toLowerCase().includes(searchQuery.value.toLowerCase())
)
}
if (accountFilter.value) {
filtered = filtered.filter((user: any) =>
user.accounts?.includes(accountFilter.value)
)
}
return filtered
})
// 可用账号
const availableAccounts = ref([])
// 加载可用账号
const loadAvailableAccounts = async () => {
try {
const response = await adminStore.getAccounts()
availableAccounts.value = response.accounts || []
} catch (error) {
console.error('加载可用账号失败:', error)
// 如果加载失败,使用默认数据
availableAccounts.value = [
{ id: 'account1', username: '账号1', description: '第一个登录账号' },
{ id: 'account2', username: '账号2', description: '第二个登录账号' },
{ id: 'account3', username: '账号3', description: '第三个登录账号' }
]
}
}
// 系统设置
const systemSettings = ref({
allowRegistration: true,
enableTOTP: false,
sessionTimeout: 24
})
// 模态框状态
const showAccountModal = ref(false)
const selectedUser = ref(null)
const selectedUserAccounts = ref([])
// 搜索处理
const handleSearch = () => {
// 这里可以添加实际的搜索逻辑
console.log('搜索:', searchQuery.value, '账号筛选:', accountFilter.value)
}
// 根据账户ID获取账户名称
const getAccountName = (accountId: string) => {
const account = availableAccounts.value.find(acc => acc.id === accountId)
return account ? account.username : accountId
}
// 编辑用户账号权限
const editUserAccounts = (user: any) => {
selectedUser.value = { ...user }
selectedUserAccounts.value = [...(user.accounts || [])]
showAccountModal.value = true
}
// 切换用户账号权限
const toggleUserAccount = (accountId: string) => {
const index = selectedUserAccounts.value.indexOf(accountId)
if (index > -1) {
selectedUserAccounts.value.splice(index, 1)
} else {
selectedUserAccounts.value.push(accountId)
}
}
// 保存用户账号权限
const saveUserAccounts = async () => {
try {
// 调用API保存用户账号权限到后端
await adminStore.updateUserAccounts(selectedUser.value.id, selectedUserAccounts.value)
console.log('保存用户账号权限:', selectedUser.value?.id, selectedUserAccounts.value)
// 直接更新本地用户数据
if (selectedUser.value) {
const userIndex = users.value.findIndex((user: any) => user.id === selectedUser.value.id)
console.log('找到用户索引:', userIndex, '用户ID:', selectedUser.value.id)
if (userIndex !== -1) {
// 使用Vue的响应式更新机制
users.value[userIndex] = {
...users.value[userIndex],
accounts: [...selectedUserAccounts.value]
}
console.log('更新后的用户数据:', users.value[userIndex])
}
}
toast.success('权限保存成功')
showAccountModal.value = false
} catch (error) {
console.error('保存权限失败:', error)
toast.error('保存权限失败')
}
}
// 切换系统设置
const toggleSystemSetting = (setting: string) => {
systemSettings.value[setting] = !systemSettings.value[setting]
console.log('切换系统设置:', setting, systemSettings.value[setting])
}
// 加载用户列表
const loadUsers = async () => {
try {
const response = await adminStore.loadUsers()
// 确保用户数据包含accounts字段如果没有则初始化为空数组
users.value = (response.users || []).map((user: any) => ({
...user,
accounts: user.accounts || []
}))
} catch (error) {
console.error('加载用户列表失败:', error)
toast.error('加载用户列表失败')
}
}
// 组件挂载时加载数据
onMounted(async () => {
await loadUsers()
await loadAvailableAccounts()
})
</script>

View File

@@ -0,0 +1,400 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- 导航栏 -->
<nav class="bg-white dark:bg-gray-800 shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<button @click="$router.back()" class="mr-4">
<svg class="w-6 h-6 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 class="text-2xl font-bold text-primary-600 dark:text-primary-400">
用户管理
</h1>
</div>
</div>
</div>
</nav>
<!-- 主要内容 -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 页面标题和操作 -->
<div class="flex justify-between items-center mb-8">
<div>
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">
用户列表
</h2>
<p class="text-gray-600 dark:text-gray-300">
管理系统中的所有用户账号
</p>
</div>
<button @click="showCreateModal = true" class="btn-primary">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
添加用户
</button>
</div>
<!-- 搜索和筛选 -->
<div class="card mb-6">
<div class="p-6">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<input
v-model="searchQuery"
type="text"
placeholder="搜索用户名或邮箱..."
class="input w-full"
@input="handleSearch"
/>
</div>
<div class="flex gap-2">
<select v-model="roleFilter" class="input" @change="handleSearch">
<option value="">所有角色</option>
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
<button @click="handleSearch" class="btn-outline">
搜索
</button>
</div>
</div>
</div>
</div>
<!-- 用户列表 -->
<div class="card">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
用户信息
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
角色
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
状态
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
创建时间
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="user in adminStore.users" :key="user.id" class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10">
<div class="h-10 w-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
<span class="text-sm font-medium text-primary-600 dark:text-primary-400">
{{ user.username.charAt(0).toUpperCase() }}
</span>
</div>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ user.username }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ user.firstName || user.lastName ? `${user.firstName || ''} ${user.lastName || ''}`.trim() : '未设置姓名' }}
</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
:class="user.role === 'admin' ? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' : 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'">
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
:class="user.isActive ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'">
{{ user.isActive ? '激活' : '禁用' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{{ formatDate(user.createdAt) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div class="flex space-x-2">
<button @click="editUser(user)" class="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300">
编辑
</button>
<button @click="deleteUser(user)" class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">
删除
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div v-if="adminStore.pagination.totalPages > 1" class="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-700 dark:text-gray-300">
显示第 {{ (adminStore.pagination.page - 1) * adminStore.pagination.limit + 1 }}
{{ Math.min(adminStore.pagination.page * adminStore.pagination.limit, adminStore.pagination.total) }}
{{ adminStore.pagination.total }} 条记录
</div>
<div class="flex space-x-2">
<button
@click="changePage(adminStore.pagination.page - 1)"
:disabled="adminStore.pagination.page <= 1"
class="btn-outline px-3 py-1"
:class="{ 'opacity-50 cursor-not-allowed': adminStore.pagination.page <= 1 }"
>
上一页
</button>
<button
@click="changePage(adminStore.pagination.page + 1)"
:disabled="adminStore.pagination.page >= adminStore.pagination.totalPages"
class="btn-outline px-3 py-1"
:class="{ 'opacity-50 cursor-not-allowed': adminStore.pagination.page >= adminStore.pagination.totalPages }"
>
下一页
</button>
</div>
</div>
</div>
</div>
</main>
<!-- 创建用户模态框 -->
<div v-if="showCreateModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white dark:bg-gray-800">
<div class="mt-3">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">创建新用户</h3>
<form @submit.prevent="handleCreateUser">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">用户名</label>
<input v-model="createForm.username" type="text" required class="input mt-1 w-full" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">密码</label>
<input v-model="createForm.password" type="password" required class="input mt-1 w-full" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">角色</label>
<select v-model="createForm.role" class="input mt-1 w-full">
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button type="button" @click="showCreateModal = false" class="btn-outline">
取消
</button>
<button type="submit" :disabled="adminStore.loading" class="btn-primary">
{{ adminStore.loading ? '创建中...' : '创建' }}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 编辑用户模态框 -->
<div v-if="showEditModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white dark:bg-gray-800">
<div class="mt-3">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">编辑用户</h3>
<form @submit.prevent="handleEditUser">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">用户名</label>
<input v-model="editForm.username" type="text" required class="input mt-1 w-full" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">角色</label>
<select v-model="editForm.role" class="input mt-1 w-full">
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">状态</label>
<select v-model="editForm.isActive" class="input mt-1 w-full">
<option :value="true">激活</option>
<option :value="false">禁用</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">新密码留空则不修改</label>
<input v-model="editForm.password" type="password" class="input mt-1 w-full" autocomplete="new-password" />
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button type="button" @click="showEditModal = false" class="btn-outline">
取消
</button>
<button type="submit" :disabled="adminStore.loading" class="btn-primary">
{{ adminStore.loading ? '保存中...' : '保存' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useAdminStore } from '@/stores/admin'
import { useToast } from 'vue-toastification'
import type { User } from '@/types'
const adminStore = useAdminStore()
const toast = useToast()
// 搜索和筛选
const searchQuery = ref('')
const roleFilter = ref('')
// 创建用户模态框
const showCreateModal = ref(false)
const createForm = reactive({
username: '',
password: '',
role: 'user'
})
// 编辑用户模态框
const showEditModal = ref(false)
const editForm = reactive({
id: '',
username: '',
role: 'user',
isActive: true,
password: ''
})
// 格式化日期
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('zh-CN')
}
// 搜索处理
const handleSearch = () => {
adminStore.pagination.page = 1 // 重置到第一页
loadUsers()
}
// 加载用户列表
const loadUsers = async () => {
try {
const params: any = {
page: adminStore.pagination.page,
limit: adminStore.pagination.limit
}
if (searchQuery.value) {
params.search = searchQuery.value
}
if (roleFilter.value) {
params.role = roleFilter.value
}
await adminStore.loadUsers(params)
} catch (error) {
console.error('加载用户列表失败:', error)
}
}
// 切换页面
const changePage = async (page: number) => {
adminStore.pagination.page = page
await loadUsers()
}
// 创建用户
const handleCreateUser = async () => {
try {
await adminStore.createUser(createForm)
toast.success('用户创建成功')
showCreateModal.value = false
// 重置表单
createForm.username = ''
createForm.password = ''
createForm.role = 'user'
} catch (error: any) {
toast.error(error.message || '创建用户失败')
}
}
// 编辑用户
const editUser = (user: User) => {
// 填充编辑表单
editForm.id = user.id
editForm.username = user.username
editForm.role = user.role
editForm.isActive = user.isActive
editForm.password = ''
// 显示编辑模态框
showEditModal.value = true
}
// 处理编辑用户
const handleEditUser = async () => {
try {
const updateData: any = {
username: editForm.username,
role: editForm.role,
isActive: editForm.isActive
}
if (editForm.password) {
console.log('正在更新密码:', editForm.password)
updateData.password = editForm.password
}
updateData.loginAttempts = 0
console.log('发送更新数据:', updateData)
await adminStore.updateUser(editForm.id, updateData)
toast.success('用户更新成功')
showEditModal.value = false
// 重置表单
editForm.id = ''
editForm.username = ''
editForm.role = 'user'
editForm.isActive = true
editForm.password = ''
} catch (error: any) {
toast.error(error.message || '更新用户失败')
}
}
// 删除用户
const deleteUser = async (user: User) => {
if (!confirm(`确定要删除用户 ${user.username} 吗?`)) {
return
}
try {
await adminStore.deleteUser(user.id)
toast.success('用户删除成功')
} catch (error: any) {
toast.error(error.message || '删除用户失败')
}
}
// 组件挂载时加载数据
onMounted(() => {
loadUsers()
})
</script>

View File

@@ -0,0 +1,243 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- 导航栏 -->
<nav class="bg-white dark:bg-gray-800 shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<h1 class="text-2xl font-bold text-primary-600 dark:text-primary-400">
AI Route
</h1>
</div>
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ authStore.user?.username }}
</span>
<div class="w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
<span class="text-sm font-medium text-primary-600 dark:text-primary-400">
{{ authStore.user?.username?.charAt(0).toUpperCase() }}
</span>
</div>
</div>
<button @click="handleLogout" class="btn-outline" :disabled="authStore.loading">
{{ authStore.loading ? '退出中...' : '退出' }}
</button>
</div>
</div>
</div>
</nav>
<!-- 主要内容 -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 欢迎信息 -->
<div class="mb-8">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">
欢迎回来{{ authStore.user?.username }}
</h2>
</div>
<!-- 账号列表 -->
<div class="card">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
我的账号
</h3>
</div>
<div class="p-6">
<div v-if="accounts.length === 0" class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">暂无账号</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
您还没有被分配任何网站账号
</p>
</div>
<div v-else class="space-y-4">
<div
v-for="account in accounts"
:key="account.id"
class="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
<!-- Claude 图标 -->
<img v-if="account.website === 'claude'" :src="claudeIcon" alt="Claude" class="w-6 h-6">
<!-- ChatGPT 图标 -->
<img v-else-if="account.website === 'chatgpt'" :src="chatgptIcon" alt="ChatGPT" class="w-6 h-6">
<!-- Grok 图标 -->
<img v-else-if="account.website === 'grok'" :src="grokIcon" alt="Grok" class="w-6 h-6">
<!-- 默认占位符 -->
<div v-else class="w-6 h-6 bg-gray-300 dark:bg-gray-600 rounded"></div>
</div>
</div>
<div class="ml-4">
<h4 class="text-lg font-medium text-gray-900 dark:text-white">
{{ account.username }}
</h4>
</div>
</div>
<div class="flex items-center space-x-2">
<button @click="handleAccessAccount(account)" class="btn-primary">
访问
</button>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { accountAPI } from '@/utils/api'
import { useToast } from 'vue-toastification'
import type { WebsiteAccount } from '@/types'
import claudeIcon from '@/assets/claude.png'
import chatgptIcon from '@/assets/ChatGPT.png'
import grokIcon from '@/assets/grok.png'
import { getWebsiteUrl, getWebsiteConfig } from '@/config/websites'
const router = useRouter()
const authStore = useAuthStore()
const toast = useToast()
// 账号列表
const accounts = ref<WebsiteAccount[]>([])
// 退出登录
const handleLogout = async () => {
try {
await authStore.logout()
router.push('/')
} catch (error) {
console.error('退出登录失败:', error)
// 即使API调用失败也清除本地状态并跳转
authStore.logout()
router.push('/')
}
}
// 获取网站URL
const getLocalWebsiteUrl = (site: string) => {
return getWebsiteUrl(site)
}
// 访问账号
const handleAccessAccount = async (account: WebsiteAccount) => {
try {
// 显示加载状态
toast.info('正在准备登录...')
// 调用网站登录API
const response = await accountAPI.loginToWebsite(account.id, authStore.user?.id || '')
if (response.success && response.loginUrl) {
// 打开登录URL
const jichuurl = getLocalWebsiteUrl(account.website)
window.open(jichuurl + response.loginUrl, '_blank')
const config = getWebsiteConfig(account.website)
toast.success(`正在跳转到 ${config?.name || account.website}...`)
} else {
toast.error('获取登录链接失败')
}
} catch (error: any) {
console.error('访问账号失败:', error)
// 根据不同的错误状态码显示不同的错误信息
if (error.response?.status === 403) {
const errorMessage = error.response?.data?.error || '您没有权限访问该账号'
toast.error(errorMessage)
// 如果是权限问题,建议用户联系管理员
if (errorMessage.includes('权限') || errorMessage.includes('permission')) {
console.log('权限错误详情:', {
userId: authStore.user?.id,
accountId: account.id,
error: error.response?.data
})
}
} else if (error.response?.status === 404) {
toast.error('账号不存在或已被删除')
} else if (error.response?.status === 400) {
toast.error(error.response?.data?.error || '请求参数错误')
} else if (error.response?.status === 500) {
toast.error('服务器内部错误,请稍后重试')
} else if (error.code === 'NETWORK_ERROR') {
toast.error('网络连接失败,请检查网络连接')
} else {
toast.error(error.response?.data?.error || '访问账号失败,请稍后重试')
}
}
}
// 加载用户账号
const loadUserAccounts = async () => {
try {
const response = await accountAPI.getUserAccounts()
accounts.value = response.accounts || []
} catch (error: any) {
console.error('加载用户账号失败:', error)
console.error('错误详情:', error.response?.data)
// 如果是认证错误,不显示错误消息(因为响应拦截器会处理重定向)
if (error.response?.status === 401) {
console.log('认证错误,响应拦截器将处理重定向')
return
}
toast.error('加载账号列表失败')
}
}
// 组件挂载时获取数据
onMounted(async () => {
try {
// 初始化认证状态
authStore.initAuth()
// 确保用户已登录
if (!authStore.isLoggedIn) {
toast.error('请先登录')
router.push('/')
return
}
// 验证token是否有效通过尝试获取用户信息
try {
await authStore.getProfile()
} catch (error: any) {
if (error.response?.status === 401) {
// Token无效清除认证状态并重定向
authStore.logout()
toast.error('登录已过期,请重新登录')
router.push('/')
return
}
}
// 加载用户账号
await loadUserAccounts()
} catch (error) {
console.error('Dashboard初始化失败:', error)
// 如果是认证错误,重定向到登录页面
if (error.response?.status === 401) {
authStore.logout()
toast.error('登录已过期,请重新登录')
router.push('/')
return
}
toast.error('加载数据失败,请刷新页面重试')
}
})
</script>

401
frontend/src/views/Home.vue Normal file
View File

@@ -0,0 +1,401 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 relative overflow-hidden">
<!-- Claude风格背景装饰 -->
<div class="absolute inset-0">
<!-- 动态背景粒子效果 -->
<div class="absolute top-20 left-20 w-2 h-2 bg-blue-400 rounded-full opacity-60 animate-pulse"></div>
<div class="absolute top-40 right-32 w-1 h-1 bg-purple-400 rounded-full opacity-40 animate-ping"></div>
<div class="absolute bottom-32 left-1/4 w-1.5 h-1.5 bg-cyan-400 rounded-full opacity-50 animate-bounce"></div>
<div class="absolute top-1/2 right-1/4 w-1 h-1 bg-green-400 rounded-full opacity-30 animate-pulse"></div>
<div class="absolute bottom-20 right-20 w-2 h-2 bg-yellow-400 rounded-full opacity-40 animate-ping"></div>
<!-- 渐变光晕效果 -->
<div class="absolute top-0 left-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl"></div>
<div class="absolute bottom-0 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl"></div>
</div>
<!-- 主要内容 -->
<div class="relative z-10 flex items-center justify-center min-h-screen px-4">
<div class="w-full max-w-md">
<!-- 标题 -->
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-white mb-2">
AI Route
</h1>
</div>
<!-- 模式切换 -->
<div class="flex justify-center mb-6">
<div class="bg-white/10 backdrop-blur-lg rounded-lg p-1 border border-white/20">
<button
@click="currentMode = 'login'"
:class="[
'px-4 py-2 rounded-md text-sm font-medium transition-all',
currentMode === 'login'
? 'bg-blue-600 text-white shadow-lg'
: 'text-slate-300 hover:text-white'
]"
>
登录
</button>
<button
@click="currentMode = 'register'"
:class="[
'px-4 py-2 rounded-md text-sm font-medium transition-all',
currentMode === 'register'
? 'bg-blue-600 text-white shadow-lg'
: 'text-slate-300 hover:text-white'
]"
>
注册
</button>
</div>
</div>
<!-- 登录表单 -->
<div v-if="currentMode === 'login'" class="bg-white/10 backdrop-blur-lg rounded-2xl p-8 border border-white/20 shadow-2xl">
<form @submit.prevent="handleLogin" class="space-y-6">
<!-- 用户名输入框 -->
<div>
<label for="username" class="block text-sm font-medium text-slate-200 mb-2">
用户名
</label>
<input
id="username"
v-model="loginForm.username"
type="text"
required
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="请输入用户名"
/>
</div>
<!-- 密码输入框 -->
<div>
<label for="password" class="block text-sm font-medium text-slate-200 mb-2">
密码
</label>
<input
id="password"
v-model="loginForm.password"
type="password"
required
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="请输入密码"
/>
</div>
<!-- 记住我 -->
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember-me"
v-model="loginForm.rememberMe"
type="checkbox"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-white/20 rounded bg-white/10"
/>
<label for="remember-me" class="ml-2 block text-sm text-slate-200">
记住我
</label>
</div>
<div class="text-sm">
<button
type="button"
@click="handleForgotPassword"
class="text-slate-300 hover:text-white transition-colors"
>
忘记密码
</button>
</div>
</div>
<!-- 登录按钮 -->
<button
type="submit"
:disabled="authStore.loading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
<span v-if="authStore.loading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
登录中...
</span>
<span v-else>登录</span>
</button>
</form>
</div>
<!-- 注册表单 -->
<div v-if="currentMode === 'register'" class="bg-white/10 backdrop-blur-lg rounded-2xl p-8 border border-white/20 shadow-2xl">
<form @submit.prevent="handleRegister" class="space-y-6">
<!-- 用户名输入框 -->
<div>
<label for="regUsername" class="block text-sm font-medium text-slate-200 mb-2">
用户名
</label>
<input
id="regUsername"
v-model="registerForm.username"
type="text"
required
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="请输入用户名"
/>
</div>
<!-- 密码输入框 -->
<div>
<label for="regPassword" class="block text-sm font-medium text-slate-200 mb-2">
密码
</label>
<input
id="regPassword"
v-model="registerForm.password"
type="password"
required
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="请输入密码"
/>
</div>
<!-- 确认密码输入框 -->
<div>
<label for="confirmPassword" class="block text-sm font-medium text-slate-200 mb-2">
确认密码
</label>
<input
id="confirmPassword"
v-model="registerForm.confirmPassword"
type="password"
required
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="请再次输入密码"
/>
</div>
<!-- 注册按钮 -->
<button
type="submit"
:disabled="authStore.loading"
class="w-full bg-gradient-to-r from-green-600 to-blue-600 hover:from-green-700 hover:to-blue-700 text-white font-semibold py-3 px-6 rounded-lg transition-all duration-200 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
>
<span v-if="authStore.loading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
注册中...
</span>
<span v-else>注册</span>
</button>
</form>
</div>
<!-- 错误提示 -->
<div v-if="authStore.error" class="mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-red-300 text-sm">{{ authStore.error }}</p>
</div>
<!-- 成功提示 -->
<div v-if="successMessage" class="mt-4 p-3 bg-green-500/20 border border-green-500/30 rounded-lg">
<p class="text-green-300 text-sm">{{ successMessage }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification'
const router = useRouter()
const authStore = useAuthStore()
const toast = useToast()
// 当前模式login 或 register
const currentMode = ref<'login' | 'register'>('login')
// 成功消息
const successMessage = ref('')
// 登录表单数据
const loginForm = reactive({
username: '',
password: '',
rememberMe: false
})
// 注册表单数据
const registerForm = reactive({
username: '',
password: '',
confirmPassword: ''
})
// 登录处理函数
const handleLogin = async () => {
if (!loginForm.username || !loginForm.password) {
toast.error('请填写完整的登录信息')
return
}
try {
await authStore.login({
username: loginForm.username,
password: loginForm.password
})
// 如果选择记住我,保存登录信息到本地存储
if (loginForm.rememberMe) {
localStorage.setItem('rememberedUser', JSON.stringify({
username: loginForm.username,
password: loginForm.password
}))
} else {
localStorage.removeItem('rememberedUser')
}
router.push('/dashboard')
} catch (error: any) {
console.log(error)
// 根据不同的错误类型显示不同的错误信息
if (error.response?.status === 401) {
const errorMessage = error.response?.data?.error || '用户名或密码错误'
const remainingAttempts = error.response?.data?.remainingAttempts
if (errorMessage.includes('锁定')) {
toast.error(errorMessage)
} else if (errorMessage.includes('禁用') || errorMessage.includes('inactive')) {
toast.error('您的账户尚未激活,请联系管理员激活账户')
} else {
if (remainingAttempts !== undefined) {
toast.error(`用户名或密码错误,还剩${remainingAttempts}次尝试机会`)
} else {
toast.error('用户名或密码错误,请检查后重试')
}
}
} else if (error.response?.status === 400) {
toast.error(error.response?.data?.message || '请求参数错误')
} else if (error.response?.status === 500) {
toast.error('服务器内部错误,请稍后重试')
} else if (error.code === 'NETWORK_ERROR') {
toast.error('网络连接失败,请检查网络连接')
} else {
toast.error(error.response?.data?.message || '登录失败,请稍后重试')
}
}
}
// 注册处理函数
const handleRegister = async () => {
if (!registerForm.username || !registerForm.password || !registerForm.confirmPassword) {
toast.error('请填写完整的注册信息')
return
}
// 验证用户名格式
if (!/^[a-zA-Z0-9_]+$/.test(registerForm.username)) {
toast.error('用户名只能包含字母、数字和下划线')
return
}
// 验证用户名长度
if (registerForm.username.length < 3 || registerForm.username.length > 30) {
toast.error('用户名长度必须在3-30个字符之间')
return
}
// 验证密码长度
if (registerForm.password.length < 8) {
toast.error('密码长度至少8个字符')
return
}
if (registerForm.password !== registerForm.confirmPassword) {
toast.error('两次输入的密码不一致')
return
}
try {
await authStore.register({
username: registerForm.username,
password: registerForm.password,
confirmPassword: registerForm.confirmPassword
})
successMessage.value = '注册成功!您的账户已创建,请等待管理员激活后即可登录。'
toast.success('注册成功!请等待管理员激活您的账户。')
// 注册成功时清空注册表单
registerForm.username = ''
registerForm.password = ''
registerForm.confirmPassword = ''
// 切换到登录模式
currentMode.value = 'login'
} catch (error: any) {
console.error('注册错误详情:', error)
console.error('错误响应:', error.response.data.error)
// 根据不同的错误类型显示不同的错误信息
if (error.response?.status === 400) {
const details = error.response?.data?.error
if (details) {
// 显示具体的验证错误信息
const errorMessages = details
toast.error(`注册失败: ${errorMessages}`)
}
} else if (error.response?.status === 409) {
toast.error('用户名已存在,请使用其他信息')
} else if (error.response?.status === 500) {
toast.error('服务器内部错误,请稍后重试')
} else if (error.code === 'NETWORK_ERROR') {
toast.error('网络连接失败,请检查网络连接')
} else {
toast.error(error.response?.data?.message || '注册失败,请稍后重试')
}
}
}
// 忘记密码处理函数
const handleForgotPassword = () => {
toast.info('请联系管理员')
}
// 页面加载时检查是否有记住的登录信息
const loadRememberedUser = () => {
const remembered = localStorage.getItem('rememberedUser')
if (remembered) {
try {
const userData = JSON.parse(remembered)
loginForm.username = userData.username || ''
loginForm.rememberMe = true
} catch (error) {
console.error('解析记住的用户信息失败:', error)
localStorage.removeItem('rememberedUser')
}
}
}
// 页面挂载时初始化
onMounted(() => {
loadRememberedUser()
})
</script>
<style scoped>
/* 自定义动画 */
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full text-center">
<div class="mb-8">
<h1 class="text-9xl font-bold text-primary-600 dark:text-primary-400">404</h1>
</div>
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">
页面未找到
</h2>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-8">
抱歉您访问的页面不存在或已被移除
</p>
<div class="space-y-4">
<router-link
to="/"
class="btn-primary w-full"
>
返回首页
</router-link>
<button
@click="goBack"
class="btn-outline w-full"
>
返回上页
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const goBack = () => {
router.go(-1)
}
</script>

212
frontend/src/views/Test.vue Normal file
View File

@@ -0,0 +1,212 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
<div class="max-w-4xl mx-auto">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-8">
API 集成测试
</h1>
<!-- 认证状态 -->
<div class="card mb-8">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
认证状态
</h2>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">登录状态</p>
<p class="text-lg font-medium text-gray-900 dark:text-white">
{{ authStore.isLoggedIn ? '已登录' : '未登录' }}
</p>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">用户信息</p>
<p class="text-lg font-medium text-gray-900 dark:text-white">
{{ authStore.user?.username || '无' }}
</p>
</div>
</div>
</div>
</div>
<!-- API 测试 -->
<div class="card mb-8">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
API 测试
</h2>
</div>
<div class="p-6 space-y-4">
<!-- 获取路径 -->
<div class="flex items-center space-x-4">
<button
@click="testGetPaths"
:disabled="loading"
class="btn-primary"
>
{{ loading ? '测试中...' : '测试获取路径' }}
</button>
<span class="text-sm text-gray-600 dark:text-gray-400">
结果: {{ pathsResult }}
</span>
</div>
<!-- 用户注册 -->
<div class="flex items-center space-x-4">
<button
@click="testRegister"
:disabled="loading"
class="btn-primary"
>
{{ loading ? '测试中...' : '测试用户注册' }}
</button>
<span class="text-sm text-gray-600 dark:text-gray-400">
结果: {{ registerResult }}
</span>
</div>
<!-- 用户登录 -->
<div class="flex items-center space-x-4">
<button
@click="testLogin"
:disabled="loading"
class="btn-primary"
>
{{ loading ? '测试中...' : '测试用户登录' }}
</button>
<span class="text-sm text-gray-600 dark:text-gray-400">
结果: {{ loginResult }}
</span>
</div>
<!-- 获取用户信息 -->
<div class="flex items-center space-x-4">
<button
@click="testGetProfile"
:disabled="loading"
class="btn-primary"
>
{{ loading ? '测试中...' : '测试获取用户信息' }}
</button>
<span class="text-sm text-gray-600 dark:text-gray-400">
结果: {{ profileResult }}
</span>
</div>
</div>
</div>
<!-- 响应数据 -->
<div class="card">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
响应数据
</h2>
</div>
<div class="p-6">
<pre class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg text-sm overflow-auto max-h-96">{{ responseData }}</pre>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { authAPI, pathAPI } from '@/utils/api'
import { useToast } from 'vue-toastification'
const authStore = useAuthStore()
const toast = useToast()
const loading = ref(false)
const pathsResult = ref('未测试')
const registerResult = ref('未测试')
const loginResult = ref('未测试')
const profileResult = ref('未测试')
const responseData = ref('')
// 测试获取路径
const testGetPaths = async () => {
loading.value = true
pathsResult.value = '测试中...'
try {
const response = await pathAPI.getPaths()
pathsResult.value = '成功'
responseData.value = JSON.stringify(response, null, 2)
toast.success('获取路径成功')
} catch (error: any) {
pathsResult.value = '失败'
responseData.value = JSON.stringify(error.response?.data || error.message, null, 2)
toast.error('获取路径失败')
} finally {
loading.value = false
}
}
// 测试用户注册
const testRegister = async () => {
loading.value = true
registerResult.value = '测试中...'
try {
const response = await authAPI.register({
username: 'testuser',
password: 'password123',
confirmPassword: 'password123'
})
registerResult.value = '成功'
responseData.value = JSON.stringify(response, null, 2)
toast.success('注册测试成功')
} catch (error: any) {
registerResult.value = '失败'
responseData.value = JSON.stringify(error.response?.data || error.message, null, 2)
toast.error('注册测试失败')
} finally {
loading.value = false
}
}
// 测试用户登录
const testLogin = async () => {
loading.value = true
loginResult.value = '测试中...'
try {
const response = await authAPI.login({
username: 'testuser',
password: 'password123'
})
loginResult.value = '成功'
responseData.value = JSON.stringify(response, null, 2)
toast.success('登录测试成功')
} catch (error: any) {
loginResult.value = '失败'
responseData.value = JSON.stringify(error.response?.data || error.message, null, 2)
toast.error('登录测试失败')
} finally {
loading.value = false
}
}
// 测试获取用户信息
const testGetProfile = async () => {
loading.value = true
profileResult.value = '测试中...'
try {
const response = await authAPI.getProfile()
profileResult.value = '成功'
responseData.value = JSON.stringify(response, null, 2)
toast.success('获取用户信息成功')
} catch (error: any) {
profileResult.value = '失败'
responseData.value = JSON.stringify(error.response?.data || error.message, null, 2)
toast.error('获取用户信息失败')
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,65 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
950: '#030712',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'bounce-in': 'bounceIn 0.6s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
bounceIn: {
'0%': { transform: 'scale(0.3)', opacity: '0' },
'50%': { transform: 'scale(1.05)' },
'70%': { transform: 'scale(0.9)' },
'100%': { transform: 'scale(1)', opacity: '1' },
},
},
},
},
plugins: [],
darkMode: 'class',
}

36
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

44
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,44 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
define: {
// 定义环境变量,使其在客户端可用
VITE_API_URL: JSON.stringify(process.env.VITE_API_URL || 'http://localhost:3001'),
VITE_APP_NAME: JSON.stringify(process.env.VITE_APP_NAME || 'Pandora'),
VITE_CLAUDE_TARGET_URL: JSON.stringify(process.env.CLAUDE_TARGET_URL || 'https://claude.ai'),
VITE_CHATGPT_TARGET_URL: JSON.stringify(process.env.CHATGPT_TARGET_URL || 'https://chat.openai.com'),
VITE_GROK_TARGET_URL: JSON.stringify(process.env.GROK_TARGET_URL || 'https://grok.x.ai'),
},
server: {
port: 3000,
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
secure: false,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
ui: ['@headlessui/vue', '@heroicons/vue'],
},
},
},
},
})