first commit

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

139
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
}
}

View 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
View 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();
});

View 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;
}
}

View 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;
}
}

View 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: '获取最近活动失败'
});
}
}
};

View 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: '获取审计日志失败'
});
}
}
};

View 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信息失败' });
}
}
};

View 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
View 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();

View 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();
}

View 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();
};
};

View 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);
}
}
}

View 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 })
});
}

View 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
});
}

View 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();
}

View 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();
};

View 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;

View 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;

View 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;

View 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;

View 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);
}
};

View 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
View 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
View 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
View 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
View File

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

108
frontend/README.md Normal file
View File

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

17
frontend/index.html Normal file
View File

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

50
frontend/package.json Normal file
View File

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

View File

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

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

View File

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

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

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

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

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

36
frontend/tsconfig.json Normal file
View File

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

View File

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

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

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

8186
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

59
package.json Normal file
View 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"
}