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