first commit
This commit is contained in:
139
.gitignore
vendored
Normal file
139
.gitignore
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Production builds
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids/
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
|
||||
# Database
|
||||
*.sqlite
|
||||
*.db
|
||||
|
||||
# Uploads
|
||||
uploads/
|
||||
public/uploads/
|
||||
|
||||
# SSL certificates
|
||||
ssl/
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
|
||||
# Backup files
|
||||
backups/
|
||||
*.backup
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Prisma
|
||||
prisma/migrations/
|
||||
!prisma/migrations/.gitkeep
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Monitoring
|
||||
monitoring/data/
|
||||
288
ARCHITECTURE.md
Normal file
288
ARCHITECTURE.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# Pandora 系统架构设计
|
||||
|
||||
## 整体架构
|
||||
|
||||
Pandora 采用现代化的微服务架构,前后端分离,支持容器化部署。
|
||||
|
||||
### 技术栈
|
||||
|
||||
**前端**: Vue 3 + TypeScript + Tailwind CSS + Vite
|
||||
**后端**: Node.js + Express.js + TypeScript + Prisma
|
||||
**数据库**: PostgreSQL + Redis
|
||||
**部署**: Docker + Docker Compose + Nginx
|
||||
|
||||
### 核心功能模块
|
||||
|
||||
1. **用户认证系统** - 多路径登录、二步验证
|
||||
2. **权限管理系统** - 基于角色的访问控制
|
||||
3. **账号管理系统** - 网站账号token存储和分配
|
||||
4. **后台管理系统** - 用户和权限管理界面
|
||||
|
||||
### 安全特性
|
||||
|
||||
- JWT身份验证
|
||||
- 密码加密存储
|
||||
- 二步验证(TOTP/邮箱)
|
||||
- API限流保护
|
||||
- 审计日志记录
|
||||
|
||||
### 部署架构
|
||||
|
||||
- 开发环境: Docker Compose
|
||||
- 生产环境: 支持负载均衡和监控
|
||||
- 数据库: PostgreSQL主从复制
|
||||
- 缓存: Redis集群
|
||||
|
||||
## 数据库设计
|
||||
|
||||
### 核心表结构
|
||||
|
||||
- **users**: 用户信息
|
||||
- **paths**: 登录路径
|
||||
- **user_paths**: 用户路径权限
|
||||
- **website_accounts**: 网站账号
|
||||
- **account_assignments**: 账号分配
|
||||
- **sessions**: 用户会话
|
||||
- **audit_logs**: 审计日志
|
||||
|
||||
## API设计
|
||||
|
||||
RESTful API设计,支持用户管理、权限管理、账号管理等核心功能。
|
||||
|
||||
## 监控和日志
|
||||
|
||||
- 健康检查端点
|
||||
- 性能监控
|
||||
- 错误追踪
|
||||
- 访问日志
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 用户层 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Domain A (path1.com) │ Domain B (path2.com) │ Domain C │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────┐
|
||||
│ Nginx Proxy │
|
||||
│ (Load Bal.) │
|
||||
└─────────────────┘
|
||||
│
|
||||
┌─────────────────┐
|
||||
│ Frontend │
|
||||
│ (React SPA) │
|
||||
└─────────────────┘
|
||||
│
|
||||
┌─────────────────┐
|
||||
│ Backend API │
|
||||
│ (Express.js) │
|
||||
└─────────────────┘
|
||||
│
|
||||
┌────────────────────┼────────────────────┐
|
||||
│ │ │
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ PostgreSQL │ │ Redis │ │ File Storage │
|
||||
│ (Main DB) │ │ (Cache/Session)│ │ (Uploads) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## 技术栈选择
|
||||
|
||||
### 前端技术栈
|
||||
- **React 18**: 现代化的前端框架,支持并发特性
|
||||
- **TypeScript**: 类型安全,提高开发效率和代码质量
|
||||
- **Vite**: 快速的构建工具,支持热重载
|
||||
- **Tailwind CSS**: 实用优先的CSS框架,快速构建UI
|
||||
- **React Router**: 客户端路由管理
|
||||
- **React Query**: 数据获取和缓存管理
|
||||
- **Zustand**: 轻量级状态管理
|
||||
- **React Hook Form**: 表单处理
|
||||
- **React Hot Toast**: 通知组件
|
||||
|
||||
### 后端技术栈
|
||||
- **Node.js**: JavaScript运行时
|
||||
- **Express.js**: 轻量级Web框架
|
||||
- **TypeScript**: 类型安全
|
||||
- **Prisma**: 现代化数据库ORM
|
||||
- **PostgreSQL**: 关系型数据库
|
||||
- **Redis**: 缓存和会话存储
|
||||
- **JWT**: 身份验证
|
||||
- **bcrypt**: 密码加密
|
||||
- **nodemailer**: 邮件发送
|
||||
- **speakeasy**: TOTP生成
|
||||
|
||||
### 基础设施
|
||||
- **Docker**: 容器化部署
|
||||
- **Docker Compose**: 多服务编排
|
||||
- **Nginx**: 反向代理和负载均衡
|
||||
- **Let's Encrypt**: SSL证书
|
||||
|
||||
## 安全架构
|
||||
|
||||
### 认证机制
|
||||
1. **多因素认证**: 支持邮箱验证码和TOTP
|
||||
2. **JWT Token**: 无状态的身份验证
|
||||
3. **会话管理**: Redis存储会话信息
|
||||
4. **密码安全**: bcrypt加密,支持盐值
|
||||
|
||||
### 权限控制
|
||||
1. **基于角色的访问控制(RBAC)**: 用户、管理员、超级管理员
|
||||
2. **路径权限**: 用户只能访问被授权的路径
|
||||
3. **账号权限**: 用户只能访问被分配的账号
|
||||
4. **API权限**: 基于角色的API访问控制
|
||||
|
||||
### 数据安全
|
||||
1. **敏感数据加密**: 使用AES加密存储token
|
||||
2. **SQL注入防护**: 使用Prisma ORM
|
||||
3. **XSS防护**: 输入验证和输出转义
|
||||
4. **CSRF防护**: Token验证
|
||||
5. **限流保护**: API访问频率限制
|
||||
|
||||
## API设计
|
||||
|
||||
### RESTful API
|
||||
```
|
||||
认证相关:
|
||||
POST /api/auth/register - 用户注册
|
||||
POST /api/auth/login - 用户登录
|
||||
POST /api/auth/logout - 用户登出
|
||||
POST /api/auth/verify-email - 邮箱验证
|
||||
POST /api/auth/verify-totp - TOTP验证
|
||||
|
||||
用户管理:
|
||||
GET /api/users - 获取用户列表
|
||||
GET /api/users/:id - 获取用户详情
|
||||
PUT /api/users/:id - 更新用户信息
|
||||
DELETE /api/users/:id - 删除用户
|
||||
|
||||
路径管理:
|
||||
GET /api/paths - 获取路径列表
|
||||
GET /api/paths/:id - 获取路径详情
|
||||
POST /api/paths - 创建路径
|
||||
PUT /api/paths/:id - 更新路径
|
||||
DELETE /api/paths/:id - 删除路径
|
||||
|
||||
账号管理:
|
||||
GET /api/accounts - 获取账号列表
|
||||
GET /api/accounts/:id - 获取账号详情
|
||||
POST /api/accounts - 创建账号
|
||||
PUT /api/accounts/:id - 更新账号
|
||||
DELETE /api/accounts/:id - 删除账号
|
||||
|
||||
权限管理:
|
||||
GET /api/permissions - 获取权限列表
|
||||
POST /api/permissions - 分配权限
|
||||
DELETE /api/permissions/:id - 撤销权限
|
||||
```
|
||||
|
||||
### GraphQL API (可选)
|
||||
```graphql
|
||||
type Query {
|
||||
users: [User!]!
|
||||
user(id: ID!): User
|
||||
paths: [Path!]!
|
||||
accounts: [Account!]!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createUser(input: CreateUserInput!): User!
|
||||
updateUser(id: ID!, input: UpdateUserInput!): User!
|
||||
assignPermission(input: AssignPermissionInput!): Permission!
|
||||
}
|
||||
```
|
||||
|
||||
## 部署架构
|
||||
|
||||
### 开发环境
|
||||
```yaml
|
||||
services:
|
||||
postgres: PostgreSQL 数据库
|
||||
redis: Redis 缓存
|
||||
backend: Express.js API 服务
|
||||
frontend: React 开发服务器
|
||||
nginx: 反向代理
|
||||
```
|
||||
|
||||
### 生产环境
|
||||
```yaml
|
||||
services:
|
||||
postgres: PostgreSQL 数据库 (主从复制)
|
||||
redis: Redis 集群
|
||||
backend: Express.js API 服务 (负载均衡)
|
||||
frontend: Nginx 静态文件服务
|
||||
nginx: 反向代理和SSL终止
|
||||
prometheus: 监控服务
|
||||
grafana: 监控面板
|
||||
```
|
||||
|
||||
## 监控和日志
|
||||
|
||||
### 应用监控
|
||||
- **健康检查**: 每个服务都有健康检查端点
|
||||
- **性能监控**: 使用Prometheus收集指标
|
||||
- **错误追踪**: 集成错误监控服务
|
||||
- **日志聚合**: 集中化日志管理
|
||||
|
||||
### 安全监控
|
||||
- **访问日志**: 记录所有API访问
|
||||
- **审计日志**: 记录敏感操作
|
||||
- **异常检测**: 检测异常访问模式
|
||||
- **实时告警**: 安全事件实时通知
|
||||
|
||||
## 扩展性设计
|
||||
|
||||
### 水平扩展
|
||||
- **无状态设计**: 后端服务无状态,支持水平扩展
|
||||
- **数据库分片**: 支持数据库水平分片
|
||||
- **缓存分层**: 多级缓存策略
|
||||
- **CDN集成**: 静态资源CDN加速
|
||||
|
||||
### 功能扩展
|
||||
- **插件系统**: 支持功能插件扩展
|
||||
- **API版本控制**: 支持API版本管理
|
||||
- **多租户**: 支持多租户架构
|
||||
- **国际化**: 支持多语言
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 前端优化
|
||||
- **代码分割**: 按路由分割代码
|
||||
- **懒加载**: 组件和图片懒加载
|
||||
- **缓存策略**: 静态资源缓存
|
||||
- **预加载**: 关键资源预加载
|
||||
|
||||
### 后端优化
|
||||
- **数据库索引**: 优化查询性能
|
||||
- **连接池**: 数据库连接池管理
|
||||
- **缓存策略**: Redis缓存热点数据
|
||||
- **异步处理**: 非关键操作异步处理
|
||||
|
||||
## 故障恢复
|
||||
|
||||
### 高可用性
|
||||
- **服务冗余**: 关键服务多实例部署
|
||||
- **数据库备份**: 定期数据库备份
|
||||
- **故障转移**: 自动故障转移机制
|
||||
- **灾难恢复**: 完整的灾难恢复计划
|
||||
|
||||
### 数据保护
|
||||
- **数据备份**: 定期数据备份
|
||||
- **版本控制**: 数据版本管理
|
||||
- **加密存储**: 敏感数据加密
|
||||
- **访问控制**: 严格的数据访问控制
|
||||
|
||||
## 开发流程
|
||||
|
||||
### 代码管理
|
||||
- **Git工作流**: 基于Git的版本控制
|
||||
- **代码审查**: 强制代码审查
|
||||
- **自动化测试**: 单元测试和集成测试
|
||||
- **CI/CD**: 持续集成和部署
|
||||
|
||||
### 质量保证
|
||||
- **代码规范**: ESLint和Prettier
|
||||
- **类型检查**: TypeScript严格模式
|
||||
- **测试覆盖**: 高测试覆盖率
|
||||
- **安全扫描**: 定期安全扫描
|
||||
|
||||
这个架构设计确保了系统的安全性、可扩展性和可维护性,为Pandora网站账号共享系统提供了坚实的基础。
|
||||
152
INTEGRATION_README.md
Normal file
152
INTEGRATION_README.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Pandora 前后端集成说明
|
||||
|
||||
## 🚀 快速启动
|
||||
|
||||
### 1. 启动后端服务
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
后端服务将在 `http://localhost:3001` 启动
|
||||
|
||||
### 2. 启动前端服务
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端服务将在 `http://localhost:3000` 启动
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
### 后端配置
|
||||
|
||||
后端使用 SQLite 数据库,无需额外配置。主要配置项在 `backend/.env` 文件中:
|
||||
|
||||
- `DATABASE_URL`: SQLite 数据库路径
|
||||
- `JWT_SECRET`: JWT 密钥
|
||||
- `PORT`: 服务端口 (默认 3001)
|
||||
|
||||
### 前端配置
|
||||
|
||||
前端 API 基础 URL 配置在 `frontend/src/utils/api.ts` 中:
|
||||
|
||||
```typescript
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001/api'
|
||||
```
|
||||
|
||||
## 🧪 测试集成
|
||||
|
||||
### 1. API 测试页面
|
||||
|
||||
访问 `http://localhost:3000/test` 可以测试各种 API 功能:
|
||||
|
||||
- 获取路径列表
|
||||
- 用户注册
|
||||
- 用户登录
|
||||
- 获取用户信息
|
||||
|
||||
### 2. 功能测试
|
||||
|
||||
1. **用户注册**: 访问首页,切换到注册模式,填写信息并注册
|
||||
2. **用户登录**: 使用注册的邮箱和密码登录
|
||||
3. **路径权限**: 登录后查看仪表板中的路径权限
|
||||
4. **账号管理**: 查看分配的网站账号
|
||||
|
||||
## 📋 已实现的功能
|
||||
|
||||
### 后端 API
|
||||
|
||||
- ✅ 用户注册 (`POST /api/auth/register`)
|
||||
- ✅ 用户登录 (`POST /api/auth/login`)
|
||||
- ✅ 邮箱验证 (`POST /api/auth/verify-email`)
|
||||
- ✅ 密码重置 (`POST /api/auth/forgot-password`, `POST /api/auth/reset-password`)
|
||||
- ✅ TOTP 二步验证 (`POST /api/auth/verify-totp`)
|
||||
- ✅ 用户信息管理 (`GET /api/auth/profile`, `PUT /api/auth/profile`)
|
||||
- ✅ 路径权限管理 (`GET /api/paths`, `GET /api/paths/user`)
|
||||
- ✅ 账号管理 (`GET /api/accounts/user`)
|
||||
- ✅ 管理员功能 (`/api/admin/*`)
|
||||
|
||||
### 前端功能
|
||||
|
||||
- ✅ 用户认证状态管理 (Pinia Store)
|
||||
- ✅ API 服务层 (Axios)
|
||||
- ✅ 路由守卫
|
||||
- ✅ 登录/注册界面
|
||||
- ✅ 用户仪表板
|
||||
- ✅ 路径权限显示
|
||||
- ✅ 账号列表显示
|
||||
- ✅ Toast 通知
|
||||
- ✅ 响应式设计
|
||||
|
||||
## 🔐 认证流程
|
||||
|
||||
1. **注册**: 用户填写信息 → 后端创建用户 → 发送验证邮件
|
||||
2. **登录**: 用户输入邮箱密码 → 后端验证 → 返回 JWT Token
|
||||
3. **权限验证**: 前端请求携带 Token → 后端验证权限 → 返回数据
|
||||
4. **路径选择**: 用户选择登录路径 → 验证路径权限 → 进入系统
|
||||
|
||||
## 🛠️ 开发工具
|
||||
|
||||
### 后端开发
|
||||
|
||||
```bash
|
||||
# 数据库迁移
|
||||
npm run db:migrate
|
||||
|
||||
# 生成 Prisma 客户端
|
||||
npm run db:generate
|
||||
|
||||
# 查看数据库
|
||||
npm run db:studio
|
||||
```
|
||||
|
||||
### 前端开发
|
||||
|
||||
```bash
|
||||
# 代码检查
|
||||
npm run lint
|
||||
|
||||
# 类型检查
|
||||
npm run type-check
|
||||
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 🐛 常见问题
|
||||
|
||||
### 1. CORS 错误
|
||||
|
||||
确保后端 CORS 配置正确,允许前端域名访问。
|
||||
|
||||
### 2. 数据库连接错误
|
||||
|
||||
检查 SQLite 数据库文件是否存在,确保有写入权限。
|
||||
|
||||
### 3. API 请求失败
|
||||
|
||||
检查后端服务是否正常运行,API 地址是否正确。
|
||||
|
||||
### 4. 认证失败
|
||||
|
||||
检查 JWT Token 是否正确传递,Token 是否过期。
|
||||
|
||||
## 📝 下一步开发
|
||||
|
||||
1. **完善管理后台**: 实现用户管理、权限管理、账号管理界面
|
||||
2. **添加更多安全功能**: API 限流、IP 白名单、审计日志
|
||||
3. **优化用户体验**: 添加加载状态、错误处理、表单验证
|
||||
4. **性能优化**: 数据库查询优化、前端代码分割、缓存策略
|
||||
5. **测试覆盖**: 单元测试、集成测试、端到端测试
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [后端 API 文档](./backend/README.md)
|
||||
- [前端开发文档](./frontend/README.md)
|
||||
- [数据库设计文档](./TODO.md)
|
||||
262
README.md
Normal file
262
README.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# Pandora - 网站账号共享系统
|
||||
|
||||
## 项目简介
|
||||
|
||||
Pandora 是一个现代化的网站账号共享管理系统,允许用户通过不同的域名路径访问共享的网站账号。系统提供了完整的用户管理、权限控制和账号token管理功能。
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 🏠 用户界面
|
||||
- **Claude风格首页**: 现代化的AI助手风格界面设计
|
||||
- **多路径登录**: 支持三个不同的登录路径,每个路径对应独立域名
|
||||
- **响应式设计**: 适配各种设备尺寸
|
||||
|
||||
### 🔐 认证系统
|
||||
- **多路径登录**: 用户可选择不同的登录路径
|
||||
- **二步验证**: 支持邮箱验证码和TOTP验证
|
||||
- **权限控制**: 基于角色的访问控制
|
||||
|
||||
### 👥 用户管理
|
||||
- **用户注册/登录**: 完整的用户认证流程
|
||||
- **权限分配**: 管理员可控制用户访问权限
|
||||
- **路径权限**: 用户只能看到有权限的登录路径
|
||||
|
||||
### 🔑 账号管理
|
||||
- **Token存储**: 安全存储不同网站的账号token
|
||||
- **账号分配**: 将网站账号分配给指定用户
|
||||
- **访问控制**: 未分配的账号用户无法访问
|
||||
|
||||
### 🛠️ 后台管理
|
||||
- **用户管理**: 查看、编辑、删除用户
|
||||
- **权限管理**: 分配和撤销用户权限
|
||||
- **账号管理**: 管理网站账号和token
|
||||
- **系统监控**: 访问日志和系统状态
|
||||
|
||||
## 技术架构
|
||||
|
||||
### 前端技术栈
|
||||
- **Vue 3**: 现代化的前端框架
|
||||
- **TypeScript**: 类型安全的JavaScript
|
||||
- **Tailwind CSS**: 实用优先的CSS框架
|
||||
- **Vue Router**: 客户端路由
|
||||
- **Pinia**: 状态管理
|
||||
- **VueUse**: 组合式工具库
|
||||
- **VeeValidate**: 表单验证
|
||||
- **Vue Toastification**: 通知组件
|
||||
|
||||
### 后端技术栈
|
||||
- **Node.js**: JavaScript运行时
|
||||
- **Express.js**: Web应用框架
|
||||
- **TypeScript**: 类型安全
|
||||
- **Prisma**: 数据库ORM
|
||||
- **PostgreSQL**: 主数据库
|
||||
- **Redis**: 缓存和会话存储
|
||||
- **JWT**: 身份验证
|
||||
- **bcrypt**: 密码加密
|
||||
- **nodemailer**: 邮件发送
|
||||
- **speakeasy**: TOTP生成
|
||||
|
||||
### 基础设施
|
||||
- **Docker**: 容器化部署
|
||||
- **Docker Compose**: 多服务编排
|
||||
- **Nginx**: 反向代理和负载均衡
|
||||
- **Let's Encrypt**: SSL证书
|
||||
|
||||
## 系统架构
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Domain A │ │ Domain B │ │ Domain C │
|
||||
│ (path1.com) │ │ (path2.com) │ │ (path3.com) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
└───────────────────────┼───────────────────────┘
|
||||
│
|
||||
┌─────────────────┐
|
||||
│ Nginx Proxy │
|
||||
│ (Load Bal.) │
|
||||
└─────────────────┘
|
||||
│
|
||||
┌─────────────────┐
|
||||
│ Frontend │
|
||||
│ (Vue App) │
|
||||
└─────────────────┘
|
||||
│
|
||||
┌─────────────────┐
|
||||
│ Backend API │
|
||||
│ (Express.js) │
|
||||
└─────────────────┘
|
||||
│
|
||||
┌───────────────────────┼───────────────────────┐
|
||||
│ │ │
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ PostgreSQL │ │ Redis │ │ File Storage │
|
||||
│ (Main DB) │ │ (Cache/Session)│ │ (Uploads) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## 数据库设计
|
||||
|
||||
### 核心表结构
|
||||
- **users**: 用户信息表
|
||||
- **user_paths**: 用户路径权限表
|
||||
- **website_accounts**: 网站账号表
|
||||
- **account_assignments**: 账号分配表
|
||||
- **sessions**: 用户会话表
|
||||
- **audit_logs**: 审计日志表
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
- Docker 20.10+
|
||||
- Docker Compose 2.0+
|
||||
- 至少 2GB 可用内存
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. **克隆项目**
|
||||
```bash
|
||||
git clone https://github.com/your-username/pandora.git
|
||||
cd pandora
|
||||
```
|
||||
|
||||
2. **配置环境变量**
|
||||
```bash
|
||||
cp env.example .env
|
||||
# 编辑 .env 文件,配置必要的环境变量
|
||||
```
|
||||
|
||||
3. **启动服务**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. **初始化数据库**
|
||||
```bash
|
||||
docker-compose exec backend npm run db:migrate
|
||||
docker-compose exec backend npm run db:seed
|
||||
```
|
||||
|
||||
5. **访问应用**
|
||||
- 前端: http://localhost:3000
|
||||
- 管理后台: http://localhost:3000/admin
|
||||
- API文档: http://localhost:3000/api/docs
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 本地开发
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
|
||||
# 运行测试
|
||||
npm test
|
||||
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 代码规范
|
||||
- 使用 ESLint 和 Prettier 进行代码格式化
|
||||
- 遵循 TypeScript 严格模式
|
||||
- 使用 Conventional Commits 规范
|
||||
|
||||
## 部署
|
||||
|
||||
### 生产环境部署
|
||||
```bash
|
||||
# 构建生产镜像
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
|
||||
# 启动生产服务
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 环境变量配置
|
||||
生产环境需要配置以下环境变量:
|
||||
- `DATABASE_URL`: PostgreSQL连接字符串
|
||||
- `REDIS_URL`: Redis连接字符串
|
||||
- `JWT_SECRET`: JWT密钥
|
||||
- `SMTP_CONFIG`: 邮件服务器配置
|
||||
- `DOMAIN_CONFIG`: 域名配置
|
||||
|
||||
## 安全特性
|
||||
|
||||
- **密码加密**: 使用bcrypt进行密码哈希
|
||||
- **JWT认证**: 安全的token认证机制
|
||||
- **二步验证**: 支持邮箱和TOTP验证
|
||||
- **权限控制**: 基于角色的访问控制
|
||||
- **审计日志**: 完整的操作日志记录
|
||||
- **HTTPS**: 强制HTTPS连接
|
||||
- **CORS**: 跨域资源共享控制
|
||||
|
||||
## 贡献指南
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 打开 Pull Request
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
|
||||
|
||||
## 支持
|
||||
|
||||
如果您遇到问题或有建议,请:
|
||||
1. 查看 [FAQ](docs/FAQ.md)
|
||||
2. 搜索 [Issues](https://github.com/your-username/pandora/issues)
|
||||
3. 创建新的 Issue
|
||||
|
||||
## 更新日志
|
||||
|
||||
查看 [CHANGELOG.md](CHANGELOG.md) 了解版本更新历史。
|
||||
|
||||
## 用户激活功能
|
||||
|
||||
### 功能说明
|
||||
- 新注册的用户默认为禁用状态(`isActive: false`)
|
||||
- 用户需要等待管理员激活账户后才能登录
|
||||
- 管理员可以在用户管理页面激活/禁用用户账户
|
||||
|
||||
### 修改内容
|
||||
|
||||
#### 后端修改
|
||||
1. **注册控制器** (`backend/src/controllers/authController.ts`)
|
||||
- 新注册用户默认为禁用状态
|
||||
- 返回明确的激活提示信息
|
||||
|
||||
2. **登录控制器** (`backend/src/controllers/authController.ts`)
|
||||
- 检查用户是否激活
|
||||
- 被禁用用户登录时返回明确错误信息
|
||||
|
||||
3. **用户管理控制器** (`backend/src/controllers/userController.ts`)
|
||||
- 管理员创建用户时默认为禁用状态
|
||||
- 支持管理员激活/禁用用户
|
||||
|
||||
#### 前端修改
|
||||
1. **登录页面** (`frontend/src/views/Home.vue`)
|
||||
- 注册成功后显示激活提示
|
||||
- 登录失败时区分账户禁用和其他错误
|
||||
|
||||
2. **管理员用户管理** (`frontend/src/views/AdminUsers.vue`)
|
||||
- 显示用户激活状态
|
||||
- 支持管理员编辑用户激活状态
|
||||
|
||||
3. **API调用** (`frontend/src/utils/api.ts`)
|
||||
- 修复登录API使用username字段
|
||||
- 移除不需要的email字段
|
||||
|
||||
4. **类型定义** (`frontend/src/types/index.ts`)
|
||||
- 更新用户类型定义
|
||||
- 移除email相关字段
|
||||
|
||||
### 使用流程
|
||||
1. 用户注册 → 账户默认为禁用状态
|
||||
2. 管理员在用户管理页面查看新注册用户
|
||||
3. 管理员激活用户账户
|
||||
4. 用户可以使用账户登录系统
|
||||
38
backend/Dockerfile
Normal file
38
backend/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apk add --no-cache \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
curl
|
||||
|
||||
# 复制 package.json 和 package-lock.json
|
||||
COPY package*.json ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 创建非root用户
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S nodejs -u 1001
|
||||
|
||||
# 更改文件所有权
|
||||
RUN chown -R nodejs:nodejs /app
|
||||
USER nodejs
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 3001
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3001/health || exit 1
|
||||
|
||||
# 启动命令
|
||||
CMD ["npm", "run", "dev"]
|
||||
347
backend/README.md
Normal file
347
backend/README.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# Pandora 后端 API
|
||||
|
||||
这是 Pandora 项目的后端 API 服务,提供完整的用户认证系统和权限管理功能。
|
||||
|
||||
## 🚀 功能特性
|
||||
|
||||
### 用户认证系统
|
||||
- ✅ 用户注册和登录
|
||||
- ✅ JWT 令牌认证
|
||||
- ✅ 邮箱验证
|
||||
- ✅ 密码重置
|
||||
- ✅ TOTP 二步验证
|
||||
- ✅ 会话管理
|
||||
- ✅ 密码加密 (bcrypt)
|
||||
|
||||
### 权限管理系统
|
||||
- ✅ 基于角色的权限控制
|
||||
- ✅ 路径权限验证
|
||||
- ✅ 用户权限分配
|
||||
- ✅ 权限审计日志
|
||||
|
||||
### 账号管理系统
|
||||
- ✅ 网站账号管理
|
||||
- ✅ 账号分配和撤销
|
||||
- ✅ 账号使用统计
|
||||
- ✅ 多用户共享控制
|
||||
|
||||
### 安全特性
|
||||
- ✅ API 限流
|
||||
- ✅ CORS 配置
|
||||
- ✅ 安全头部 (Helmet)
|
||||
- ✅ 输入验证
|
||||
- ✅ 错误处理
|
||||
- ✅ 审计日志
|
||||
|
||||
## 📋 技术栈
|
||||
|
||||
- **运行时**: Node.js + TypeScript
|
||||
- **框架**: Express.js
|
||||
- **数据库**: PostgreSQL + Prisma ORM
|
||||
- **缓存**: Redis
|
||||
- **认证**: JWT + bcrypt
|
||||
- **二步验证**: TOTP (Google Authenticator)
|
||||
- **邮件**: Nodemailer
|
||||
- **日志**: Winston
|
||||
- **验证**: express-validator
|
||||
|
||||
## 🛠️ 安装和设置
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 环境配置
|
||||
|
||||
复制环境变量文件:
|
||||
|
||||
```bash
|
||||
cp env.example .env
|
||||
```
|
||||
|
||||
编辑 `.env` 文件,配置以下变量:
|
||||
|
||||
```env
|
||||
# 数据库配置
|
||||
DATABASE_URL="postgresql://username:password@localhost:5432/pandora"
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET="your-super-secret-jwt-key-here"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
|
||||
# 邮件配置 (用于邮箱验证和密码重置)
|
||||
SMTP_HOST="smtp.gmail.com"
|
||||
SMTP_PORT=587
|
||||
SMTP_USER="your-email@gmail.com"
|
||||
SMTP_PASS="your-app-password"
|
||||
EMAIL_FROM="noreply@pandora.com"
|
||||
|
||||
# Redis 配置
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
|
||||
# 服务器配置
|
||||
PORT=3001
|
||||
NODE_ENV="development"
|
||||
|
||||
# 安全配置
|
||||
BCRYPT_ROUNDS=12
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
# TOTP 配置
|
||||
TOTP_ISSUER="Pandora"
|
||||
TOTP_LABEL="Pandora Authentication"
|
||||
```
|
||||
|
||||
### 3. 数据库设置
|
||||
|
||||
```bash
|
||||
# 生成 Prisma 客户端
|
||||
npm run db:generate
|
||||
|
||||
# 运行数据库迁移
|
||||
npm run db:migrate
|
||||
|
||||
# 初始化测试数据
|
||||
npm run db:seed
|
||||
```
|
||||
|
||||
### 4. 启动服务
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
npm run dev
|
||||
|
||||
# 生产模式
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## 📚 API 文档
|
||||
|
||||
### 认证相关 API
|
||||
|
||||
#### 用户注册
|
||||
```http
|
||||
POST /api/auth/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"username": "username",
|
||||
"password": "password123",
|
||||
"firstName": "张",
|
||||
"lastName": "三"
|
||||
}
|
||||
```
|
||||
|
||||
#### 用户登录
|
||||
```http
|
||||
POST /api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
#### 邮箱验证
|
||||
```http
|
||||
POST /api/auth/verify-email
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"token": "verification-token"
|
||||
}
|
||||
```
|
||||
|
||||
#### 设置 TOTP
|
||||
```http
|
||||
POST /api/auth/setup-totp
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
#### 验证 TOTP
|
||||
```http
|
||||
POST /api/auth/verify-totp
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"token": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
#### 获取当前用户信息
|
||||
```http
|
||||
GET /api/auth/me
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### 用户管理 API
|
||||
|
||||
#### 获取所有用户 (管理员)
|
||||
```http
|
||||
GET /api/users
|
||||
Authorization: Bearer <admin-token>
|
||||
```
|
||||
|
||||
#### 获取用户详情
|
||||
```http
|
||||
GET /api/users/:id
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
#### 更新用户信息
|
||||
```http
|
||||
PUT /api/users/:id
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"firstName": "新名字",
|
||||
"lastName": "新姓氏"
|
||||
}
|
||||
```
|
||||
|
||||
### 账号管理 API
|
||||
|
||||
#### 获取用户账号
|
||||
```http
|
||||
GET /api/accounts/user/assigned
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
#### 创建账号 (管理员)
|
||||
```http
|
||||
POST /api/accounts
|
||||
Authorization: Bearer <admin-token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"website": "claude.ai",
|
||||
"accountName": "claude_pro_1",
|
||||
"token": "sk-ant-api03-xxx",
|
||||
"maxUsers": 3
|
||||
}
|
||||
```
|
||||
|
||||
## 🔐 安全说明
|
||||
|
||||
### 密码安全
|
||||
- 使用 bcrypt 进行密码哈希,默认 12 轮加密
|
||||
- 密码最小长度 8 位
|
||||
- 支持密码重置功能
|
||||
|
||||
### 会话管理
|
||||
- JWT 令牌有效期 7 天
|
||||
- 支持令牌刷新
|
||||
- 会话存储在数据库中,支持强制登出
|
||||
|
||||
### 二步验证
|
||||
- 支持 TOTP (Google Authenticator)
|
||||
- 提供 10 个备用码
|
||||
- 可选择性启用
|
||||
|
||||
### API 安全
|
||||
- 所有敏感 API 需要认证
|
||||
- 管理员 API 需要管理员权限
|
||||
- 实现 API 限流防止滥用
|
||||
- 输入验证和清理
|
||||
|
||||
## 🧪 测试账户
|
||||
|
||||
运行 `npm run db:seed` 后会创建以下测试账户:
|
||||
|
||||
### 管理员账户
|
||||
- 邮箱: `admin@pandora.com`
|
||||
- 密码: `admin123`
|
||||
- 权限: 所有账号
|
||||
|
||||
### 测试用户账户
|
||||
- 邮箱: `user@pandora.com`
|
||||
- 密码: `user123`
|
||||
- 权限: claude.ai 和 openai.com 账号
|
||||
|
||||
## 📝 开发说明
|
||||
|
||||
### 项目结构
|
||||
```
|
||||
src/
|
||||
├── config/ # 配置文件
|
||||
│ ├── database.ts # 数据库连接
|
||||
│ └── redis.ts # Redis 连接
|
||||
├── controllers/ # 控制器
|
||||
│ ├── authController.ts
|
||||
│ ├── userController.ts
|
||||
│ └── accountController.ts
|
||||
├── middleware/ # 中间件
|
||||
│ ├── authMiddleware.ts
|
||||
│ ├── adminMiddleware.ts
|
||||
│ ├── validateRequest.ts
|
||||
│ ├── errorHandler.ts
|
||||
│ └── notFoundHandler.ts
|
||||
├── routes/ # 路由
|
||||
│ ├── auth.ts
|
||||
│ ├── users.ts
|
||||
│ └── accounts.ts
|
||||
├── utils/ # 工具函数
|
||||
│ └── logger.ts
|
||||
└── index.ts # 入口文件
|
||||
```
|
||||
|
||||
### 数据库模型
|
||||
- `User`: 用户信息
|
||||
- `WebsiteAccount`: 网站账号
|
||||
- `AccountAssignment`: 账号分配
|
||||
- `Session`: 用户会话
|
||||
- `AuditLog`: 审计日志
|
||||
|
||||
### 开发命令
|
||||
```bash
|
||||
# 开发模式
|
||||
npm run dev
|
||||
|
||||
# 构建
|
||||
npm run build
|
||||
|
||||
# 数据库操作
|
||||
npm run db:generate # 生成 Prisma 客户端
|
||||
npm run db:migrate # 运行迁移
|
||||
npm run db:push # 推送 schema 到数据库
|
||||
npm run db:studio # 打开 Prisma Studio
|
||||
npm run db:seed # 初始化测试数据
|
||||
```
|
||||
|
||||
## 🚨 注意事项
|
||||
|
||||
1. **环境变量**: 确保所有必需的环境变量都已正确配置
|
||||
2. **数据库**: 确保 PostgreSQL 数据库正在运行
|
||||
3. **Redis**: 确保 Redis 服务正在运行
|
||||
4. **邮件服务**: 如果使用邮箱验证功能,需要配置有效的 SMTP 服务
|
||||
5. **JWT 密钥**: 生产环境请使用强随机密钥
|
||||
6. **HTTPS**: 生产环境建议使用 HTTPS
|
||||
|
||||
## 📞 支持
|
||||
|
||||
如有问题,请查看:
|
||||
1. 日志文件 (`logs/` 目录)
|
||||
2. 数据库连接状态
|
||||
3. Redis 连接状态
|
||||
4. 环境变量配置
|
||||
|
||||
## 🔄 更新日志
|
||||
|
||||
### v1.0.0
|
||||
- ✅ 完整的用户认证系统
|
||||
- ✅ JWT 令牌认证
|
||||
- ✅ 邮箱验证功能
|
||||
- ✅ TOTP 二步验证
|
||||
- ✅ 权限管理系统
|
||||
- ✅ 账号管理系统
|
||||
- ✅ 审计日志
|
||||
- ✅ API 限流和安全防护
|
||||
45
backend/package.json
Normal file
45
backend/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "pandora-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Pandora Backend API",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.7.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-validator": "^7.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^6.9.7",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"nodemon": "^3.0.2",
|
||||
"prisma": "^5.7.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
99
backend/prisma/schema.prisma
Normal file
99
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,99 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
password String
|
||||
firstName String?
|
||||
lastName String?
|
||||
isActive Boolean @default(true)
|
||||
isAdmin Boolean @default(false)
|
||||
lastLoginAt DateTime?
|
||||
loginAttempts Int @default(0)
|
||||
lockedUntil DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
sessions Session[]
|
||||
auditLogs AuditLog[]
|
||||
accountAssignments AccountAssignment[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model WebsiteAccount {
|
||||
id String @id @default(cuid())
|
||||
website String
|
||||
accountName String
|
||||
token String
|
||||
isActive Boolean @default(true)
|
||||
maxUsers Int @default(1)
|
||||
currentUsers Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
accountAssignments AccountAssignment[]
|
||||
|
||||
@@unique([website, accountName])
|
||||
@@map("website_accounts")
|
||||
}
|
||||
|
||||
model AccountAssignment {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
accountId String
|
||||
assignedAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
account WebsiteAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, accountId])
|
||||
@@map("account_assignments")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
token String @unique
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
userId String?
|
||||
action String
|
||||
resource String?
|
||||
resourceId String?
|
||||
details String? // JSON string
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("audit_logs")
|
||||
}
|
||||
153
backend/prisma/seed.ts
Normal file
153
backend/prisma/seed.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('开始初始化数据库...');
|
||||
|
||||
// 创建管理员用户
|
||||
const adminPassword = await bcrypt.hash('admin123', 12);
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: 'admin@pandora.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'admin@pandora.com',
|
||||
username: 'admin',
|
||||
password: adminPassword,
|
||||
firstName: '管理员',
|
||||
lastName: '系统',
|
||||
isAdmin: true,
|
||||
isActive: true,
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 创建测试用户
|
||||
const userPassword = await bcrypt.hash('user123', 12);
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: 'user@pandora.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'user@pandora.com',
|
||||
username: 'testuser',
|
||||
password: userPassword,
|
||||
firstName: '测试',
|
||||
lastName: '用户',
|
||||
isAdmin: false,
|
||||
isActive: true,
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 创建网站账号
|
||||
const accounts = [
|
||||
{
|
||||
website: 'claude.ai',
|
||||
accountName: 'claude_pro_1',
|
||||
token: 'sk-ant-api03-xxx-claude-pro-1',
|
||||
maxUsers: 3,
|
||||
currentUsers: 0,
|
||||
},
|
||||
{
|
||||
website: 'openai.com',
|
||||
accountName: 'gpt4_plus_1',
|
||||
token: 'sk-xxx-gpt4-plus-1',
|
||||
maxUsers: 2,
|
||||
currentUsers: 0,
|
||||
},
|
||||
{
|
||||
website: 'gemini.google.com',
|
||||
accountName: 'gemini_pro_1',
|
||||
token: 'AIzaSyCxxx-gemini-pro-1',
|
||||
maxUsers: 1,
|
||||
currentUsers: 0,
|
||||
},
|
||||
];
|
||||
|
||||
for (const accountData of accounts) {
|
||||
await prisma.websiteAccount.upsert({
|
||||
where: {
|
||||
website_accountName: {
|
||||
website: accountData.website,
|
||||
accountName: accountData.accountName,
|
||||
}
|
||||
},
|
||||
update: {},
|
||||
create: accountData,
|
||||
});
|
||||
}
|
||||
|
||||
// 为管理员分配所有账号
|
||||
const allAccounts = await prisma.websiteAccount.findMany();
|
||||
for (const account of allAccounts) {
|
||||
await prisma.accountAssignment.upsert({
|
||||
where: {
|
||||
userId_accountId: {
|
||||
userId: admin.id,
|
||||
accountId: account.id,
|
||||
}
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
userId: admin.id,
|
||||
accountId: account.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 为用户分配部分账号
|
||||
const userAccounts = await prisma.websiteAccount.findMany({
|
||||
where: {
|
||||
website: {
|
||||
in: ['claude.ai', 'openai.com']
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const account of userAccounts) {
|
||||
await prisma.accountAssignment.upsert({
|
||||
where: {
|
||||
userId_accountId: {
|
||||
userId: user.id,
|
||||
accountId: account.id,
|
||||
}
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
userId: user.id,
|
||||
accountId: account.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 更新账号当前用户数
|
||||
for (const account of allAccounts) {
|
||||
const userCount = await prisma.accountAssignment.count({
|
||||
where: {
|
||||
accountId: account.id,
|
||||
isActive: true,
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.websiteAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { currentUsers: userCount }
|
||||
});
|
||||
}
|
||||
|
||||
console.log('数据库初始化完成!');
|
||||
console.log('管理员账户:', admin.email, '密码: admin123');
|
||||
console.log('测试用户账户:', user.email, '密码: user123');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('数据库初始化失败:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
34
backend/src/config/database.ts
Normal file
34
backend/src/config/database.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
declare global {
|
||||
var prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma = globalThis.prisma || new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalThis.prisma = prisma;
|
||||
}
|
||||
|
||||
export async function connectDatabase() {
|
||||
try {
|
||||
await prisma.$connect();
|
||||
logger.info('Database connected successfully');
|
||||
} catch (error) {
|
||||
logger.error('Database connection failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectDatabase() {
|
||||
try {
|
||||
await prisma.$disconnect();
|
||||
logger.info('Database disconnected successfully');
|
||||
} catch (error) {
|
||||
logger.error('Database disconnection failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
545
backend/src/controllers/accountController.ts
Normal file
545
backend/src/controllers/accountController.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { prisma } from '../config/database';
|
||||
import { AuthRequest } from '../middleware/authMiddleware';
|
||||
|
||||
export const accountController = {
|
||||
// 获取所有账号 (管理员)
|
||||
async getAllAccounts(req: Request, res: Response) {
|
||||
const accounts = await prisma.websiteAccount.findMany({
|
||||
include: {
|
||||
accountAssignments: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({
|
||||
accounts: accounts.map((account: any) => ({
|
||||
id: account.id,
|
||||
website: account.website,
|
||||
username: account.accountName,
|
||||
token: account.token,
|
||||
isActive: account.isActive,
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
assignedUsers: account.accountAssignments.map((aa: any) => aa.user)
|
||||
}))
|
||||
});
|
||||
},
|
||||
|
||||
// 根据ID获取账号 (管理员)
|
||||
async getAccountById(req: Request, res: Response) {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: '账号ID是必需的' });
|
||||
}
|
||||
|
||||
const account = await prisma.websiteAccount.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
accountAssignments: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: '账号不存在' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
account: {
|
||||
id: account.id,
|
||||
website: account.website,
|
||||
username: account.accountName,
|
||||
token: account.token,
|
||||
isActive: account.isActive,
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
assignedUsers: account.accountAssignments.map((aa: any) => aa.user)
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 创建新账号 (管理员)
|
||||
async createAccount(req: Request, res: Response) {
|
||||
const { website, accountName, token, isActive, maxUsers } = req.body;
|
||||
|
||||
if (!website || !accountName || !token) {
|
||||
return res.status(400).json({ error: '网站、账号名称和token是必需的' });
|
||||
}
|
||||
|
||||
// 检查账号是否已存在
|
||||
const existingAccount = await prisma.websiteAccount.findFirst({
|
||||
where: {
|
||||
website,
|
||||
accountName
|
||||
}
|
||||
});
|
||||
|
||||
if (existingAccount) {
|
||||
return res.status(400).json({ error: '账号已存在' });
|
||||
}
|
||||
|
||||
const account = await prisma.websiteAccount.create({
|
||||
data: {
|
||||
website,
|
||||
accountName,
|
||||
token,
|
||||
isActive: isActive !== undefined ? isActive : true,
|
||||
maxUsers: maxUsers || 1,
|
||||
currentUsers: 0,
|
||||
}
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
message: '账号创建成功',
|
||||
account: {
|
||||
id: account.id,
|
||||
website: account.website,
|
||||
username: account.accountName,
|
||||
token: account.token,
|
||||
isActive: account.isActive,
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 更新账号 (管理员)
|
||||
async updateAccount(req: Request, res: Response) {
|
||||
const { id } = req.params;
|
||||
const { website, accountName, token, maxUsers, isActive } = req.body;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: '账号ID是必需的' });
|
||||
}
|
||||
|
||||
const account = await prisma.websiteAccount.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: '账号不存在' });
|
||||
}
|
||||
|
||||
// 检查是否与其他账号重复(排除当前账号)
|
||||
if (website && accountName) {
|
||||
const existingAccount = await prisma.websiteAccount.findFirst({
|
||||
where: {
|
||||
website,
|
||||
accountName,
|
||||
id: { not: id }
|
||||
}
|
||||
});
|
||||
|
||||
if (existingAccount) {
|
||||
return res.status(400).json({ error: '该网站和账号名称组合已存在' });
|
||||
}
|
||||
}
|
||||
|
||||
const updatedAccount = await prisma.websiteAccount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
website,
|
||||
accountName,
|
||||
token,
|
||||
maxUsers,
|
||||
isActive,
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({
|
||||
message: '账号更新成功',
|
||||
account: {
|
||||
id: updatedAccount.id,
|
||||
website: updatedAccount.website,
|
||||
username: updatedAccount.accountName,
|
||||
token: updatedAccount.token,
|
||||
isActive: updatedAccount.isActive,
|
||||
createdAt: updatedAccount.createdAt,
|
||||
updatedAt: updatedAccount.updatedAt
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 删除账号 (管理员)
|
||||
async deleteAccount(req: Request, res: Response) {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: '账号ID是必需的' });
|
||||
}
|
||||
|
||||
const account = await prisma.websiteAccount.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: '账号不存在' });
|
||||
}
|
||||
|
||||
// 删除账号相关的所有分配
|
||||
await prisma.$transaction([
|
||||
prisma.accountAssignment.deleteMany({ where: { accountId: id } }),
|
||||
prisma.websiteAccount.delete({ where: { id } })
|
||||
]);
|
||||
|
||||
return res.json({ message: '账号删除成功' });
|
||||
},
|
||||
|
||||
// 获取用户的已分配账号
|
||||
async getUserAccounts(req: AuthRequest, res: Response) {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: '未授权' });
|
||||
}
|
||||
|
||||
const assignments = await prisma.accountAssignment.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: { gt: new Date() } }
|
||||
]
|
||||
},
|
||||
include: {
|
||||
account: {
|
||||
select: {
|
||||
id: true,
|
||||
website: true,
|
||||
accountName: true,
|
||||
token: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({
|
||||
accounts: assignments.map((aa: any) => ({
|
||||
id: aa.account.id,
|
||||
website: aa.account.website,
|
||||
username: aa.account.accountName,
|
||||
token: aa.account.token,
|
||||
isActive: aa.account.isActive,
|
||||
createdAt: aa.account.createdAt,
|
||||
updatedAt: aa.account.updatedAt,
|
||||
assignedAt: aa.assignedAt,
|
||||
expiresAt: aa.expiresAt,
|
||||
}))
|
||||
});
|
||||
},
|
||||
|
||||
// 分配账号给用户 (管理员)
|
||||
async assignAccount(req: Request, res: Response) {
|
||||
const { id } = req.params;
|
||||
const { userId, expiresAt } = req.body;
|
||||
|
||||
if (!id || !userId) {
|
||||
return res.status(400).json({ error: '账号ID和用户ID都是必需的' });
|
||||
}
|
||||
|
||||
// 检查账号是否存在
|
||||
const account = await prisma.websiteAccount.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: '账号不存在' });
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: '用户不存在' });
|
||||
}
|
||||
|
||||
// 检查账号是否已满
|
||||
if (account.currentUsers >= account.maxUsers) {
|
||||
return res.status(400).json({ error: '账号已达到最大用户数' });
|
||||
}
|
||||
|
||||
// 检查是否已经分配
|
||||
const existingAssignment = await prisma.accountAssignment.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
accountId: id,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (existingAssignment) {
|
||||
return res.status(400).json({ error: '用户已分配此账号' });
|
||||
}
|
||||
|
||||
// 创建分配
|
||||
const assignment = await prisma.accountAssignment.create({
|
||||
data: {
|
||||
userId,
|
||||
accountId: id,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
isActive: true,
|
||||
}
|
||||
});
|
||||
|
||||
// 更新账号当前用户数
|
||||
await prisma.websiteAccount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
currentUsers: {
|
||||
increment: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({
|
||||
message: '账号分配成功',
|
||||
assignment
|
||||
});
|
||||
},
|
||||
|
||||
// 取消账号分配 (管理员)
|
||||
async unassignAccount(req: Request, res: Response) {
|
||||
const { id, userId } = req.params;
|
||||
|
||||
if (!id || !userId) {
|
||||
return res.status(400).json({ error: '账号ID和用户ID都是必需的' });
|
||||
}
|
||||
|
||||
const assignment = await prisma.accountAssignment.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
accountId: id,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
return res.status(404).json({ error: '分配不存在' });
|
||||
}
|
||||
|
||||
// 删除分配
|
||||
await prisma.accountAssignment.delete({
|
||||
where: { id: assignment.id }
|
||||
});
|
||||
|
||||
// 更新账号当前用户数
|
||||
await prisma.websiteAccount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
currentUsers: {
|
||||
decrement: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ message: '账号分配已取消' });
|
||||
},
|
||||
|
||||
// 网站登录
|
||||
async loginToWebsite(req: Request, res: Response) {
|
||||
const { accountId } = req.params;
|
||||
const { userId } = req.body;
|
||||
|
||||
if (!accountId || !userId) {
|
||||
return res.status(400).json({ error: '账号ID和用户ID是必需的' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取账号信息
|
||||
const account = await prisma.websiteAccount.findUnique({
|
||||
where: { id: accountId }
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: '账号不存在' });
|
||||
}
|
||||
|
||||
// 检查用户是否有权限访问该账号
|
||||
console.log('检查用户权限:', { accountId, userId });
|
||||
|
||||
const assignment = await prisma.accountAssignment.findFirst({
|
||||
where: {
|
||||
accountId,
|
||||
userId,
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: { gt: new Date() } }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
console.log('权限检查结果:', {
|
||||
assignment: assignment ? 'found' : 'not found',
|
||||
accountId,
|
||||
userId,
|
||||
currentTime: new Date()
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
// 获取更多调试信息
|
||||
const allAssignments = await prisma.accountAssignment.findMany({
|
||||
where: { accountId, userId }
|
||||
});
|
||||
console.log('该用户的所有分配记录:', allAssignments);
|
||||
|
||||
return res.status(403).json({ error: '您没有权限访问该账号' });
|
||||
}
|
||||
|
||||
let loginUrl = '';
|
||||
|
||||
// 根据网站类型处理登录
|
||||
switch (account.website) {
|
||||
case 'claude':
|
||||
loginUrl = await handleClaudeLogin(account.token, userId);
|
||||
break;
|
||||
case 'chatgpt':
|
||||
loginUrl = await handleChatGPTLogin(account.token, userId);
|
||||
break;
|
||||
case 'grok':
|
||||
loginUrl = await handleGrokLogin(account.token, userId);
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: '不支持的网站类型' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
loginUrl,
|
||||
website: account.website,
|
||||
accountName: account.accountName
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('网站登录失败:', error);
|
||||
return res.status(500).json({ error: '登录失败,请稍后重试' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Claude 登录处理
|
||||
async function handleClaudeLogin(token: string, userName: string): Promise<string> {
|
||||
try {
|
||||
const baseUrl = process.env.CLAUDE_TARGET_URL || 'https://chat.micar9.com:8443';
|
||||
console.log('Claude登录处理:', { token, userName });
|
||||
// 第一步:获取oauth token
|
||||
const oauthResponse = await fetch(`${baseUrl}/manage-api/auth/oauth_token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_key: token,
|
||||
unique_name: userName
|
||||
})
|
||||
});
|
||||
|
||||
if (!oauthResponse.ok) {
|
||||
throw new Error(`OAuth token 请求失败: ${oauthResponse.status}`);
|
||||
}
|
||||
|
||||
const oauthData = await oauthResponse.json() as { login_url?: string };
|
||||
|
||||
if (!oauthData.login_url) {
|
||||
throw new Error('未获取到登录URL');
|
||||
}
|
||||
|
||||
return oauthData.login_url;
|
||||
} catch (error) {
|
||||
console.error('Claude登录处理失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ChatGPT 登录处理
|
||||
async function handleChatGPTLogin(token: string, userName: string): Promise<string> {
|
||||
try {
|
||||
const baseUrl = process.env.CLAUDE_TARGET_URL || 'http://127.0.0.1:8181';
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${process.env.ADMIN_PASSWORD || 'admin'}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
access_token: token,
|
||||
user_name: userName,
|
||||
isolated_session: true,
|
||||
limits: []
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`ChatGPT登录请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { login_url?: string };
|
||||
|
||||
if (!data.login_url) {
|
||||
throw new Error('未获取到登录URL');
|
||||
}
|
||||
|
||||
return data.login_url;
|
||||
} catch (error) {
|
||||
console.error('ChatGPT登录处理失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Grok 登录处理
|
||||
async function handleGrokLogin(token: string, userName: string): Promise<string> {
|
||||
try {
|
||||
const baseUrl = process.env.CLAUDE_TARGET_URL || 'https://grok-mirror.micar9.com:8443';
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/login-v2`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_name: userName,
|
||||
sso_token: token
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Grok登录请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { login_url?: string };
|
||||
|
||||
if (!data.login_url) {
|
||||
throw new Error('未获取到登录URL');
|
||||
}
|
||||
|
||||
return data.login_url;
|
||||
} catch (error) {
|
||||
console.error('Grok登录处理失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
222
backend/src/controllers/adminController.ts
Normal file
222
backend/src/controllers/adminController.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
import { createAuditLog } from '../utils/audit';
|
||||
|
||||
export const adminController = {
|
||||
// 管理员登录
|
||||
async login(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// 验证管理员凭据
|
||||
const admin = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
isAdmin: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
// 记录管理员登录失败审计日志
|
||||
await createAuditLog({
|
||||
userId: null,
|
||||
action: 'ADMIN_LOGIN_FAILED',
|
||||
resource: 'ADMIN',
|
||||
details: JSON.stringify({ username, reason: '管理员不存在' }),
|
||||
ipAddress: req.ip ?? null,
|
||||
userAgent: req.get('User-Agent') ?? null
|
||||
});
|
||||
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '管理员凭据无效'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isValidPassword = await bcrypt.compare(password, admin.password);
|
||||
if (!isValidPassword) {
|
||||
// 记录管理员登录失败审计日志
|
||||
await createAuditLog({
|
||||
userId: admin.id,
|
||||
action: 'ADMIN_LOGIN_FAILED',
|
||||
resource: 'ADMIN',
|
||||
details: JSON.stringify({ username, reason: '密码错误' }),
|
||||
ipAddress: req.ip ?? null,
|
||||
userAgent: req.get('User-Agent') ?? null
|
||||
});
|
||||
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '管理员凭据无效'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成JWT token
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: admin.id,
|
||||
username: admin.username,
|
||||
role: admin.isAdmin ? 'admin' : 'user'
|
||||
},
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
// 创建审计日志
|
||||
await createAuditLog({
|
||||
userId: admin.id,
|
||||
action: 'ADMIN_LOGIN',
|
||||
resource: 'ADMIN',
|
||||
details: { username: admin.username },
|
||||
ipAddress: req.ip ?? null,
|
||||
userAgent: req.get('User-Agent') ?? null
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '管理员登录成功',
|
||||
token,
|
||||
admin: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
role: admin.isAdmin ? 'admin' : 'user'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Admin login error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 获取统计数据
|
||||
async getStats(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
// 获取用户总数
|
||||
const totalUsers = await prisma.user.count({
|
||||
where: { isAdmin: false }
|
||||
});
|
||||
|
||||
// 获取账号总数
|
||||
const totalAccounts = await prisma.websiteAccount.count();
|
||||
|
||||
// 获取今日访问数(基于会话)
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayVisits = await prisma.session.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: today
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 获取系统告警数(基于审计日志中的错误)
|
||||
const alerts = await prisma.auditLog.count({
|
||||
where: {
|
||||
action: { contains: 'ERROR' },
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - 24 * 60 * 60 * 1000) // 最近24小时
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('统计数据:', { totalUsers, totalAccounts, todayVisits, alerts });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalUsers,
|
||||
totalAccounts,
|
||||
todayVisits,
|
||||
alerts
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get stats error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取统计数据失败'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 获取最近活动
|
||||
async getRecentActivities(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const activities = await prisma.auditLog.findMany({
|
||||
take: 10,
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const formattedActivities = activities.map((activity: any) => {
|
||||
let description = '';
|
||||
|
||||
if (activity.action === 'USER_LOGIN_FAILED' || activity.action === 'ADMIN_LOGIN_FAILED') {
|
||||
// 解析失败原因
|
||||
let reason = '登录失败';
|
||||
let username = '未知用户';
|
||||
|
||||
if (activity.details) {
|
||||
try {
|
||||
const details = JSON.parse(activity.details);
|
||||
reason = details.reason || '登录失败';
|
||||
username = details.username || '未知用户';
|
||||
} catch (e) {
|
||||
console.error('解析活动详情失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
description = `${username} ${reason}`;
|
||||
} else if (activity.user) {
|
||||
description = `${activity.user.username} ${activity.action}`;
|
||||
} else {
|
||||
description = `系统 ${activity.action}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: activity.id,
|
||||
description,
|
||||
time: activity.createdAt,
|
||||
details: activity.details,
|
||||
ipAddress: activity.ipAddress,
|
||||
userAgent: activity.userAgent,
|
||||
action: activity.action
|
||||
};
|
||||
});
|
||||
|
||||
console.log('最近活动:', formattedActivities);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
activities: formattedActivities
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get recent activities error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取最近活动失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
57
backend/src/controllers/auditLogController.ts
Normal file
57
backend/src/controllers/auditLogController.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { prisma } from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export const auditLogController = {
|
||||
// 获取审计日志
|
||||
async getAuditLogs(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { page = 1, limit = 10, userId, action, resource } = req.query;
|
||||
const skip = (Number(page) - 1) * Number(limit);
|
||||
|
||||
const where: any = {};
|
||||
if (userId) where.userId = String(userId);
|
||||
if (action) where.action = { contains: String(action) };
|
||||
if (resource) where.resource = { contains: String(resource) };
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: Number(limit),
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.auditLog.count({ where })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
logs,
|
||||
pagination: {
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
total,
|
||||
totalPages: Math.ceil(total / Number(limit))
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get audit logs error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取审计日志失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
387
backend/src/controllers/authController.ts
Normal file
387
backend/src/controllers/authController.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import { Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
import { AuthRequest } from '../middleware/authMiddleware';
|
||||
import type { Secret, SignOptions } from 'jsonwebtoken';
|
||||
|
||||
// Generate JWT token
|
||||
function generateToken(userId: string): string {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET is not configured');
|
||||
}
|
||||
const expiresIn = process.env.JWT_EXPIRES_IN || '7d';
|
||||
return jwt.sign(
|
||||
{ userId },
|
||||
secret,
|
||||
{ expiresIn: expiresIn as any }
|
||||
);
|
||||
}
|
||||
|
||||
// Create session
|
||||
async function createSession(userId: string, token: string, req: Request) {
|
||||
console.log('创建session:', { userId, token: token.substring(0, 20) + '...' })
|
||||
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId,
|
||||
token,
|
||||
ipAddress: req.ip ?? null,
|
||||
userAgent: req.get('User-Agent') ?? null,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Session创建成功:', {
|
||||
sessionId: session.id,
|
||||
expiresAt: session.expiresAt,
|
||||
currentTime: new Date()
|
||||
})
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export const authController = {
|
||||
// 用户注册
|
||||
async register(req: Request, res: Response) {
|
||||
const { username, password, confirmPassword, firstName, lastName } = req.body;
|
||||
|
||||
// 验证密码确认
|
||||
if (password !== confirmPassword) {
|
||||
return res.status(400).json({
|
||||
error: '密码和确认密码不匹配'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { username }
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(400).json({
|
||||
error: '用户名已存在'
|
||||
});
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, parseInt(process.env.BCRYPT_ROUNDS || '12'));
|
||||
|
||||
// Create user with isActive set to false by default
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
password: hashedPassword,
|
||||
firstName,
|
||||
lastName,
|
||||
isActive: false, // 新注册用户默认为禁用状态
|
||||
},
|
||||
});
|
||||
|
||||
// Create audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'USER_REGISTERED',
|
||||
resource: 'user',
|
||||
resourceId: user.id,
|
||||
ipAddress: req.ip ?? null,
|
||||
userAgent: req.get('User-Agent') ?? null,
|
||||
}
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
message: '注册成功,请等待管理员激活您的账户',
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
isAdmin: user.isAdmin,
|
||||
isActive: user.isActive,
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 用户登录
|
||||
async login(req: Request, res: Response) {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Find user
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// 记录登录失败审计日志
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: null,
|
||||
action: 'USER_LOGIN_FAILED',
|
||||
resource: 'user',
|
||||
resourceId: null,
|
||||
details: JSON.stringify({ username, reason: '用户不存在' }),
|
||||
ipAddress: req.ip ?? null,
|
||||
userAgent: req.get('User-Agent') ?? null,
|
||||
}
|
||||
});
|
||||
return res.status(401).json({ error: '用户不存在' });
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (!user.isActive) {
|
||||
// 记录登录失败审计日志
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'USER_LOGIN_FAILED',
|
||||
resource: 'user',
|
||||
resourceId: user.id,
|
||||
details: JSON.stringify({ username, reason: '账户已被禁用' }),
|
||||
ipAddress: req.ip ?? null,
|
||||
userAgent: req.get('User-Agent') ?? null,
|
||||
}
|
||||
});
|
||||
return res.status(401).json({ error: '账户已被禁用,请联系管理员激活' });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await bcrypt.compare(password, user.password);
|
||||
if (!isValidPassword) {
|
||||
// 增加登录失败次数
|
||||
const loginAttempts = (user.loginAttempts || 0) + 1;
|
||||
const updateData: any = { loginAttempts };
|
||||
let userDisabled = false;
|
||||
|
||||
// 如果失败次数达到5次,禁用账户
|
||||
if (loginAttempts >= 5) {
|
||||
updateData.isActive = false;
|
||||
userDisabled = true;
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: updateData
|
||||
});
|
||||
|
||||
// 记录登录失败审计日志
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'USER_LOGIN_FAILED',
|
||||
resource: 'user',
|
||||
resourceId: user.id,
|
||||
details: JSON.stringify({
|
||||
username,
|
||||
reason: userDisabled ? '密码错误且账户已被禁用' : '密码错误',
|
||||
loginAttempts,
|
||||
isDisabled: userDisabled
|
||||
}),
|
||||
ipAddress: req.ip ?? null,
|
||||
userAgent: req.get('User-Agent') ?? null,
|
||||
}
|
||||
});
|
||||
|
||||
if (userDisabled) {
|
||||
return res.status(401).json({ error: '登录失败次数过多,账户已被禁用,请联系管理员' });
|
||||
}
|
||||
return res.status(401).json({
|
||||
error: '用户名或密码错误',
|
||||
remainingAttempts: 5 - loginAttempts
|
||||
});
|
||||
}
|
||||
|
||||
// 登录成功,重置登录失败次数和锁定时间
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
loginAttempts: 0,
|
||||
lockedUntil: null
|
||||
}
|
||||
});
|
||||
|
||||
// Generate token
|
||||
const token = generateToken(user.id);
|
||||
|
||||
// Delete existing sessions for this user (optional - for single session per user)
|
||||
await prisma.session.deleteMany({
|
||||
where: { userId: user.id }
|
||||
});
|
||||
|
||||
// Create session
|
||||
await createSession(user.id, token, req);
|
||||
|
||||
// Update last login
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() }
|
||||
});
|
||||
|
||||
// Create audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'USER_LOGIN',
|
||||
resource: 'user',
|
||||
resourceId: user.id,
|
||||
ipAddress: req.ip ?? null,
|
||||
userAgent: req.get('User-Agent') ?? null,
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({
|
||||
message: '登录成功',
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
isAdmin: user.isAdmin,
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 用户登出
|
||||
async logout(req: AuthRequest, res: Response) {
|
||||
const token = req.headers.authorization?.substring(7);
|
||||
|
||||
if (token) {
|
||||
// Delete session
|
||||
await prisma.session.deleteMany({
|
||||
where: { token }
|
||||
});
|
||||
}
|
||||
|
||||
// Create audit log
|
||||
if (req.user) {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
action: 'USER_LOGOUT',
|
||||
resource: 'user',
|
||||
resourceId: req.user.id,
|
||||
ipAddress: req.ip ?? null,
|
||||
userAgent: req.get('User-Agent') ?? null,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: '登出成功' });
|
||||
},
|
||||
|
||||
// 获取当前用户信息
|
||||
async getCurrentUser(req: AuthRequest, res: Response) {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: '未授权' });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
isAdmin: true,
|
||||
isActive: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: '用户不存在' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
user: user
|
||||
});
|
||||
},
|
||||
|
||||
// 刷新token
|
||||
async refreshToken(req: Request, res: Response) {
|
||||
const { refreshToken } = req.body;
|
||||
|
||||
if (!refreshToken) {
|
||||
return res.status(400).json({ error: '刷新令牌是必需的' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET!) as any;
|
||||
|
||||
const session = await prisma.session.findFirst({
|
||||
where: {
|
||||
token: refreshToken,
|
||||
expiresAt: {
|
||||
gt: new Date()
|
||||
}
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!session || !session.user || !session.user.isActive) {
|
||||
return res.status(401).json({ error: '无效的刷新令牌' });
|
||||
}
|
||||
|
||||
// Generate new token
|
||||
const newToken = generateToken(session.user.id);
|
||||
|
||||
// Update session
|
||||
await prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
token: newToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({
|
||||
token: newToken,
|
||||
user: session.user
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: '无效的刷新令牌' });
|
||||
}
|
||||
},
|
||||
|
||||
// 调试端点 - 检查用户session状态
|
||||
async debugSession(req: AuthRequest, res: Response) {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: '未授权' });
|
||||
}
|
||||
|
||||
try {
|
||||
const sessions = await prisma.session.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
return res.json({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
sessions: sessions.map((s: any) => ({
|
||||
id: s.id,
|
||||
token: s.token.substring(0, 20) + '...',
|
||||
expiresAt: s.expiresAt,
|
||||
createdAt: s.createdAt,
|
||||
isExpired: s.expiresAt < new Date()
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: '获取session信息失败' });
|
||||
}
|
||||
}
|
||||
};
|
||||
365
backend/src/controllers/userController.ts
Normal file
365
backend/src/controllers/userController.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { prisma } from '../config/database';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export const userController = {
|
||||
// 获取所有用户 (管理员)
|
||||
async getAllUsers(req: Request, res: Response) {
|
||||
const { page = 1, limit = 10, search = '', role = '' } = req.query;
|
||||
|
||||
const pageNum = parseInt(page as string) || 1;
|
||||
const limitNum = parseInt(limit as string) || 10;
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
// 构建查询条件
|
||||
const where: any = {};
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ username: { contains: search as string, mode: 'insensitive' } },
|
||||
{ firstName: { contains: search as string, mode: 'insensitive' } },
|
||||
{ lastName: { contains: search as string, mode: 'insensitive' } }
|
||||
];
|
||||
}
|
||||
|
||||
if (role) {
|
||||
if (role === 'admin') {
|
||||
where.isAdmin = true;
|
||||
} else if (role === 'user') {
|
||||
where.isAdmin = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
const total = await prisma.user.count({ where });
|
||||
|
||||
// 获取用户列表
|
||||
const users = await prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
isAdmin: true,
|
||||
isActive: true,
|
||||
totpEnabled: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
accountAssignments: {
|
||||
select: {
|
||||
accountId: true
|
||||
}
|
||||
}
|
||||
},
|
||||
skip,
|
||||
take: limitNum,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
// 计算分页信息
|
||||
const totalPages = Math.ceil(total / limitNum);
|
||||
|
||||
return res.json({
|
||||
users: users.map((user: any) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.isAdmin ? 'admin' : 'user',
|
||||
isAdmin: user.isAdmin,
|
||||
isActive: user.isActive,
|
||||
totpEnabled: user.totpEnabled,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
accounts: user.accountAssignments.map((assignment: any) => assignment.accountId)
|
||||
})),
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
totalPages
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 根据ID获取用户信息
|
||||
async getUserById(req: AuthRequest, res: Response) {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: '用户ID是必需的' });
|
||||
}
|
||||
|
||||
// 检查权限:只能查看自己的信息或管理员可以查看所有
|
||||
if (req.user?.id !== id && req.user?.role !== 'admin') {
|
||||
return res.status(403).json({ error: '权限不足' });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
isAdmin: true,
|
||||
isActive: true,
|
||||
totpEnabled: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: '用户不存在' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
isAdmin: user.isAdmin,
|
||||
isActive: user.isActive,
|
||||
totpEnabled: user.totpEnabled,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 更新用户信息
|
||||
async updateUser(req: AuthRequest, res: Response) {
|
||||
const { id } = req.params;
|
||||
const { username, role, firstName, lastName, isActive, loginAttempts } = req.body;
|
||||
|
||||
console.log('收到更新请求:', req.body);
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: '用户ID是必需的' });
|
||||
}
|
||||
|
||||
// 检查权限:只能更新自己的信息或管理员可以更新所有
|
||||
if (req.user?.id !== id && req.user?.role !== 'admin') {
|
||||
return res.status(403).json({ error: '权限不足' });
|
||||
}
|
||||
|
||||
// 构建更新数据
|
||||
const updateData: any = {};
|
||||
|
||||
// 只有管理员可以修改这些字段
|
||||
if (req.user?.role === 'admin') {
|
||||
if (username !== undefined) updateData.username = username;
|
||||
if (role !== undefined) updateData.isAdmin = role === 'admin';
|
||||
if (typeof isActive === 'boolean') updateData.isActive = isActive;
|
||||
}
|
||||
|
||||
// 普通用户可以修改这些字段
|
||||
if (firstName !== undefined) updateData.firstName = firstName;
|
||||
if (lastName !== undefined) updateData.lastName = lastName;
|
||||
|
||||
// 新增:处理密码修改
|
||||
if (req.body.password && typeof req.body.password === 'string' && req.body.password.trim() !== '') {
|
||||
console.log('正在处理密码更新');
|
||||
const hashedPassword = await bcrypt.hash(req.body.password, 12);
|
||||
updateData.password = hashedPassword;
|
||||
console.log('密码已加密');
|
||||
}
|
||||
updateData.loginAttempts = 0
|
||||
console.log('最终更新数据:', updateData);
|
||||
|
||||
// 检查用户名是否已存在(排除当前用户)
|
||||
if (username) {
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
id: { not: id }
|
||||
}
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ error: '用户名已存在' });
|
||||
}
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
isAdmin: true,
|
||||
isActive: true,
|
||||
totpEnabled: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
password: true,
|
||||
loginAttempts: true
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
isAdmin: user.isAdmin,
|
||||
isActive: user.isActive,
|
||||
totpEnabled: user.totpEnabled,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
password: user.password,
|
||||
loginAttempts: loginAttempts
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 更新用户账号权限
|
||||
async updateUserAccounts(req: AuthRequest, res: Response) {
|
||||
const { id } = req.params;
|
||||
const { accountIds } = req.body;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: '用户ID是必需的' });
|
||||
}
|
||||
|
||||
if (!Array.isArray(accountIds)) {
|
||||
return res.status(400).json({ error: 'accountIds必须是数组' });
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: '用户不存在' });
|
||||
}
|
||||
|
||||
// 检查所有账号是否存在
|
||||
const accounts = await prisma.websiteAccount.findMany({
|
||||
where: {
|
||||
id: { in: accountIds }
|
||||
}
|
||||
});
|
||||
|
||||
if (accounts.length !== accountIds.length) {
|
||||
return res.status(400).json({ error: '部分账号不存在' });
|
||||
}
|
||||
|
||||
// 使用事务来确保数据一致性
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 删除用户现有的所有账号分配
|
||||
await tx.accountAssignment.deleteMany({
|
||||
where: { userId: id }
|
||||
});
|
||||
|
||||
// 创建新的账号分配
|
||||
if (accountIds.length > 0) {
|
||||
await tx.accountAssignment.createMany({
|
||||
data: accountIds.map(accountId => ({
|
||||
userId: id,
|
||||
accountId: accountId,
|
||||
isActive: true
|
||||
}))
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({
|
||||
message: '用户账号权限更新成功',
|
||||
accountIds
|
||||
});
|
||||
},
|
||||
|
||||
// 删除用户 (管理员)
|
||||
async deleteUser(req: Request, res: Response) {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: '用户ID是必需的' });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: '用户不存在' });
|
||||
}
|
||||
|
||||
// 删除用户相关的所有数据
|
||||
await prisma.$transaction([
|
||||
prisma.session.deleteMany({ where: { userId: id } }),
|
||||
prisma.accountAssignment.deleteMany({ where: { userId: id } }),
|
||||
prisma.auditLog.deleteMany({ where: { userId: id } }),
|
||||
prisma.user.delete({ where: { id } })
|
||||
]);
|
||||
|
||||
return res.json({ message: '用户删除成功' });
|
||||
},
|
||||
|
||||
// 创建用户 (管理员)
|
||||
async createUser(req: Request, res: Response) {
|
||||
const { username, password, role } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: '用户名和密码是必需的' });
|
||||
}
|
||||
|
||||
// 检查用户是否已存在
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { username }
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ error: '用户名已存在' });
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// 创建用户,默认为禁用状态
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
password: hashedPassword,
|
||||
isAdmin: role === 'admin',
|
||||
isActive: false, // 新创建的用户默认为禁用状态
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
isAdmin: true,
|
||||
isActive: true,
|
||||
totpEnabled: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
}
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
message: '用户创建成功,需要激活后才能登录',
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.isAdmin ? 'admin' : 'user',
|
||||
isAdmin: user.isAdmin,
|
||||
isActive: user.isActive,
|
||||
totpEnabled: user.totpEnabled,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
105
backend/src/index.ts
Normal file
105
backend/src/index.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'express-async-errors';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import dotenv from 'dotenv';
|
||||
import { logger } from './utils/logger';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
import { notFoundHandler } from './middleware/notFoundHandler';
|
||||
import authRoutes from './routes/auth';
|
||||
import userRoutes from './routes/users';
|
||||
import accountRoutes from './routes/accounts';
|
||||
import adminRoutes from './routes/admin';
|
||||
import { connectDatabase } from './config/database';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// 信任代理,确保正确获取客户端IP地址
|
||||
app.set('trust proxy', true);
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: process.env.NODE_ENV === 'production'
|
||||
? ['http://frontend:3000',"http://backend:3001"]
|
||||
: ['http://localhost:3000', 'http://localhost:5173'],
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'), // 15 minutes
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100'), // limit each IP to 100 requests per windowMs
|
||||
message: {
|
||||
error: 'Too many requests from this IP, please try again later.'
|
||||
}
|
||||
});
|
||||
app.use('/api/', limiter);
|
||||
|
||||
// Body parsing middleware
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Request logging
|
||||
app.use((req, res, next) => {
|
||||
logger.info(`${req.method} ${req.path}`, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent')
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'OK',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime()
|
||||
});
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/accounts', accountRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
// Start server
|
||||
async function startServer() {
|
||||
try {
|
||||
// Connect to database
|
||||
await connectDatabase();
|
||||
logger.info('Database connected successfully');
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
logger.info(`Environment: ${process.env.NODE_ENV}`);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM received, shutting down gracefully');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('SIGINT received, shutting down gracefully');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
startServer();
|
||||
19
backend/src/middleware/adminMiddleware.ts
Normal file
19
backend/src/middleware/adminMiddleware.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { AuthRequest } from './authMiddleware';
|
||||
import { AppError } from './errorHandler';
|
||||
|
||||
export function adminMiddleware(
|
||||
req: AuthRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
if (!req.user) {
|
||||
throw new AppError('Authentication required', 401);
|
||||
}
|
||||
|
||||
if (!req.user.isAdmin) {
|
||||
throw new AppError('Admin privileges required', 403);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
82
backend/src/middleware/auth.ts
Normal file
82
backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../config/database';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 验证JWT token
|
||||
export const authenticateToken = async (
|
||||
req: AuthRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '未提供认证token'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any;
|
||||
|
||||
// 检查用户是否存在
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId }
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '用户不存在或已被禁用'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
req.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.isAdmin ? 'admin' : 'user'
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '无效的token'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 检查角色权限
|
||||
export const requireRole = (role: string) => {
|
||||
return (req: AuthRequest, res: Response, next: NextFunction): void => {
|
||||
if (!req.user) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '需要认证'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.user.role !== role) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: '权限不足'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
72
backend/src/middleware/authMiddleware.ts
Normal file
72
backend/src/middleware/authMiddleware.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../config/database';
|
||||
import { AppError } from './errorHandler';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export async function authMiddleware(
|
||||
req: AuthRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new AppError('No token provided', 401);
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
console.log('验证token:', { token: token.substring(0, 20) + '...' })
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any;
|
||||
console.log('JWT解码成功:', { userId: decoded.userId })
|
||||
|
||||
// Check if session exists and is valid
|
||||
const session = await prisma.session.findFirst({
|
||||
where: {
|
||||
token,
|
||||
expiresAt: {
|
||||
gt: new Date()
|
||||
}
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Session查询结果:', {
|
||||
found: !!session,
|
||||
expiresAt: session?.expiresAt,
|
||||
currentTime: new Date(),
|
||||
userActive: session?.user?.isActive
|
||||
})
|
||||
|
||||
if (!session || !session.user || !session.user.isActive) {
|
||||
throw new AppError('Invalid or expired token', 401);
|
||||
}
|
||||
|
||||
req.user = session.user;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
next(new AppError('Invalid token', 401));
|
||||
} else {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
45
backend/src/middleware/errorHandler.ts
Normal file
45
backend/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export class AppError extends Error {
|
||||
public statusCode: number;
|
||||
public isOperational: boolean;
|
||||
|
||||
constructor(message: string, statusCode: number = 500) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.isOperational = true;
|
||||
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
export function errorHandler(
|
||||
error: AppError,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const statusCode = error.statusCode || 500;
|
||||
const message = error.message || 'Internal Server Error';
|
||||
|
||||
// Log error
|
||||
logger.error('Error occurred:', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent')
|
||||
});
|
||||
|
||||
// Don't leak error details in production
|
||||
const responseMessage = process.env.NODE_ENV === 'production' && statusCode === 500
|
||||
? 'Internal Server Error'
|
||||
: message;
|
||||
|
||||
res.status(statusCode).json({
|
||||
error: responseMessage,
|
||||
...(process.env.NODE_ENV === 'development' && { stack: error.stack })
|
||||
});
|
||||
}
|
||||
9
backend/src/middleware/notFoundHandler.ts
Normal file
9
backend/src/middleware/notFoundHandler.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
export function notFoundHandler(req: Request, res: Response) {
|
||||
res.status(404).json({
|
||||
error: 'Route not found',
|
||||
path: req.path,
|
||||
method: req.method
|
||||
});
|
||||
}
|
||||
16
backend/src/middleware/validateRequest.ts
Normal file
16
backend/src/middleware/validateRequest.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { validationResult } from 'express-validator';
|
||||
|
||||
export function validateRequest(req: Request, res: Response, next: NextFunction): void {
|
||||
const errors = validationResult(req);
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: errors.array()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
17
backend/src/middleware/validation.ts
Normal file
17
backend/src/middleware/validation.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { validationResult } from 'express-validator';
|
||||
|
||||
export const validateRequest = (req: Request, res: Response, next: NextFunction): void => {
|
||||
const errors = validationResult(req);
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '验证失败',
|
||||
errors: errors.array()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
38
backend/src/routes/accounts.ts
Normal file
38
backend/src/routes/accounts.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Router } from 'express';
|
||||
import { accountController } from '../controllers/accountController';
|
||||
import { authMiddleware } from '../middleware/authMiddleware';
|
||||
import { adminMiddleware } from '../middleware/adminMiddleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Get all accounts (admin only)
|
||||
router.get('/', adminMiddleware, accountController.getAllAccounts);
|
||||
|
||||
// Get account by ID (admin only)
|
||||
router.get('/:id', adminMiddleware, accountController.getAccountById);
|
||||
|
||||
// Create new account (admin only)
|
||||
router.post('/', adminMiddleware, accountController.createAccount);
|
||||
|
||||
// Update account (admin only)
|
||||
router.put('/:id', adminMiddleware, accountController.updateAccount);
|
||||
|
||||
// Delete account (admin only)
|
||||
router.delete('/:id', adminMiddleware, accountController.deleteAccount);
|
||||
|
||||
// Get user's assigned accounts
|
||||
router.get('/user/assigned', accountController.getUserAccounts);
|
||||
|
||||
// Assign account to user (admin only)
|
||||
router.post('/:id/assign', adminMiddleware, accountController.assignAccount);
|
||||
|
||||
// Unassign account from user (admin only)
|
||||
router.delete('/:id/assign/:userId', adminMiddleware, accountController.unassignAccount);
|
||||
|
||||
// Login to website
|
||||
router.post('/:accountId/login', accountController.loginToWebsite);
|
||||
|
||||
export default router;
|
||||
37
backend/src/routes/admin.ts
Normal file
37
backend/src/routes/admin.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import express from 'express';
|
||||
import { authenticateToken, requireRole } from '../middleware/auth';
|
||||
import { validateRequest } from '../middleware/validation';
|
||||
import { adminController } from '../controllers/adminController';
|
||||
import { userController } from '../controllers/userController';
|
||||
import { accountController } from '../controllers/accountController';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 管理员认证中间件
|
||||
const requireAdmin = requireRole('admin');
|
||||
|
||||
// 管理员登录
|
||||
router.post('/login', validateRequest, adminController.login);
|
||||
|
||||
// 获取统计数据
|
||||
router.get('/stats', authenticateToken, requireAdmin, adminController.getStats);
|
||||
|
||||
// 获取最近活动
|
||||
router.get('/activities', authenticateToken, requireAdmin, adminController.getRecentActivities);
|
||||
|
||||
// 用户管理
|
||||
router.get('/users', authenticateToken, requireAdmin, userController.getAllUsers);
|
||||
router.post('/users', authenticateToken, requireAdmin, validateRequest, userController.createUser);
|
||||
router.get('/users/:id', authenticateToken, requireAdmin, userController.getUserById);
|
||||
router.put('/users/:id', authenticateToken, requireAdmin, validateRequest, userController.updateUser);
|
||||
router.put('/users/:id/accounts', authenticateToken, requireAdmin, validateRequest, userController.updateUserAccounts);
|
||||
router.delete('/users/:id', authenticateToken, requireAdmin, userController.deleteUser);
|
||||
|
||||
// 账号管理
|
||||
router.get('/accounts', authenticateToken, requireAdmin, accountController.getAllAccounts);
|
||||
router.post('/accounts', authenticateToken, requireAdmin, validateRequest, accountController.createAccount);
|
||||
router.get('/accounts/:id', authenticateToken, requireAdmin, accountController.getAccountById);
|
||||
router.put('/accounts/:id', authenticateToken, requireAdmin, validateRequest, accountController.updateAccount);
|
||||
router.delete('/accounts/:id', authenticateToken, requireAdmin, accountController.deleteAccount);
|
||||
|
||||
export default router;
|
||||
52
backend/src/routes/auth.ts
Normal file
52
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Router } from 'express';
|
||||
import { body } from 'express-validator';
|
||||
import { validateRequest } from '../middleware/validateRequest';
|
||||
import { authController } from '../controllers/authController';
|
||||
import { authMiddleware } from '../middleware/authMiddleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Register
|
||||
router.post('/register', [
|
||||
body('username').isLength({ min: 3, max: 30 }).matches(/^[a-zA-Z0-9_]+$/),
|
||||
body('password').isLength({ min: 8 }),
|
||||
body('confirmPassword').isLength({ min: 8 }).custom((value, { req }) => {
|
||||
if (value !== req.body.password) {
|
||||
throw new Error('密码和确认密码不匹配');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('firstName').optional().isLength({ max: 50 }),
|
||||
body('lastName').optional().isLength({ max: 50 }),
|
||||
validateRequest
|
||||
], authController.register);
|
||||
|
||||
// Login
|
||||
router.post('/login', [
|
||||
body('username').isLength({ min: 3, max: 30 }),
|
||||
body('password').notEmpty(),
|
||||
validateRequest
|
||||
], authController.login);
|
||||
|
||||
// Logout
|
||||
router.post('/logout', authMiddleware, authController.logout);
|
||||
|
||||
// Setup TOTP
|
||||
router.post('/setup-totp', authMiddleware, authController.setupTOTP);
|
||||
|
||||
// Verify TOTP
|
||||
router.post('/verify-totp', [
|
||||
body('token').notEmpty(),
|
||||
validateRequest
|
||||
], authMiddleware, authController.verifyTOTP);
|
||||
|
||||
// Get current user
|
||||
router.get('/me', authMiddleware, authController.getCurrentUser);
|
||||
|
||||
// Refresh token
|
||||
router.post('/refresh', authController.refreshToken);
|
||||
|
||||
// Debug session (for development)
|
||||
router.get('/debug-session', authMiddleware, authController.debugSession);
|
||||
|
||||
export default router;
|
||||
23
backend/src/routes/users.ts
Normal file
23
backend/src/routes/users.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Router } from 'express';
|
||||
import { userController } from '../controllers/userController';
|
||||
import { authMiddleware } from '../middleware/authMiddleware';
|
||||
import { adminMiddleware } from '../middleware/adminMiddleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Get all users (admin only)
|
||||
router.get('/', adminMiddleware, userController.getAllUsers);
|
||||
|
||||
// Get user by ID (admin or self)
|
||||
router.get('/:id', userController.getUserById);
|
||||
|
||||
// Update user (admin or self)
|
||||
router.put('/:id', userController.updateUser);
|
||||
|
||||
// Delete user (admin only)
|
||||
router.delete('/:id', adminMiddleware, userController.deleteUser);
|
||||
|
||||
export default router;
|
||||
41
backend/src/utils/audit.ts
Normal file
41
backend/src/utils/audit.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { prisma } from '../config/database';
|
||||
|
||||
interface AuditLogData {
|
||||
userId: string | null;
|
||||
action: string;
|
||||
resource: string;
|
||||
resourceId?: string;
|
||||
details?: any;
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
}
|
||||
|
||||
export const createAuditLog = async (data: AuditLogData) => {
|
||||
try {
|
||||
const auditData: any = {
|
||||
userId: data.userId,
|
||||
action: data.action,
|
||||
resource: data.resource
|
||||
};
|
||||
|
||||
// 只添加非undefined的字段
|
||||
if (data.resourceId !== undefined) {
|
||||
auditData.resourceId = data.resourceId;
|
||||
}
|
||||
if (data.details !== undefined) {
|
||||
auditData.details = data.details;
|
||||
}
|
||||
if (data.ipAddress !== undefined) {
|
||||
auditData.ipAddress = data.ipAddress;
|
||||
}
|
||||
if (data.userAgent !== undefined) {
|
||||
auditData.userAgent = data.userAgent;
|
||||
}
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: auditData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create audit log:', error);
|
||||
}
|
||||
};
|
||||
26
backend/src/utils/logger.ts
Normal file
26
backend/src/utils/logger.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import winston from 'winston';
|
||||
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
format: logFormat,
|
||||
defaultMeta: { service: 'pandora-backend' },
|
||||
transports: [
|
||||
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
||||
new winston.transports.File({ filename: 'logs/combined.log' }),
|
||||
],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
}));
|
||||
}
|
||||
30
backend/tsconfig.json
Normal file
30
backend/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
44
docker-compose.yml
Normal file
44
docker-compose.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# 后端 API 服务
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: pandora_backend
|
||||
volumes:
|
||||
- sqlite_data:/app/prisma
|
||||
ports:
|
||||
- "3001:3001"
|
||||
networks:
|
||||
- pandora_network
|
||||
restart: always
|
||||
command: npm run start
|
||||
|
||||
# 前端应用
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: pandora_frontend
|
||||
environment:
|
||||
CLAUDE_TARGET_URL: https://claude.micar9.com:8443
|
||||
CHATGPT_TARGET_URL: https://gpt.micar9.com:8443
|
||||
GROK_TARGET_URL: https://grok-mirror.micar9.com:8443
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- pandora_network
|
||||
restart: always
|
||||
command: npm run start
|
||||
|
||||
volumes:
|
||||
sqlite_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
pandora_network:
|
||||
driver: bridge
|
||||
68
env.example
Normal file
68
env.example
Normal file
@@ -0,0 +1,68 @@
|
||||
# 数据库配置
|
||||
DATABASE_URL=postgresql://pandora_user:pandora_password@localhost:5432/pandora
|
||||
POSTGRES_DB=pandora
|
||||
POSTGRES_USER=pandora_user
|
||||
POSTGRES_PASSWORD=your-secure-password
|
||||
|
||||
# Redis配置
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_PASSWORD=your-redis-password
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# 服务器配置
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# 邮件配置
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
SMTP_FROM=Pandora <your-email@gmail.com>
|
||||
|
||||
# 前端配置
|
||||
VITE_API_URL=http://localhost:3001
|
||||
VITE_APP_NAME=Pandora
|
||||
|
||||
# 域名配置
|
||||
DOMAIN_PATH1=path1.com
|
||||
DOMAIN_PATH2=path2.com
|
||||
DOMAIN_PATH3=path3.com
|
||||
|
||||
# 聊天配置 - 网站代理目标地址
|
||||
CLAUDE_TARGET_URL=https://claude.ai
|
||||
CHATGPT_TARGET_URL=https://chat.openai.com
|
||||
GROK_TARGET_URL=https://grok.x.ai
|
||||
|
||||
# 聊天配置 - 自定义User-Agent(可选)
|
||||
CLAUDE_USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36
|
||||
CHATGPT_USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36
|
||||
GROK_USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36
|
||||
|
||||
# 聊天配置 - 超时和限制
|
||||
CHAT_TIMEOUT=10000
|
||||
CHAT_RATE_LIMIT=100
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL=info
|
||||
|
||||
# 监控配置 (可选)
|
||||
GRAFANA_PASSWORD=admin
|
||||
|
||||
# 安全配置
|
||||
BCRYPT_ROUNDS=12
|
||||
RATE_LIMIT_WINDOW=15m
|
||||
RATE_LIMIT_MAX=100
|
||||
|
||||
# 文件上传配置
|
||||
UPLOAD_MAX_SIZE=10mb
|
||||
UPLOAD_PATH=./uploads
|
||||
|
||||
# 备份配置
|
||||
BACKUP_ENABLED=true
|
||||
BACKUP_SCHEDULE=0 2 * * *
|
||||
BACKUP_RETENTION_DAYS=30
|
||||
35
frontend/Dockerfile
Normal file
35
frontend/Dockerfile
Normal 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
108
frontend/README.md
Normal 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
17
frontend/index.html
Normal 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
50
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
15
frontend/src/App.vue
Normal file
15
frontend/src/App.vue
Normal 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>
|
||||
BIN
frontend/src/assets/ChatGPT.png
Normal file
BIN
frontend/src/assets/ChatGPT.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/src/assets/claude.png
Normal file
BIN
frontend/src/assets/claude.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
frontend/src/assets/grok.png
Normal file
BIN
frontend/src/assets/grok.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
35
frontend/src/components/icons/index.ts
Normal file
35
frontend/src/components/icons/index.ts
Normal 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>
|
||||
`
|
||||
}
|
||||
62
frontend/src/config/websites.ts
Normal file
62
frontend/src/config/websites.ts
Normal 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
11
frontend/src/env.d.ts
vendored
Normal 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
44
frontend/src/main.ts
Normal 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')
|
||||
105
frontend/src/router/index.ts
Normal file
105
frontend/src/router/index.ts
Normal 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
|
||||
248
frontend/src/stores/admin.ts
Normal file
248
frontend/src/stores/admin.ts
Normal 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
247
frontend/src/stores/auth.ts
Normal 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
39
frontend/src/style.css
Normal 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
119
frontend/src/types/index.ts
Normal 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
13
frontend/src/types/vue.d.ts
vendored
Normal 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
294
frontend/src/utils/api.ts
Normal 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
|
||||
96
frontend/src/utils/auth.ts
Normal file
96
frontend/src/utils/auth.ts
Normal 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
|
||||
}
|
||||
472
frontend/src/views/Admin.vue
Normal file
472
frontend/src/views/Admin.vue
Normal 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>
|
||||
422
frontend/src/views/AdminAccounts.vue
Normal file
422
frontend/src/views/AdminAccounts.vue
Normal 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>
|
||||
208
frontend/src/views/AdminLogin.vue
Normal file
208
frontend/src/views/AdminLogin.vue
Normal 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>
|
||||
38
frontend/src/views/AdminMonitor.vue
Normal file
38
frontend/src/views/AdminMonitor.vue
Normal 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>
|
||||
388
frontend/src/views/AdminPermissions.vue
Normal file
388
frontend/src/views/AdminPermissions.vue
Normal 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>
|
||||
400
frontend/src/views/AdminUsers.vue
Normal file
400
frontend/src/views/AdminUsers.vue
Normal 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>
|
||||
243
frontend/src/views/Dashboard.vue
Normal file
243
frontend/src/views/Dashboard.vue
Normal 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
401
frontend/src/views/Home.vue
Normal 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>
|
||||
43
frontend/src/views/NotFound.vue
Normal file
43
frontend/src/views/NotFound.vue
Normal 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
212
frontend/src/views/Test.vue
Normal 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>
|
||||
65
frontend/tailwind.config.js
Normal file
65
frontend/tailwind.config.js
Normal 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
36
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
44
frontend/vite.config.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
8186
package-lock.json
generated
Normal file
8186
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
package.json
Normal file
59
package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "pandora",
|
||||
"version": "1.0.0",
|
||||
"description": "网站账号共享系统",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"frontend",
|
||||
"backend"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
"dev:frontend": "cd frontend && npm run dev",
|
||||
"build": "npm run build:backend && npm run build:frontend",
|
||||
"build:backend": "cd backend && npm run build",
|
||||
"build:frontend": "cd frontend && npm run build",
|
||||
"test": "npm run test:backend && npm run test:frontend",
|
||||
"test:backend": "cd backend && npm test",
|
||||
"test:frontend": "cd frontend && npm test",
|
||||
"lint": "npm run lint:backend && npm run lint:frontend",
|
||||
"lint:backend": "cd backend && npm run lint",
|
||||
"lint:frontend": "cd frontend && npm run lint",
|
||||
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,vue,json,md}\"",
|
||||
"docker:build": "docker-compose build",
|
||||
"docker:up": "docker-compose up -d",
|
||||
"docker:down": "docker-compose down",
|
||||
"docker:logs": "docker-compose logs -f",
|
||||
"docker:prod": "docker-compose -f docker-compose.prod.yml up -d",
|
||||
"db:migrate": "cd backend && npm run db:migrate",
|
||||
"db:seed": "cd backend && npm run db:seed",
|
||||
"db:reset": "cd backend && npm run db:reset"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2",
|
||||
"prettier": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"account-sharing",
|
||||
"user-management",
|
||||
"authentication",
|
||||
"vue",
|
||||
"express",
|
||||
"typescript"
|
||||
],
|
||||
"author": "Your Name",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/your-username/pandora.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/your-username/pandora/issues"
|
||||
},
|
||||
"homepage": "https://github.com/your-username/pandora#readme"
|
||||
}
|
||||
Reference in New Issue
Block a user