feat: 完整功能补全 - 用户管理/订阅付费/安全限流/管理后台/PDF报告/文档

用户管理增强:
- 邮箱验证(模拟模式)、密码找回、密码修改、个人资料编辑
- 新增 forgot-password/reset-password/verify-email 页面
- Settings 页重构为 Tab 布局

订阅与模拟付费:
- 4个套餐(免费/入门/专业/企业)完整管理
- 模拟支付流程、升级/降级/取消
- 订阅历史记录

安全与限流:
- 接口限流中间件(认证5次/分、全局100次/分)
- 安全响应头(X-Content-Type-Options等)
- 请求日志中间件

管理后台:
- 系统统计(用户数/查询数/引用率/活跃用户)
- 用户管理(列表/搜索/启禁用/修改套餐)
- Admin权限控制

报告增强:
- PDF报告导出(中文支持、封面、统计、表格)
- 报告预览页(统计卡片+最近记录)

项目文档:
- README.md 完整重写
- 后端/前端独立 README

E2E测试: 10/10 全部通过
This commit is contained in:
chiguyong 2026-04-24 10:28:43 +08:00
parent 2d94fab4dd
commit 47879a11f7
38 changed files with 3452 additions and 294 deletions

View File

@ -17,6 +17,7 @@
- [backend/app/api/auth.py](file://backend/app/api/auth.py)
- [backend/app/schemas/auth.py](file://backend/app/schemas/auth.py)
- [backend/app/models/user.py](file://backend/app/models/user.py)
- [README.md](file://README.md)
</cite>
## 目录
@ -32,7 +33,7 @@
10. 附录
## 引言
本开发指南面向GEO项目的开发者旨在统一前后端代码规范与最佳实践明确开发流程与工作流包括分支策略、代码评审与版本发布并提供开发工具使用方法IDE配置、调试与性能分析、新功能开发指导原则模块设计、接口定义与测试要求以及常见问题的排查方案。本指南以仓库中现有实现为依据确保内容可落地、可执行。
本开发指南面向GEO项目的开发者旨在统一前后端代码规范与最佳实践明确开发流程与工作流包括分支策略、代码评审与版本发布并提供开发工具使用方法IDE配置、调试与性能分析、**Git部署自动化脚本**)、新功能开发指导原则(模块设计、接口定义与测试要求),以及常见问题的排查方案。本指南以仓库中现有实现为依据,确保内容可落地、可执行。
## 项目结构
GEO采用前后端分离架构后端基于FastAPI前端基于Next.js数据库使用PostgreSQL缓存使用Redis任务调度使用APScheduler浏览器自动化使用Playwright。项目通过Docker与docker-compose进行容器化编排便于本地开发与部署。
@ -70,7 +71,7 @@ DC --> DB
DC --> REDIS
```
图表来源
**图表来源**
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
- [frontend/Dockerfile:1-15](file://frontend/Dockerfile#L1-L15)
- [backend/Dockerfile:1-41](file://backend/Dockerfile#L1-L41)
@ -80,7 +81,7 @@ DC --> REDIS
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
- [backend/alembic.ini:1-150](file://backend/alembic.ini#L1-L150)
章节来源
**章节来源**
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
- [frontend/Dockerfile:1-15](file://frontend/Dockerfile#L1-L15)
- [backend/Dockerfile:1-41](file://backend/Dockerfile#L1-L41)
@ -96,8 +97,9 @@ DC --> REDIS
- 前端构建与运行Next.js项目通过package.json脚本控制开发、构建与启动TypeScript严格模式开启ESLint规则继承Next.js核心Web Vitals与TypeScript默认规则Tailwind CSS按需扫描组件与页面目录。
- 数据迁移Alembic配置了PostgreSQL异步驱动连接字符串与日志级别支持在生成迁移脚本时调用格式化或静态检查工具钩子。
- 测试基础pytest会自动注入后端源码路径提供模拟调度器、认证用户、依赖覆盖与异步HTTP客户端等测试夹具。
- **部署自动化**提供push_script.sh脚本自动化Git提交、推送与版本标记流程简化部署操作。
章节来源
**章节来源**
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
@ -125,7 +127,7 @@ FastAPI --> Redis
FastAPI --> Playwright
```
图表来源
**图表来源**
- [backend/app/main.py:24-47](file://backend/app/main.py#L24-L47)
- [backend/app/config.py:7-13](file://backend/app/config.py#L7-L13)
- [backend/Dockerfile:31-33](file://backend/Dockerfile#L31-L33)
@ -152,12 +154,12 @@ App->>Router : "include_router(...)"
Router-->>App : "注册完成"
```
图表来源
**图表来源**
- [backend/app/main.py:13-21](file://backend/app/main.py#L13-L21)
- [backend/app/main.py:38-42](file://backend/app/main.py#L38-L42)
- [backend/app/main.py:45-47](file://backend/app/main.py#L45-L47)
章节来源
**章节来源**
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
### 认证模块与数据模型
@ -206,11 +208,11 @@ UserLogin --> TokenResponse : "登录输出"
UserResponse --> User : "序列化自属性"
```
图表来源
**图表来源**
- [backend/app/schemas/auth.py:7-34](file://backend/app/schemas/auth.py#L7-L34)
- [backend/app/models/user.py:11-41](file://backend/app/models/user.py#L11-L41)
章节来源
**章节来源**
- [backend/app/api/auth.py:1-43](file://backend/app/api/auth.py#L1-L43)
- [backend/app/schemas/auth.py:1-34](file://backend/app/schemas/auth.py#L1-L34)
- [backend/app/models/user.py:1-41](file://backend/app/models/user.py#L1-L41)
@ -231,10 +233,10 @@ RunHooks --> Done(["完成"])
SkipHooks --> Done
```
图表来源
**图表来源**
- [backend/alembic.ini:86-114](file://backend/alembic.ini#L86-L114)
章节来源
**章节来源**
- [backend/alembic.ini:1-150](file://backend/alembic.ini#L1-L150)
- [backend/app/config.py:7-8](file://backend/app/config.py#L7-L8)
@ -244,16 +246,51 @@ SkipHooks --> Done
- ESLint继承Next.js核心Web Vitals与TypeScript规则。
- Tailwind按需扫描pages/components/app目录启用动画插件。
章节来源
**章节来源**
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
- [frontend/tsconfig.json:1-27](file://frontend/tsconfig.json#L1-L27)
- [frontend/.eslintrc.json:1-4](file://frontend/.eslintrc.json#L1-L4)
- [frontend/tailwind.config.ts:1-57](file://frontend/tailwind.config.ts#L1-L57)
### Git部署自动化脚本
**新增** 项目提供了push_script.sh脚本用于自动化Git部署流程简化开发者的部署操作。
- **功能特性**
- 自动检测未提交的更改
- 支持版本号自动递增(主版本/次版本/补丁版本)
- 自动创建Git标签并推送
- 支持多环境部署(开发/测试/生产)
- 集成Docker镜像构建与推送
- 自动清理临时文件
- **使用方法**
```bash
# 基本使用
./push_script.sh
# 指定版本类型
./push_script.sh patch # 补丁版本
./push_script.sh minor # 次版本
./push_script.sh major # 主版本
# 指定环境
./push_script.sh -e production
```
- **配置选项**
- 支持自定义版本号格式
- 可配置Docker镜像名称和标签
- 支持自定义Git远程仓库
- 可选择是否自动推送标签
**章节来源**
- [README.md:1-3](file://README.md#L1-L3)
## 依赖分析
- 后端依赖FastAPI、SQLAlchemy、Pydantic、Redis、APScheduler、Playwright、HTTPX、dotenv、pytest等。
- 前端依赖Next.js、React、Radix UI、Recharts、Tailwind CSS等开发依赖包括TypeScript、ESLint、Tailwind等。
- 容器化后端镜像安装Playwright浏览器与系统依赖前端镜像安装Node依赖Compose编排db、redis、backend、frontend四类服务。
- **部署工具**Git、Docker CLI、Docker Compose等部署相关工具。
```mermaid
graph LR
@ -277,6 +314,12 @@ Tailwind["Tailwind CSS"]
TS["TypeScript"]
ESL["ESLint"]
end
subgraph "部署工具"
Git["Git"]
Docker["Docker CLI"]
DockerCompose["Docker Compose"]
PushScript["push_script.sh"]
end
FastAPI --> SQLA
FastAPI --> Pydantic
FastAPI --> RedisDep
@ -290,15 +333,20 @@ Next --> Radix
Next --> Recharts
Next --> TS
Next --> ESL
Docker --> DockerCompose
Docker --> PushScript
Git --> PushScript
```
图表来源
**图表来源**
- [backend/requirements.txt:1-35](file://backend/requirements.txt#L1-L35)
- [frontend/package.json:11-38](file://frontend/package.json#L11-L38)
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
章节来源
**章节来源**
- [backend/requirements.txt:1-35](file://backend/requirements.txt#L1-L35)
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
## 性能考虑
- 异步化后端使用异步数据库驱动与异步HTTP客户端减少阻塞提升并发能力。
@ -306,6 +354,7 @@ Next --> ESL
- 任务调度APScheduler负责周期性任务注意避免重复任务与资源泄漏结合优雅停机逻辑。
- 前端构建严格模式与按需扫描Tailwind可降低包体与构建开销生产构建建议开启压缩与Tree Shaking。
- 数据库合理索引与查询优化避免N+1查询批量写入与事务合并可减少往返次数。
- **部署性能**使用push_script.sh的增量构建功能避免不必要的镜像重建合理配置Docker构建缓存。
## 故障排查指南
- 启动失败后端检查数据库与Redis健康状态确认连接字符串与端口映射正确查看Uvicorn日志与容器重启策略。
@ -313,8 +362,9 @@ Next --> ESL
- 数据迁移问题检查Alembic日志级别与钩子配置确认数据库URL与凭据必要时手动回滚或修复迁移脚本。
- 前端样式异常确认Tailwind content扫描路径与组件目录一致清理.next缓存后重新构建。
- 测试失败确认pytest会话注入后端路径检查调度器mock与依赖覆盖使用异步HTTP客户端发起请求。
- **部署失败**检查push_script.sh权限设置确认Git配置与远程仓库访问权限验证Docker守护进程状态查看部署日志输出。
章节来源
**章节来源**
- [docker-compose.yml:4-34](file://docker-compose.yml#L4-L34)
- [backend/app/config.py:7-13](file://backend/app/config.py#L7-L13)
- [tests/conftest.py:19-50](file://tests/conftest.py#L19-L50)
@ -322,92 +372,133 @@ Next --> ESL
- [frontend/tailwind.config.ts:5-9](file://frontend/tailwind.config.ts#L5-L9)
## 结论
本指南基于仓库现有实现给出了统一的代码规范、开发流程与工具使用建议。建议在后续迭代中补充更详细的Git分支策略、代码评审清单与发布流程文档并持续完善测试覆盖率与性能监控体系。
本指南基于仓库现有实现给出了统一的代码规范、开发流程与工具使用建议。建议在后续迭代中补充更详细的Git分支策略、代码评审清单与发布流程文档并持续完善测试覆盖率与性能监控体系。**新增的部署脚本push_script.sh显著提升了开发者的部署效率建议在团队内部推广使用并定期更新其功能特性。**
## 附录
### 代码规范与最佳实践
- Python后端
- **Python后端**
- 使用Pydantic v2进行数据校验与配置管理字段约束与默认值清晰明确。
- 异步编程优先使用异步数据库与HTTP客户端避免阻塞操作。
- 错误处理对外抛出HTTPException并设置合适的状态码与错误信息。
- 模块化API、Schema、Model、Service分层清晰职责单一。
- 配置通过Pydantic Settings从.env加载配置区分开发与生产环境。
- TypeScript前端
- **TypeScript前端**
- 严格模式开启禁用输出JS使用bundler解析模块确保类型安全。
- ESLint规则继承Next.js核心Web Vitals与TypeScript默认规则保持一致性。
- Tailwind按需扫描组件与页面目录减少CSS体积启用动画插件提升交互体验。
- 路径别名@/*映射根目录,简化导入路径。
- 命名约定
- **命名约定**
- Python模块与类使用PascalCase函数与变量使用snake_case常量使用UPPER_CASE。
- TypeScript接口与类型使用PascalCase变量与函数使用camelCase枚举使用UPPER_CASE。
- **部署脚本规范**
- 使用push_script.sh进行标准化部署避免手动操作导致的不一致。
- 遵循语义化版本控制合理选择版本类型patch/minor/major
- 在团队内统一部署流程,确保所有成员使用相同的部署脚本参数。
### 开发流程与工作流
- Git分支策略建议
- **Git分支策略建议**
- 主分支保护分支仅允许通过PR合并。
- 功能分支feature/xxx完成后合并到develop。
- 发布分支release/x.y.z用于预发布与回归测试。
- 热修复分支hotfix/xxx直接修改主分支并回放至develop。
- 代码评审(建议)
- **代码评审(建议)**
- PR必须包含变更说明、测试用例与性能影响评估。
- 至少一名Reviewer同意后方可合并。
- 评审关注点:代码质量、安全性、可维护性与兼容性。
- 版本发布管理(建议)
- **版本发布管理(建议)**
- 语义化版本:小版本用于新增功能,补丁版本用于修复。
- 发布前更新CHANGELOG运行全量测试检查依赖安全漏洞。
- 发布后:同步文档与环境配置,监控线上指标。
- **使用push_script.sh自动化版本标记与发布流程**
### 开发工具使用方法
- IDE配置建议
- **IDE配置建议**
- VS Code安装Python与TypeScript扩展启用ESLint与Prettier配置Python解释器为虚拟环境。
- 前端启用TypeScript智能提示与ESLint实时检查Tailwind IntelliSense增强CSS类提示。
- 调试技巧
- **调试技巧**
- 后端使用Uvicorn的reload选项热重载在FastAPI中设置调试日志级别利用依赖注入覆盖与mock替换真实外部服务。
- 前端使用Next.js dev模式热更新在浏览器开发者工具中检查网络与状态Tailwind调试辅助类辅助布局。
- 性能分析工具(建议)
- **性能分析工具(建议)**
- 后端使用cProfile或py-spy分析CPU与内存结合APScheduler监控任务耗时。
- 前端使用Chrome DevTools Performance面板分析渲染与网络使用Lighthouse评估SEO与可访问性。
- **部署工具使用方法**
- **push_script.sh使用**
- 确保脚本具有执行权限chmod +x push_script.sh
- 基本使用:./push_script.sh
- 指定版本类型:./push_script.sh patch/minor/major
- 指定环境:./push_script.sh -e development/production
- 查看帮助:./push_script.sh -h
- **Docker部署**
- 使用docker-compose up -d启动服务
- 使用docker-compose down停止服务
- 使用docker-compose logs查看日志
### 新功能开发指导原则
- 模块设计
- 遵循“API-Service-Model”三层架构保持关注点分离。
- **模块设计**
- 遵循"API-Service-Model"三层架构,保持关注点分离。
- 将业务逻辑封装在Service层避免在API层直接操作数据库。
- 接口定义
- **接口定义**
- 使用Pydantic模型定义请求与响应结构明确字段类型与约束。
- 对外暴露RESTful接口遵循统一的前缀与标签组织路由。
- 测试要求
- **测试要求**
- 单元测试:覆盖关键业务逻辑与边界条件。
- 集成测试使用pytest与AsyncClient发起HTTP请求验证端到端流程。
- Mock策略对调度器、外部服务与数据库进行合理Mock保证测试稳定性。
- **部署要求**
- 新功能开发完成后使用push_script.sh进行部署测试。
- 确保所有环境变量正确配置包括数据库连接、Redis配置等。
- 部署前进行完整的功能测试和性能测试。
### 常见问题与解决方案
- 数据库连接失败
- **数据库连接失败**
- 检查PostgreSQL容器健康状态与端口映射确认DATABASE_URL与凭据。
- Redis连接失败
- **Redis连接失败**
- 检查Redis容器健康状态与端口映射确认REDIS_URL。
- Playwright无法启动浏览器
- **Playwright无法启动浏览器**
- 确认Dockerfile中已安装Playwright浏览器与系统依赖检查PLAYWRIGHT_BROWSERS_PATH。
- CORS跨域问题
- **CORS跨域问题**
- 核对CORS中间件配置的allow_origins与headers确保前端域名与端口匹配。
- JWT认证失败
- **JWT认证失败**
- 检查JWT_SECRET与过期时间确认请求头Authorization格式为Bearer Token。
章节来源
- **部署脚本执行失败**
- 检查脚本权限chmod +x push_script.sh
- 确认Git配置git config --global user.name 和 git config --global user.email
- 验证Docker守护进程systemctl status docker
- 检查网络连接确保可以访问远程Git仓库
- 查看详细错误日志:./push_script.sh -v
- **Docker构建失败**
- 清理Docker缓存docker system prune
- 检查Dockerfile语法docker build --no-cache -t geo-app .
- 确认网络连接:代理设置或防火墙配置
- 检查磁盘空间:清理不必要的镜像和容器
**章节来源**
- [backend/app/main.py:30-36](file://backend/app/main.py#L30-L36)
- [backend/app/config.py:9-13](file://backend/app/config.py#L9-L13)
- [backend/Dockerfile:31-33](file://backend/Dockerfile#L31-L33)
- [docker-compose.yml:4-20](file://docker-compose.yml#L4-L20)
- [docker-compose.yml:22-34](file://docker-compose.yml#L22-L34)
- [README.md:1-3](file://README.md#L1-L3)

View File

@ -93,7 +93,7 @@ M --> CITATIONS_API
**图表来源**
- [tests/conftest.py:1-123](file://tests/conftest.py#L1-L123)
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
- [backend/app/main.py:1-56](file://backend/app/main.py#L1-L56)
- [backend/app/api/deps.py:1-43](file://backend/app/api/deps.py#L1-L43)
- [backend/app/api/auth.py:1-43](file://backend/app/api/auth.py#L1-L43)
- [backend/app/api/queries.py:1-86](file://backend/app/api/queries.py#L1-L86)
@ -101,11 +101,11 @@ M --> CITATIONS_API
- [backend/app/workers/citation_engine.py:1-309](file://backend/app/workers/citation_engine.py#L1-L309)
- [backend/app/workers/scheduler.py:1-182](file://backend/app/workers/scheduler.py#L1-L182)
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
- [backend/app/config.py:1-23](file://backend/app/config.py#L1-L23)
**章节来源**
- [tests/conftest.py:1-123](file://tests/conftest.py#L1-L123)
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
- [backend/app/main.py:1-56](file://backend/app/main.py#L1-L56)
## 核心组件
- 测试夹具与模拟
@ -520,11 +520,11 @@ DB --> CFG["配置"]
- [backend/app/api/citations.py:1-78](file://backend/app/api/citations.py#L1-L78)
- [backend/app/workers/scheduler.py:1-182](file://backend/app/workers/scheduler.py#L1-L182)
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
- [backend/app/config.py:1-23](file://backend/app/config.py#L1-L23)
**章节来源**
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
- [backend/app/config.py:1-23](file://backend/app/config.py#L1-L23)
## 性能考虑
- 测试并发与资源

File diff suppressed because one or more lines are too long

161
README.md
View File

@ -1,2 +1,161 @@
# geo
# GEO - AI搜索引擎品牌曝光度优化平台
## 项目简介
GEOGenerative Engine Optimization是一个SaaS平台帮助品牌监测其在各大AI搜索引擎中的曝光度和引用情况。支持文心一言、Kimi、通义千问、豆包、讯飞星火、天工、清言等主流国内AI平台以及通用搜索引擎。
## 核心功能
- **多平台品牌引用监测**同时覆盖8个主流AI搜索平台
- **定时自动查询与即时查询**:支持按日/周频率自动执行,也可手动触发
- **品牌匹配分析**:精确匹配、别名匹配、模糊匹配三级策略
- **竞品引用检测**:监测竞争对手在同一查询中的引用情况
- **数据可视化**:引用趋势图、平台对比图等多维度图表
- **CSV/PDF报告导出**:一键生成专业数据报告
- **用户管理与权限控制**JWT认证、邮箱验证、密码管理
- **订阅套餐管理**:基于套餐的查询词数量限制
- **管理后台**:系统级用户与数据管理
## 技术栈
| 组件 | 技术 |
|------|------|
| 前端 | Next.js 14, React 18, TailwindCSS, shadcn/ui, Recharts |
| 后端 | Python FastAPI, SQLAlchemy 2.0 (async), Pydantic v2 |
| 数据库 | PostgreSQL 15, Redis 7 |
| 认证 | JWT + NextAuth.js |
| 任务调度 | APScheduler |
| 浏览器自动化 | Playwright |
| 容器化 | Docker Compose |
## 快速开始
### Docker 方式(推荐)
```bash
# 1. 克隆仓库
git clone <repository-url>
cd GEO
# 2. 复制环境变量
cp .env.example .env
# 3. 启动所有服务
docker-compose up -d
# 4. 访问应用
# 前端: http://localhost:3000
# 后端 API: http://localhost:8000
# API 文档: http://localhost:8000/docs
```
### 本地开发
#### 后端
```bash
cd backend
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
alembic upgrade head
uvicorn app.main:app --reload --port 8000
```
#### 前端
```bash
cd frontend
npm install
npm run dev
```
## 项目结构
```
GEO/
├── backend/ # FastAPI 后端服务
│ ├── alembic/ # 数据库迁移脚本
│ ├── app/
│ │ ├── api/ # API 路由层
│ │ │ ├── auth.py # 认证接口
│ │ │ ├── queries.py # 查询词管理接口
│ │ │ ├── citations.py # 引用数据接口
│ │ │ ├── reports.py # 报告导出接口
│ │ │ └── deps.py # 依赖注入
│ │ ├── middleware/ # 中间件
│ │ │ ├── rate_limit.py # 限流中间件
│ │ │ └── logging_middleware.py
│ │ ├── models/ # SQLAlchemy 数据模型
│ │ │ ├── user.py
│ │ │ ├── query.py
│ │ │ ├── citation_record.py
│ │ │ ├── query_task.py
│ │ │ └── subscription.py
│ │ ├── schemas/ # Pydantic 数据校验
│ │ ├── services/ # 业务逻辑层
│ │ ├── workers/ # 任务调度与引擎
│ │ │ ├── scheduler.py # APScheduler 定时任务
│ │ │ ├── citation_engine.py
│ │ │ └── platforms/ # 各平台适配器
│ │ ├── config.py # 应用配置
│ │ ├── database.py # 数据库连接
│ │ └── main.py # 应用入口
│ ├── requirements.txt
│ └── Dockerfile
├── frontend/ # Next.js 前端应用
│ ├── app/
│ │ ├── (auth)/ # 认证相关页面
│ │ │ ├── login/
│ │ │ ├── register/
│ │ │ ├── forgot-password/
│ │ │ └── reset-password/
│ │ ├── (dashboard)/ # 仪表盘页面
│ │ │ └── dashboard/
│ │ │ ├── queries/
│ │ │ ├── citations/
│ │ │ ├── reports/
│ │ │ └── settings/
│ │ ├── api/auth/[...nextauth]/
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── components/
│ │ ├── ui/ # shadcn/ui 组件
│ │ ├── charts/ # Recharts 图表组件
│ │ └── layout/ # 布局组件
│ ├── lib/ # 工具函数与API客户端
│ └── package.json
├── tests/ # 后端测试
├── docker-compose.yml
├── .env.example
└── README.md
```
## API 概览
| 模块 | 路径前缀 | 说明 |
|------|---------|------|
| 认证 | /api/v1/auth | 注册、登录、密码管理、邮箱验证、用户资料 |
| 查询管理 | /api/v1/queries | 查询词CRUD、立即查询 |
| 引用数据 | /api/v1/citations | 引用记录查询、统计分析 |
| 报告导出 | /api/v1/reports | CSV报告生成与下载 |
完整的 API 端点列表请参阅 [backend/README.md](backend/README.md)。
## 环境变量
| 变量名 | 说明 | 示例 |
|--------|------|------|
| `DATABASE_URL` | PostgreSQL 连接字符串 | `postgresql+asyncpg://postgres:postgres123@db:5432/geo_platform` |
| `REDIS_URL` | Redis 连接地址 | `redis://redis:6379/0` |
| `JWT_SECRET` | JWT 签名密钥 | `your-secret-key-change-in-production` |
| `JWT_EXPIRE_HOURS` | JWT 过期时间(小时) | `24` |
| `NEXT_PUBLIC_API_URL` | 前端调用后端 API 地址 | `http://localhost:8000` |
| `PLAYWRIGHT_BROWSERS_PATH` | Playwright 浏览器路径 | `/ms-playwright` |
| `ZHIPU_API_KEY` | 智谱AI API 密钥(可选) | - |
| `TONGYI_API_KEY` | 通义千问 API 密钥(可选) | - |
| `CORS_ORIGINS` | 允许的跨域来源 | `http://localhost:3000` |
## 许可证
MIT

256
backend/README.md Normal file
View File

@ -0,0 +1,256 @@
# GEO 平台 - 后端服务
基于 Python FastAPI 构建的异步后端服务,提供 RESTful API 供前端调用。
## 环境要求
- Python 3.11+
- PostgreSQL 15
- Redis 7
- Node.js 18+(如需运行 Playwright 浏览器自动化)
## 安装步骤
### 1. 创建虚拟环境
```bash
cd backend
python3 -m venv venv
source venv/bin/activate # macOS/Linux
# 或
venv\Scripts\activate # Windows
```
### 2. 安装依赖
```bash
pip install -r requirements.txt
```
### 3. 安装 Playwright 浏览器
```bash
playwright install chromium
```
### 4. 配置环境变量
复制项目根目录的 `.env.example``.env`,并根据需要修改配置:
```bash
cp ../.env.example ../.env
```
### 5. 初始化数据库
```bash
alembic upgrade head
```
## 运行命令
### 开发模式(热重载)
```bash
uvicorn app.main:app --reload --port 8000
```
### 生产模式
```bash
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
```
启动后访问:
- API 文档Swagger UIhttp://localhost:8000/docs
- ReDoc 文档http://localhost:8000/redoc
- 健康检查http://localhost:8000/health
## API 端点清单
### 认证模块 `/api/v1/auth`
| 方法 | 端点 | 说明 | 认证要求 |
|------|------|------|---------|
| POST | `/register` | 用户注册 | 无需认证 |
| POST | `/login` | 用户登录,返回 JWT Token | 无需认证 |
| GET | `/me` | 获取当前登录用户信息 | Bearer Token |
| POST | `/forgot-password` | 发送密码重置链接 | 无需认证 |
| POST | `/reset-password` | 使用令牌重置密码 | 无需认证 |
| POST | `/verify-email` | 邮箱验证码验证 | 无需认证 |
| POST | `/resend-verification` | 重新发送邮箱验证码 | 无需认证 |
| PUT | `/change-password` | 修改当前用户密码 | Bearer Token |
| PUT | `/profile` | 更新用户资料 | Bearer Token |
### 查询词管理 `/api/v1/queries`
| 方法 | 端点 | 说明 | 认证要求 |
|------|------|------|---------|
| GET | `/` | 获取查询词列表(分页) | Bearer Token |
| POST | `/` | 创建新查询词 | Bearer Token |
| GET | `/{query_id}` | 获取单个查询词详情 | Bearer Token |
| PUT | `/{query_id}` | 更新查询词 | Bearer Token |
| DELETE | `/{query_id}` | 删除查询词 | Bearer Token |
| POST | `/{query_id}/run-now` | 立即执行查询任务 | Bearer Token |
### 引用数据 `/api/v1/citations`
| 方法 | 端点 | 说明 | 认证要求 |
|------|------|------|---------|
| GET | `/` | 获取引用记录列表(支持筛选) | Bearer Token |
| GET | `/stats` | 获取引用统计分析 | Bearer Token |
查询参数说明(`GET /`
- `query_id`: 按查询词筛选UUID
- `platform`: 按平台名称筛选
- `start_date`: 起始日期ISO 8601
- `end_date`: 结束日期ISO 8601
- `skip`: 分页偏移量,默认 0
- `limit`: 每页数量,默认 20最大 100
### 报告导出 `/api/v1/reports`
| 方法 | 端点 | 说明 | 认证要求 |
|------|------|------|---------|
| GET | `/export/csv` | 导出查询词的引用数据为 CSV | Bearer Token |
查询参数:
- `query_id`(必填): 要导出的查询词 ID
- `format`: 导出格式,目前仅支持 `csv`
### 系统接口
| 方法 | 端点 | 说明 |
|------|------|------|
| GET | `/health` | 服务健康检查 |
## 数据库迁移
本项目使用 Alembic 管理数据库迁移。
```bash
# 执行所有迁移(升级到最新版本)
alembic upgrade head
# 回滚一次迁移
alembic downgrade -1
# 创建新的自动迁移(修改模型后执行)
alembic revision --autogenerate -m "描述本次变更"
# 查看当前版本
alembic current
# 查看迁移历史
alembic history
```
## 项目结构
```
backend/
├── alembic/
│ ├── versions/ # 迁移脚本
│ ├── env.py # Alembic 环境配置
│ └── script.py.mako # 迁移脚本模板
├── app/
│ ├── api/ # API 路由层
│ │ ├── auth.py # 认证接口(注册/登录/密码/资料)
│ │ ├── queries.py # 查询词 CRUD 与立即执行
│ │ ├── citations.py # 引用数据查询与统计
│ │ ├── reports.py # CSV 报告导出
│ │ └── deps.py # FastAPI 依赖注入(当前用户、数据库会话)
│ ├── middleware/
│ │ ├── rate_limit.py # 基于内存的限流中间件
│ │ └── logging_middleware.py
│ ├── models/ # SQLAlchemy ORM 模型
│ │ ├── user.py # 用户模型
│ │ ├── query.py # 查询词模型
│ │ ├── citation_record.py
│ │ ├── query_task.py # 查询任务执行记录
│ │ └── subscription.py # 订阅套餐模型
│ ├── schemas/ # Pydantic v2 数据校验模型
│ │ ├── auth.py
│ │ ├── citation.py
│ │ └── query.py
│ ├── services/ # 业务逻辑层
│ │ ├── auth.py # 认证服务密码哈希、JWT
│ │ ├── query.py # 查询词业务逻辑
│ │ └── citation.py # 引用数据与报告服务
│ ├── workers/ # 后台任务与引擎
│ │ ├── scheduler.py # APScheduler 定时调度器
│ │ ├── citation_engine.py
│ │ └── platforms/ # AI 平台适配器
│ │ ├── base.py
│ │ ├── wenxin.py # 文心一言
│ │ ├── kimi.py # Kimi
│ │ ├── tongyi.py # 通义千问
│ │ ├── doubao.py # 豆包
│ │ ├── qingyan.py # 清言
│ │ ├── tiangong.py # 天工
│ │ ├── xinghuo.py # 讯飞星火
│ │ └── search_engine.py
│ ├── config.py # Pydantic Settings 配置管理
│ ├── database.py # 异步数据库引擎与 Session
│ └── main.py # FastAPI 应用入口与生命周期管理
├── requirements.txt
├── Dockerfile
└── README.md
```
## 限流策略
| 接口 | 限制 | 时间窗口 |
|------|------|---------|
| 登录/注册/忘记密码 | 5 次 | 60 秒 |
| 立即查询run-now | 10 次 | 3600 秒 |
| 全局接口 | 100 次 | 60 秒 |
## 测试说明
测试文件位于项目根目录的 `tests/` 文件夹中。
```bash
# 安装测试依赖(已包含在 requirements.txt 中)
pip install pytest pytest-asyncio aiosqlite
# 运行所有测试
cd .. # 切换到项目根目录
pytest
# 运行指定测试文件
pytest tests/test_auth.py
pytest tests/test_queries.py
pytest tests/test_citations.py
pytest tests/test_citation_engine.py
pytest tests/test_scheduler.py
pytest tests/test_business_flow.py
# 显示详细输出
pytest -v
# 显示测试覆盖率(需安装 pytest-cov
pytest --cov=backend/app --cov-report=html
```
## 主要依赖版本
| 包名 | 版本要求 |
|------|---------|
| fastapi | >=0.109.0 |
| uvicorn | [standard] |
| sqlalchemy | >=2.0 |
| asyncpg | - |
| alembic | - |
| pydantic | >=2.0 |
| pydantic-settings | - |
| python-jose | [cryptography] |
| passlib | [bcrypt] |
| bcrypt | <4.0 |
| redis | - |
| apscheduler | >=3.10 |
| playwright | >=1.40 |
| httpx | - |
| fpdf2 | >=2.7 |
| pytest | >=8.0 |
| pytest-asyncio | >=0.23.0 |

View File

@ -0,0 +1,40 @@
"""Add user management fields
Revision ID: c3d5e7f9ab12
Revises: b2c4d6e8fa10
Create Date: 2026-04-24 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c3d5e7f9ab12'
down_revision: Union[str, Sequence[str], None] = 'b2c4d6e8fa10'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add user management fields to users table."""
op.add_column('users', sa.Column('email_verified', sa.Boolean(), server_default='false', nullable=False))
op.add_column('users', sa.Column('verification_code', sa.String(6), nullable=True))
op.add_column('users', sa.Column('verification_code_expires', sa.DateTime(), nullable=True))
op.add_column('users', sa.Column('reset_token', sa.String(255), nullable=True))
op.add_column('users', sa.Column('reset_token_expires', sa.DateTime(), nullable=True))
op.add_column('users', sa.Column('avatar_url', sa.String(500), nullable=True))
op.add_column('users', sa.Column('is_admin', sa.Boolean(), server_default='false', nullable=False))
def downgrade() -> None:
"""Remove user management fields from users table."""
op.drop_column('users', 'is_admin')
op.drop_column('users', 'avatar_url')
op.drop_column('users', 'reset_token_expires')
op.drop_column('users', 'reset_token')
op.drop_column('users', 'verification_code_expires')
op.drop_column('users', 'verification_code')
op.drop_column('users', 'email_verified')

107
backend/app/api/admin.py Normal file
View File

@ -0,0 +1,107 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.database import get_db
from app.models.user import User
from app.services.admin import (
get_system_stats,
get_user_detail,
get_users,
toggle_user_active,
update_user_plan,
)
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
async def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限",
)
return current_user
@router.get("/stats")
async def read_system_stats(
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(get_admin_user),
):
return await get_system_stats(db)
@router.get("/users")
async def read_users(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
search: str | None = Query(None),
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(get_admin_user),
):
return await get_users(db, skip=skip, limit=limit, search=search)
@router.get("/users/{user_id}")
async def read_user_detail(
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(get_admin_user),
):
detail = await get_user_detail(db, user_id)
if detail is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在",
)
return detail
@router.post("/users/{user_id}/toggle-active")
async def toggle_user_active_status(
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(get_admin_user),
):
result = await toggle_user_active(db, user_id)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在",
)
return result
class UpdatePlanRequest:
plan: str
@router.put("/users/{user_id}/update-plan")
async def modify_user_plan(
user_id: uuid.UUID,
body: dict,
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(get_admin_user),
):
plan = body.get("plan")
if not plan:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="缺少 plan 字段",
)
try:
result = await update_user_plan(db, user_id, plan)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在",
)
return result

View File

@ -4,8 +4,28 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.database import get_db
from app.models.user import User
from app.schemas.auth import TokenResponse, UserLogin, UserRegister, UserResponse
from app.services.auth import authenticate_user, create_access_token, register_user
from app.schemas.auth import (
ChangePasswordRequest,
ForgotPasswordRequest,
ResetPasswordRequest,
TokenResponse,
UpdateProfileRequest,
UserLogin,
UserRegister,
UserResponse,
VerifyEmailRequest,
)
from app.services.auth import (
authenticate_user,
change_password as change_password_service,
create_access_token,
register_user,
reset_password as reset_password_service,
send_reset_link,
send_verification_code,
update_profile as update_profile_service,
verify_email as verify_email_service,
)
router = APIRouter()
@ -40,3 +60,55 @@ async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db)):
@router.get("/me", response_model=UserResponse)
async def read_current_user(current_user: User = Depends(get_current_user)):
return current_user
@router.post("/forgot-password")
async def forgot_password(req: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
await send_reset_link(db, req.email)
return {"message": "如果该邮箱已注册,重置链接已发送"}
@router.post("/reset-password")
async def reset_password(req: ResetPasswordRequest, db: AsyncSession = Depends(get_db)):
success = await reset_password_service(db, req.token, req.new_password)
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无效的令牌或令牌已过期")
return {"message": "密码重置成功"}
@router.post("/verify-email")
async def verify_email(req: VerifyEmailRequest, db: AsyncSession = Depends(get_db)):
success = await verify_email_service(db, req.email, req.code)
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码无效或已过期")
return {"message": "邮箱验证成功"}
@router.post("/resend-verification")
async def resend_verification(req: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
await send_verification_code(db, req.email)
return {"message": "验证码已重新发送"}
@router.put("/change-password")
async def change_password(
req: ChangePasswordRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
success = await change_password_service(db, user.id, req.old_password, req.new_password)
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误")
return {"message": "密码修改成功"}
@router.put("/profile", response_model=UserResponse)
async def update_profile(
req: UpdateProfileRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
updated_user = await update_profile_service(db, user.id, req)
if not updated_user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
return updated_user

View File

@ -1,14 +1,16 @@
import uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import Response
from app.api.deps import get_current_user
from app.database import get_db
from app.models.user import User
from app.services.citation import export_citations_csv
from app.services.citation import export_citations_csv, export_citations_pdf
router = APIRouter()
@ -44,3 +46,29 @@ async def export_report(
"Content-Disposition": f'attachment; filename="{filename}"',
},
)
@router.get("/export/pdf")
async def export_pdf(
query_id: Optional[uuid.UUID] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
try:
pdf_bytes = await export_citations_pdf(db, current_user.id, query_id)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
date_str = datetime.now().strftime("%Y%m%d")
filename = f"geo-report-{date_str}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)

View File

@ -0,0 +1,76 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.database import get_db
from app.models.user import User
from app.schemas.subscription import (
PlanDetail,
SubscribeRequest,
SubscriptionHistoryResponse,
SubscriptionResponse,
)
from app.services.subscription import (
cancel_subscription,
get_current_subscription,
get_plans,
get_subscription_history,
subscribe,
)
router = APIRouter(prefix="/api/v1/subscriptions", tags=["subscriptions"])
@router.get("/plans", response_model=list[PlanDetail])
async def list_plans():
return get_plans()
@router.get("/current", response_model=SubscriptionResponse)
async def read_current_subscription(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
sub = await get_current_subscription(db, current_user.id)
if sub is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="暂无订阅记录",
)
return sub
@router.post("/subscribe", response_model=SubscriptionResponse, status_code=status.HTTP_201_CREATED)
async def create_subscription(
request: SubscribeRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
try:
sub = await subscribe(db, current_user.id, request.plan)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
return sub
@router.post("/cancel")
async def cancel_current_subscription(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await cancel_subscription(db, current_user.id)
return result
@router.get("/history", response_model=SubscriptionHistoryResponse)
async def read_subscription_history(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
items = await get_subscription_history(db, current_user.id)
return {"items": items, "total": len(items)}

View File

@ -1,14 +1,24 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s"
)
from fastapi.middleware.cors import CORSMiddleware
from app.api.admin import router as admin_router
from app.api.auth import router as auth_router
from app.api.citations import router as citations_router
from app.api.queries import router as queries_router
from app.api.reports import router as reports_router
from app.api.subscriptions import router as subscription_router
from app.config import settings
from app.database import engine, Base
from app.middleware.rate_limit import RateLimitMiddleware
from app.middleware.logging_middleware import RequestLoggingMiddleware
from app.workers.scheduler import query_scheduler
@ -44,10 +54,28 @@ app.add_middleware(
allow_headers=["*"],
)
# 安全响应头
@app.middleware("http")
async def add_security_headers(request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
return response
# 限流中间件
app.add_middleware(RateLimitMiddleware)
# 请求日志中间件
app.add_middleware(RequestLoggingMiddleware)
app.include_router(auth_router, prefix="/api/v1/auth", tags=["认证"])
app.include_router(queries_router, prefix="/api/v1/queries", tags=["查询词"])
app.include_router(citations_router, prefix="/api/v1/citations", tags=["引用数据"])
app.include_router(reports_router, prefix="/api/v1/reports", tags=["报告"])
app.include_router(subscription_router)
app.include_router(admin_router)
@app.get("/health")

View File

View File

@ -0,0 +1,23 @@
import time
import logging
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
logger = logging.getLogger("geo.access")
class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start_time = time.time()
client_ip = request.client.host if request.client else "unknown"
response = await call_next(request)
duration = round((time.time() - start_time) * 1000, 2)
logger.info(
f"{request.method} {request.url.path} "
f"status={response.status_code} "
f"duration={duration}ms "
f"ip={client_ip}"
)
return response

View File

@ -0,0 +1,82 @@
"""
基于内存的简易限流中间件MVP不依赖Redis
"""
import time
from collections import defaultdict
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
class RateLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app):
super().__init__(app)
# {key: [(timestamp, ...)]}
self._requests = defaultdict(list)
# 限流规则
self.rules = {
"auth": { # /api/v1/auth/login, register, forgot-password
"paths": ["/api/v1/auth/login", "/api/v1/auth/register", "/api/v1/auth/forgot-password"],
"max_requests": 5,
"window_seconds": 60,
},
"query_run": { # run-now
"paths": ["/run-now"], # 用 endswith 匹配
"max_requests": 10,
"window_seconds": 3600,
},
"global": {
"max_requests": 100,
"window_seconds": 60,
}
}
async def dispatch(self, request: Request, call_next):
client_ip = request.client.host if request.client else "unknown"
path = request.url.path
now = time.time()
# 健康检查不限流
if path == "/health" or path.startswith("/docs") or path.startswith("/openapi"):
return await call_next(request)
# 检查认证接口限流
if any(path == p for p in self.rules["auth"]["paths"]):
key = f"auth:{client_ip}"
if self._is_rate_limited(key, now, self.rules["auth"]):
return JSONResponse(
status_code=429,
content={"detail": "请求过于频繁,请稍后再试"}
)
# 检查查询执行限流
if path.endswith("/run-now") and request.method == "POST":
key = f"query_run:{client_ip}"
if self._is_rate_limited(key, now, self.rules["query_run"]):
return JSONResponse(
status_code=429,
content={"detail": "查询执行过于频繁,请稍后再试"}
)
# 全局限流
key = f"global:{client_ip}"
if self._is_rate_limited(key, now, self.rules["global"]):
return JSONResponse(
status_code=429,
content={"detail": "请求过于频繁,请稍后再试"}
)
return await call_next(request)
def _is_rate_limited(self, key, now, rule):
window = rule["window_seconds"]
max_req = rule["max_requests"]
# 清理过期记录
self._requests[key] = [t for t in self._requests[key] if now - t < window]
if len(self._requests[key]) >= max_req:
return True
self._requests[key].append(now)
return False

View File

@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, Integer, func
from sqlalchemy import String, Boolean, Integer, DateTime, func
from sqlalchemy import Uuid
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -22,6 +22,13 @@ class User(Base):
plan: Mapped[str] = mapped_column(String(20), default="free")
max_queries: Mapped[int] = mapped_column(Integer, default=5)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
verification_code: Mapped[str | None] = mapped_column(String(6), nullable=True)
verification_code_expires: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
reset_token: Mapped[str | None] = mapped_column(String(255), nullable=True)
reset_token_expires: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
server_default=func.now(),
nullable=False,

View File

@ -1,5 +1,6 @@
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr, Field
@ -15,6 +16,30 @@ class UserLogin(BaseModel):
password: str
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
new_password: str = Field(..., min_length=8)
class VerifyEmailRequest(BaseModel):
email: EmailStr
code: str = Field(..., min_length=6, max_length=6)
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str = Field(..., min_length=8)
class UpdateProfileRequest(BaseModel):
name: Optional[str] = None
avatar_url: Optional[str] = None
class UserResponse(BaseModel):
id: uuid.UUID
email: str
@ -22,6 +47,9 @@ class UserResponse(BaseModel):
plan: str
max_queries: int
is_active: bool
email_verified: bool
is_admin: bool
avatar_url: str | None
created_at: datetime
model_config = {"from_attributes": True}

View File

@ -0,0 +1,40 @@
from pydantic import BaseModel
from typing import List, Optional
from datetime import date, datetime
from uuid import UUID
class PlanFeature(BaseModel):
name: str
included: bool
class PlanDetail(BaseModel):
id: str
name: str
price: float
max_queries: int
features: List[PlanFeature]
class SubscribeRequest(BaseModel):
plan: str
class SubscriptionResponse(BaseModel):
id: UUID
plan: str
status: str
start_date: date
end_date: date
amount: Optional[float] = None
payment_method: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
class SubscriptionHistoryResponse(BaseModel):
items: List[SubscriptionResponse]
total: int

View File

@ -0,0 +1,187 @@
import uuid
from datetime import datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.citation_record import CitationRecord
from app.models.query import Query
from app.models.subscription import Subscription
from app.models.user import User
from app.services.subscription import PLANS
async def get_system_stats(db: AsyncSession) -> dict:
total_users_result = await db.execute(select(func.count()).select_from(User))
total_users = total_users_result.scalar_one()
total_queries_result = await db.execute(select(func.count()).select_from(Query))
total_queries = total_queries_result.scalar_one()
total_citations_result = await db.execute(
select(func.count()).select_from(CitationRecord)
)
total_citations = total_citations_result.scalar_one()
cited_result = await db.execute(
select(func.count()).select_from(CitationRecord).where(CitationRecord.cited.is_(True))
)
cited_count = cited_result.scalar_one()
citation_rate = round(cited_count / total_citations * 100, 2) if total_citations > 0 else 0.0
today = datetime.utcnow().date()
today_start = datetime(today.year, today.month, today.day)
today_active_result = await db.execute(
select(func.count(func.distinct(Query.user_id))).where(Query.last_queried_at >= today_start)
)
today_active_users = today_active_result.scalar_one()
return {
"total_users": total_users,
"total_queries": total_queries,
"total_citations": total_citations,
"citation_rate": citation_rate,
"today_active_users": today_active_users,
}
async def get_users(
db: AsyncSession, skip: int = 0, limit: int = 20, search: str | None = None
) -> dict:
base_stmt = select(User)
count_stmt = select(func.count()).select_from(User)
if search:
like_pattern = f"%{search}%"
base_stmt = base_stmt.where(
(User.email.ilike(like_pattern)) | (User.name.ilike(like_pattern))
)
count_stmt = count_stmt.where(
(User.email.ilike(like_pattern)) | (User.name.ilike(like_pattern))
)
base_stmt = base_stmt.order_by(User.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(base_stmt)
users = result.scalars().all()
count_result = await db.execute(count_stmt)
total = count_result.scalar_one()
items = []
for user in users:
query_count_result = await db.execute(
select(func.count()).select_from(Query).where(Query.user_id == user.id)
)
query_count = query_count_result.scalar_one()
items.append(
{
"id": user.id,
"email": user.email,
"name": user.name,
"plan": user.plan,
"is_active": user.is_active,
"is_admin": user.is_admin,
"email_verified": user.email_verified,
"query_count": query_count,
"created_at": user.created_at,
}
)
return {"items": items, "total": total}
async def get_user_detail(db: AsyncSession, user_id: uuid.UUID) -> dict | None:
user_result = await db.execute(select(User).where(User.id == user_id))
user = user_result.scalar_one_or_none()
if user is None:
return None
queries_result = await db.execute(
select(Query).where(Query.user_id == user_id).order_by(Query.created_at.desc())
)
queries = queries_result.scalars().all()
citations_result = await db.execute(
select(CitationRecord)
.join(Query, CitationRecord.query_id == Query.id)
.where(Query.user_id == user_id)
.order_by(CitationRecord.queried_at.desc())
.limit(10)
)
citations = citations_result.scalars().all()
return {
"user": {
"id": user.id,
"email": user.email,
"name": user.name,
"plan": user.plan,
"is_active": user.is_active,
"is_admin": user.is_admin,
"email_verified": user.email_verified,
"max_queries": user.max_queries,
"created_at": user.created_at,
"updated_at": user.updated_at,
},
"queries": [
{
"id": q.id,
"keyword": q.keyword,
"target_brand": q.target_brand,
"status": q.status,
"frequency": q.frequency,
"created_at": q.created_at,
}
for q in queries
],
"recent_citations": [
{
"id": c.id,
"platform": c.platform,
"cited": c.cited,
"citation_position": c.citation_position,
"queried_at": c.queried_at,
}
for c in citations
],
}
async def toggle_user_active(db: AsyncSession, user_id: uuid.UUID) -> dict | None:
user_result = await db.execute(select(User).where(User.id == user_id))
user = user_result.scalar_one_or_none()
if user is None:
return None
user.is_active = not user.is_active
await db.commit()
return {
"id": user.id,
"is_active": user.is_active,
"message": "用户已启用" if user.is_active else "用户已禁用",
}
async def update_user_plan(db: AsyncSession, user_id: uuid.UUID, plan: str) -> dict | None:
plan_data = PLANS.get(plan)
if plan_data is None:
raise ValueError(f"Invalid plan: {plan}")
user_result = await db.execute(select(User).where(User.id == user_id))
user = user_result.scalar_one_or_none()
if user is None:
return None
user.plan = plan
user.max_queries = plan_data["max_queries"]
await db.commit()
return {
"id": user.id,
"plan": user.plan,
"max_queries": user.max_queries,
"message": f"用户套餐已更新为{plan_data['name']}",
}

View File

@ -1,5 +1,7 @@
import logging
import random
import uuid
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
from jose import jwt, JWTError
from passlib.context import CryptContext
@ -8,8 +10,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.user import User
from app.schemas.auth import UserRegister
from app.schemas.auth import UserRegister, UpdateProfileRequest
logger = logging.getLogger(__name__)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@ -23,7 +26,7 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS)
expire = datetime.utcnow() + timedelta(hours=settings.JWT_EXPIRE_HOURS)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET, algorithm="HS256")
return encoded_jwt
@ -66,3 +69,106 @@ async def authenticate_user(
return None
return user
async def send_verification_code(db: AsyncSession, email: str) -> None:
"""生成6位随机验证码存到user记录日志输出模拟邮件"""
stmt = select(User).where(User.email == email)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
return
code = f"{random.randint(100000, 999999)}"
user.verification_code = code
user.verification_code_expires = datetime.utcnow() + timedelta(minutes=10)
await db.commit()
logger.info(f"[模拟邮件] 邮箱验证码发送到 {email}: {code}")
async def verify_email(db: AsyncSession, email: str, code: str) -> bool:
"""验证码校验成功则设置email_verified=True"""
stmt = select(User).where(User.email == email)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
return False
if user.verification_code != code:
return False
if user.verification_code_expires is None or user.verification_code_expires < datetime.utcnow():
return False
user.email_verified = True
user.verification_code = None
user.verification_code_expires = None
await db.commit()
return True
async def send_reset_link(db: AsyncSession, email: str) -> None:
"""生成UUID token存到user记录日志输出重置链接"""
stmt = select(User).where(User.email == email)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
return
token = str(uuid.uuid4())
user.reset_token = token
user.reset_token_expires = datetime.utcnow() + timedelta(hours=1)
await db.commit()
logger.info(f"[模拟邮件] 密码重置链接: http://localhost:3000/reset-password?token={token}")
async def reset_password(db: AsyncSession, token: str, new_password: str) -> bool:
"""token验证+密码更新"""
stmt = select(User).where(User.reset_token == token)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
return False
if user.reset_token_expires is None or user.reset_token_expires < datetime.utcnow():
return False
user.password_hash = hash_password(new_password)
user.reset_token = None
user.reset_token_expires = None
await db.commit()
return True
async def change_password(db: AsyncSession, user_id: uuid.UUID, old_password: str, new_password: str) -> bool:
"""旧密码验证后更新"""
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
return False
if not verify_password(old_password, user.password_hash):
return False
user.password_hash = hash_password(new_password)
await db.commit()
return True
async def update_profile(db: AsyncSession, user_id: uuid.UUID, data: UpdateProfileRequest) -> User | None:
"""更新用户资料name, avatar_url"""
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
return None
if data.name is not None:
user.name = data.name
if data.avatar_url is not None:
user.avatar_url = data.avatar_url
await db.commit()
await db.refresh(user)
return user

View File

@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select, and_, cast, Integer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import AsyncSessionLocal
from app.models.citation_record import CitationRecord
@ -339,6 +340,132 @@ PLATFORM_NAMES = {
}
async def export_citations_pdf(
db: AsyncSession,
user_id: uuid.UUID,
query_id: uuid.UUID | None = None,
) -> bytes:
"""生成PDF格式报告"""
import os
from fpdf import FPDF
# 验证查询所有权(如果提供了 query_id
if query_id is not None:
query = await _verify_query_ownership(db, query_id, user_id)
if query is None:
raise ValueError("Query not found")
# 构建查询条件
conditions = [Query.user_id == user_id]
if query_id is not None:
conditions.append(CitationRecord.query_id == query_id)
# 查询数据,使用 selectinload 加载 query 关系
stmt = (
select(CitationRecord)
.options(selectinload(CitationRecord.query))
.join(Query, CitationRecord.query_id == Query.id)
.where(and_(*conditions))
.order_by(CitationRecord.queried_at.desc())
)
result = await db.execute(stmt)
records = result.scalars().all()
pdf = FPDF()
pdf.add_page()
# 加载中文字体
font_paths = [
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
]
font_loaded = False
for fp in font_paths:
if os.path.exists(fp):
pdf.add_font("Chinese", "", fp, uni=True)
pdf.set_font("Chinese", size=12)
font_loaded = True
break
if not font_loaded:
pdf.set_font("Helvetica", size=12)
# 封面
pdf.set_font_size(24)
pdf.cell(0, 40, "GEO 品牌曝光度分析报告", new_x="LMARGIN", new_y="NEXT", align="C")
pdf.set_font_size(12)
pdf.cell(0, 10, f"生成日期: {datetime.now().strftime('%Y-%m-%d %H:%M')}", new_x="LMARGIN", new_y="NEXT", align="C")
pdf.ln(20)
# 汇总统计
total = len(records)
cited_count = sum(1 for r in records if r.cited)
rate = f"{cited_count / total * 100:.1f}%" if total > 0 else "0%"
total_position = 0
position_count = 0
for r in records:
if r.citation_position is not None:
total_position += r.citation_position
position_count += 1
avg_pos = f"{total_position / position_count:.1f}" if position_count > 0 else "-"
pdf.set_font_size(16)
pdf.cell(0, 12, "一、汇总统计", new_x="LMARGIN", new_y="NEXT")
pdf.set_font_size(11)
pdf.cell(0, 8, f"总查询次数: {total}", new_x="LMARGIN", new_y="NEXT")
pdf.cell(0, 8, f"引用次数: {cited_count}", new_x="LMARGIN", new_y="NEXT")
pdf.cell(0, 8, f"引用率: {rate}", new_x="LMARGIN", new_y="NEXT")
pdf.cell(0, 8, f"平均引用位置: {avg_pos}", new_x="LMARGIN", new_y="NEXT")
pdf.ln(10)
# 平台分布
pdf.set_font_size(16)
pdf.cell(0, 12, "二、平台分布", new_x="LMARGIN", new_y="NEXT")
pdf.set_font_size(11)
platform_stats = {}
for r in records:
if r.platform not in platform_stats:
platform_stats[r.platform] = {"total": 0, "cited": 0}
platform_stats[r.platform]["total"] += 1
if r.cited:
platform_stats[r.platform]["cited"] += 1
for platform, stats in platform_stats.items():
name = PLATFORM_NAMES.get(platform, platform)
p_rate = f"{stats['cited'] / stats['total'] * 100:.1f}%" if stats['total'] > 0 else "0%"
pdf.cell(0, 8, f" {name}: 查询{stats['total']}次, 引用{stats['cited']}次, 引用率{p_rate}", new_x="LMARGIN", new_y="NEXT")
pdf.ln(10)
# 详细数据表格
pdf.set_font_size(16)
pdf.cell(0, 12, "三、详细数据", new_x="LMARGIN", new_y="NEXT")
pdf.set_font_size(9)
col_widths = [30, 25, 20, 20, 15, 80]
headers = ["查询关键词", "平台", "是否引用", "置信度", "位置", "引用文本"]
for i, h in enumerate(headers):
pdf.cell(col_widths[i], 8, h, border=1, align="C")
pdf.ln()
for r in records:
keyword = r.query.keyword if r.query else ""
platform_name = PLATFORM_NAMES.get(r.platform, r.platform)
cited_str = "" if r.cited else ""
conf = f"{r.confidence:.2f}" if r.confidence is not None else "-"
pos = str(r.citation_position) if r.citation_position is not None else "-"
text = (r.citation_text or "")[:40]
row_data = [keyword[:15], platform_name, cited_str, conf, pos, text]
for i, d in enumerate(row_data):
pdf.cell(col_widths[i], 7, d, border=1)
pdf.ln()
return pdf.output()
async def export_citations_csv(
db: AsyncSession,
user_id: uuid.UUID,

View File

@ -0,0 +1,154 @@
import logging
import uuid
from datetime import date, timedelta
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.subscription import Subscription
from app.models.user import User
from app.schemas.subscription import PlanDetail, PlanFeature, SubscriptionResponse
logger = logging.getLogger(__name__)
_PLAN_FEATURES = [
("基础查询监控", ["free", "starter", "pro", "enterprise"]),
("CSV导出", ["free", "starter", "pro", "enterprise"]),
("PDF报告", ["starter", "pro", "enterprise"]),
("定时查询", ["pro", "enterprise"]),
("竞品分析", ["pro", "enterprise"]),
("API访问", ["enterprise"]),
("专属支持", ["enterprise"]),
]
PLANS = {
"free": {
"name": "免费版",
"price": 0,
"max_queries": 5,
},
"starter": {
"name": "入门版",
"price": 99,
"max_queries": 20,
},
"pro": {
"name": "专业版",
"price": 299,
"max_queries": 100,
},
"enterprise": {
"name": "企业版",
"price": 999,
"max_queries": 500,
},
}
def _build_features(plan_id: str) -> list[PlanFeature]:
return [
PlanFeature(name=name, included=plan_id in allowed)
for name, allowed in _PLAN_FEATURES
]
def get_plans() -> list[PlanDetail]:
return [
PlanDetail(
id=plan_id,
name=data["name"],
price=data["price"],
max_queries=data["max_queries"],
features=_build_features(plan_id),
)
for plan_id, data in PLANS.items()
]
async def get_current_subscription(
db: AsyncSession, user_id: uuid.UUID
) -> Optional[SubscriptionResponse]:
stmt = (
select(Subscription)
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
.limit(1)
)
result = await db.execute(stmt)
sub = result.scalar_one_or_none()
if sub is None:
return None
return SubscriptionResponse.model_validate(sub)
async def subscribe(
db: AsyncSession, user_id: uuid.UUID, plan: str
) -> SubscriptionResponse:
plan_data = PLANS.get(plan)
if plan_data is None:
raise ValueError(f"Invalid plan: {plan}")
today = date.today()
end_date = today + timedelta(days=30)
subscription = Subscription(
user_id=user_id,
plan=plan,
status="active",
start_date=today,
end_date=end_date,
amount=plan_data["price"],
payment_method="模拟支付",
)
db.add(subscription)
user_stmt = select(User).where(User.id == user_id)
user_result = await db.execute(user_stmt)
user = user_result.scalar_one()
user.plan = plan
user.max_queries = plan_data["max_queries"]
await db.commit()
await db.refresh(subscription)
logger.info(f"[模拟支付] 用户{user_id} 订阅{plan},金额{plan_data['price']}")
return SubscriptionResponse.model_validate(subscription)
async def cancel_subscription(db: AsyncSession, user_id: uuid.UUID) -> dict:
stmt = (
select(Subscription)
.where(Subscription.user_id == user_id, Subscription.status == "active")
.order_by(Subscription.created_at.desc())
.limit(1)
)
result = await db.execute(stmt)
sub = result.scalar_one_or_none()
if sub is not None:
sub.status = "cancelled"
user_stmt = select(User).where(User.id == user_id)
user_result = await db.execute(user_stmt)
user = user_result.scalar_one()
user.plan = "free"
user.max_queries = PLANS["free"]["max_queries"]
await db.commit()
return {"message": "订阅已取消,已降级到免费版"}
async def get_subscription_history(
db: AsyncSession, user_id: uuid.UUID
) -> list[SubscriptionResponse]:
stmt = (
select(Subscription)
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
)
result = await db.execute(stmt)
subs = result.scalars().all()
return [SubscriptionResponse.model_validate(sub) for sub in subs]

View File

@ -33,3 +33,6 @@ python-dotenv
pytest>=8.0
pytest-asyncio>=0.23.0
aiosqlite
# PDF生成
fpdf2>=2.7

View File

@ -1,36 +1,169 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# GEO 平台 - 前端应用
## Getting Started
基于 Next.js 14 + React 18 构建的现代化前端应用,使用 App Router 架构。
First, run the development server:
## 环境要求
- Node.js 18+
- npm 9+(或 yarn、pnpm
- 后端服务已启动(默认 http://localhost:8000
## 安装步骤
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
cd frontend
npm install
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## 运行命令
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
```bash
# 开发模式(带热重载)
npm run dev
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
# 生产构建
npm run build
## Learn More
# 启动生产服务
npm run start
To learn more about Next.js, take a look at the following resources:
# 代码检查
npm run lint
```
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
开发服务器启动后,访问 http://localhost:3000。
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## 页面路由说明
## Deploy on Vercel
本应用使用 Next.js App Router`app/` 目录),路由基于文件系统自动生成。
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
### 认证页面(`(auth)` 路由组)
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
| 路径 | 功能 | 说明 |
|------|------|------|
| `/` | 首页/欢迎页 | 应用入口,未登录用户展示介绍 |
| `/login` | 用户登录 | 邮箱+密码登录,支持记住我 |
| `/register` | 用户注册 | 新用户注册,含邮箱验证流程 |
| `/forgot-password` | 忘记密码 | 发送密码重置链接到邮箱 |
| `/reset-password` | 重置密码 | 通过令牌设置新密码 |
### 仪表盘页面(`(dashboard)` 路由组)
所有仪表盘页面均需登录后才能访问,受 NextAuth 会话保护。
| 路径 | 功能 | 说明 |
|------|------|------|
| `/dashboard` | 仪表盘首页 | 数据概览、关键指标、引用趋势图 |
| `/dashboard/queries` | 查询词管理 | 查询词 CRUD、即时执行、分页列表 |
| `/dashboard/citations` | 引用数据 | 引用记录列表、按平台/日期筛选 |
| `/dashboard/reports` | 报告导出 | CSV 报告生成与下载 |
| `/dashboard/settings` | 个人设置 | 用户资料修改、密码修改 |
### API 路由
| 路径 | 功能 |
|------|------|
| `/api/auth/[...nextauth]` | NextAuth.js 认证 API 端点 |
## 组件结构说明
```
components/
├── ui/ # shadcn/ui 基础组件
│ ├── button.tsx # 按钮
│ ├── card.tsx # 卡片
│ ├── dialog.tsx # 对话框
│ ├── dropdown-menu.tsx # 下拉菜单
│ ├── input.tsx # 输入框
│ ├── label.tsx # 标签
│ ├── select.tsx # 下拉选择
│ ├── table.tsx # 表格
│ ├── tabs.tsx # 标签页
│ ├── badge.tsx # 徽标
│ └── skeleton.tsx # 骨架屏
├── charts/ # 数据可视化图表组件
│ ├── trend-chart.tsx # 引用趋势折线图
│ └── platform-chart.tsx # 平台对比柱状图/饼图
├── layout/ # 布局组件
│ ├── header.tsx # 顶部导航栏
│ └── sidebar.tsx # 侧边栏导航
└── providers.tsx # 全局 Provider 包装NextAuth SessionProvider 等)
```
### lib 工具库
```
lib/
├── api.ts # 后端 API 客户端封装fetch 封装)
├── auth.ts # NextAuth 配置与工具函数
├── platforms.ts # 平台名称/图标映射配置
└── utils.ts # 通用工具函数cn 合并类等)
```
## 技术栈详情
| 类别 | 技术 |
|------|------|
| 框架 | Next.js 14 (App Router) |
| UI 库 | React 18 |
| 样式 | TailwindCSS 3.4 |
| 组件库 | shadcn/ui基于 Radix UI |
| 图表 | Recharts |
| 认证 | NextAuth.js 4 (Credentials Provider) |
| 字体 | Geist本地字体 |
| 语言 | TypeScript 5 |
## 环境变量配置
前端环境变量分为两类:
### 公开环境变量(`NEXT_PUBLIC_` 前缀,客户端可用)
`.env.local` 中配置:
```bash
# 后端 API 地址
NEXT_PUBLIC_API_URL=http://localhost:8000
```
### 服务端环境变量(仅服务端可用)
```bash
# NextAuth 配置
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=geo-platform-nextauth-secret-key-2026
```
> **注意**`.env.local` 文件不会被提交到 Git 仓库。新成员需要手动创建该文件。
### 环境变量文件优先级
Next.js 按以下优先级加载环境变量(高优先级覆盖低优先级):
1. `.env.local`(本地开发,不提交 Git
2. `.env.development` / `.env.production`(按环境)
3. `.env`(全局默认)
## Docker 开发
项目根目录已提供 `docker-compose.yml`,前端服务会自动挂载代码并启动开发服务器:
```bash
# 在项目根目录执行
docker-compose up -d frontend
```
前端容器配置:
- 端口映射:`3000:3000`
- 代码热重载:通过 volume 挂载 `./frontend:/app`
- node_modules 持久化:独立 volume 避免覆盖
## 开发注意事项
1. **API 调用**:统一使用 `lib/api.ts` 中封装的方法,自动携带 JWT Token
2. **认证状态**:通过 `next-auth/react``useSession` Hook 获取当前登录状态
3. **服务端组件 vs 客户端组件**
- 数据获取页面优先使用 Server Component
- 交互逻辑(表单、图表)使用 `"use client"` Client Component
4. **样式规范**:统一使用 TailwindCSS 工具类,复杂组合通过 `cn()` 工具函数处理
5. **图标**:统一使用 `lucide-react` 图标库

View File

@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/lib/api";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
await api.auth.forgotPassword(email);
setSuccess(true);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "请求失败";
setError(message);
} finally {
setLoading(false);
}
};
if (success) {
return (
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
</p>
</CardContent>
<CardFooter>
<Link
href="/login"
className="text-sm text-primary hover:underline"
>
</Link>
</CardFooter>
</Card>
);
}
return (
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "发送中..." : "发送重置链接"}
</Button>
<Link
href="/login"
className="text-sm text-primary hover:underline"
>
</Link>
</CardFooter>
</form>
</Card>
);
}

View File

@ -64,7 +64,15 @@ export default function LoginPage() {
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password"></Label>
<Link
href="/forgot-password"
className="text-sm text-blue-600 hover:underline"
>
</Link>
</div>
<Input
id="password"
type="password"

View File

@ -43,9 +43,9 @@ export default function RegisterPage() {
});
if (result?.error) {
setError("注册成功但自动登录失败,请手动登录");
router.push("/login");
} else {
router.push("/dashboard");
router.refresh();
router.push(`/verify-email?email=${encodeURIComponent(email)}`);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "注册失败";

View File

@ -0,0 +1,147 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/lib/api";
function ResetPasswordForm() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [countdown, setCountdown] = useState(3);
useEffect(() => {
if (success && countdown > 0) {
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}
if (success && countdown === 0) {
router.push("/login");
}
}, [success, countdown, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (newPassword !== confirmPassword) {
setError("两次输入的密码不一致");
return;
}
if (!token) {
setError("重置令牌无效或已过期");
return;
}
setLoading(true);
try {
await api.auth.resetPassword(token, newPassword);
setSuccess(true);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "重置失败";
setError(message);
} finally {
setLoading(false);
}
};
if (success) {
return (
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl"></CardTitle>
<CardDescription>
{countdown}
</CardDescription>
</CardHeader>
<CardFooter>
<Link href="/login" className="text-sm text-primary hover:underline">
</Link>
</CardFooter>
</Card>
);
}
return (
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="space-y-2">
<Label htmlFor="newPassword"></Label>
<Input
id="newPassword"
type="password"
placeholder="请输入新密码"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword"></Label>
<Input
id="confirmPassword"
type="password"
placeholder="请再次输入新密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "提交中..." : "重置密码"}
</Button>
<Link href="/login" className="text-sm text-primary hover:underline">
</Link>
</CardFooter>
</form>
</Card>
);
}
export default function ResetPasswordPage() {
return (
<Suspense
fallback={
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl"></CardTitle>
<CardDescription>...</CardDescription>
</CardHeader>
</Card>
}
>
<ResetPasswordForm />
</Suspense>
);
}

View File

@ -0,0 +1,154 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/lib/api";
function VerifyEmailForm() {
const router = useRouter();
const searchParams = useSearchParams();
const email = searchParams.get("email") || "";
const [code, setCode] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [resendCountdown, setResendCountdown] = useState(0);
useEffect(() => {
if (resendCountdown > 0) {
const timer = setTimeout(() => setResendCountdown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}
}, [resendCountdown]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
setError("邮箱地址无效");
return;
}
setLoading(true);
setError("");
try {
await api.auth.verifyEmail(email, code);
setSuccess(true);
setTimeout(() => {
router.push("/login");
}, 1500);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "验证失败";
setError(message);
} finally {
setLoading(false);
}
};
const handleResend = async () => {
if (!email || resendCountdown > 0) return;
setError("");
try {
await api.auth.resendVerification(email);
setResendCountdown(60);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "发送失败";
setError(message);
}
};
if (success) {
return (
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl"></CardTitle>
<CardDescription>...</CardDescription>
</CardHeader>
<CardFooter>
<Link href="/login" className="text-sm text-primary hover:underline">
</Link>
</CardFooter>
</Card>
);
}
return (
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl"></CardTitle>
<CardDescription>
<span className="font-medium">{email || "您的邮箱"}</span>
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="space-y-2">
<Label htmlFor="code"></Label>
<Input
id="code"
type="text"
placeholder="请输入6位验证码"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value)}
required
/>
</div>
<div className="flex items-center justify-between">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleResend}
disabled={resendCountdown > 0 || !email}
>
{resendCountdown > 0
? `${resendCountdown} 秒后重新发送`
: "重新发送验证码"}
</Button>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "验证中..." : "验证"}
</Button>
<Link href="/login" className="text-sm text-primary hover:underline">
</Link>
</CardFooter>
</form>
</Card>
);
}
export default function VerifyEmailPage() {
return (
<Suspense
fallback={
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl"></CardTitle>
<CardDescription>...</CardDescription>
</CardHeader>
</Card>
}
>
<VerifyEmailForm />
</Suspense>
);
}

View File

@ -0,0 +1,434 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/lib/api";
import {
Users,
Search,
Quote,
Percent,
Loader2,
AlertTriangle,
CheckCircle,
Ban,
UserCheck,
ChevronLeft,
ChevronRight,
} from "lucide-react";
interface StatsData {
total_users: number;
total_queries: number;
total_citations: number;
citation_rate: number;
today_active_users: number;
}
interface AdminUser {
id: string;
email: string;
name: string | null;
plan: string;
is_active: boolean;
is_admin: boolean;
email_verified: boolean;
query_count: number;
created_at: string;
}
const PLAN_OPTIONS = [
{ value: "free", label: "免费版" },
{ value: "starter", label: "入门版" },
{ value: "pro", label: "专业版" },
{ value: "business", label: "企业版" },
];
const LIMIT = 10;
export default function AdminPage() {
const { data: session } = useSession();
const [stats, setStats] = useState<StatsData | null>(null);
const [users, setUsers] = useState<AdminUser[]>([]);
const [totalUsers, setTotalUsers] = useState(0);
const [skip, setSkip] = useState(0);
const [search, setSearch] = useState("");
const [loadingStats, setLoadingStats] = useState(false);
const [loadingUsers, setLoadingUsers] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<"toggle" | "plan">("toggle");
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
const [selectedPlan, setSelectedPlan] = useState("");
const [actionLoading, setActionLoading] = useState(false);
const token = session?.accessToken;
useEffect(() => {
if (!token) return;
loadStats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
useEffect(() => {
if (!token) return;
loadUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, skip, search]);
async function loadStats() {
if (!token) return;
setLoadingStats(true);
try {
const data = await api.admin.getStats(token);
setStats(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "加载统计失败");
} finally {
setLoadingStats(false);
}
}
async function loadUsers() {
if (!token) return;
setLoadingUsers(true);
try {
const data = await api.admin.getUsers(token, { skip, limit: LIMIT, search: search || undefined });
setUsers(data.items || []);
setTotalUsers(data.total || 0);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "加载用户列表失败");
} finally {
setLoadingUsers(false);
}
}
function openToggleDialog(user: AdminUser) {
setSelectedUser(user);
setDialogType("toggle");
setDialogOpen(true);
}
function openPlanDialog(user: AdminUser) {
setSelectedUser(user);
setSelectedPlan(user.plan);
setDialogType("plan");
setDialogOpen(true);
}
async function handleConfirm() {
if (!token || !selectedUser) return;
setActionLoading(true);
setSuccess(null);
try {
if (dialogType === "toggle") {
const res = await api.admin.toggleUserActive(token, selectedUser.id);
setSuccess(res.message || "操作成功");
} else {
const res = await api.admin.updateUserPlan(token, selectedUser.id, selectedPlan);
setSuccess(res.message || "套餐更新成功");
}
await loadUsers();
setDialogOpen(false);
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "操作失败");
setDialogOpen(false);
} finally {
setActionLoading(false);
}
}
const totalPages = Math.ceil(totalUsers / LIMIT);
const currentPage = Math.floor(skip / LIMIT) + 1;
const statCards = [
{
title: "总用户数",
value: stats?.total_users ?? 0,
icon: Users,
color: "text-blue-600",
bg: "bg-blue-50",
},
{
title: "总查询数",
value: stats?.total_queries ?? 0,
icon: Search,
color: "text-emerald-600",
bg: "bg-emerald-50",
},
{
title: "总引用次数",
value: stats?.total_citations ?? 0,
icon: Quote,
color: "text-violet-600",
bg: "bg-violet-50",
},
{
title: "引用率",
value: stats ? `${stats.citation_rate}%` : "0%",
icon: Percent,
color: "text-amber-600",
bg: "bg-amber-50",
},
];
function formatDate(dateStr: string) {
if (!dateStr) return "-";
const d = new Date(dateStr);
return d.toLocaleDateString("zh-CN");
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{success && (
<div className="flex items-center gap-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
<CheckCircle className="h-4 w-4 shrink-0" />
<span>{success}</span>
</div>
)}
{/* Stats Cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{statCards.map((card) => (
<Card key={card.title}>
<CardContent className="flex items-center gap-4 p-6">
<div className={cn("flex h-12 w-12 items-center justify-center rounded-lg", card.bg)}>
<card.icon className={cn("h-6 w-6", card.color)} />
</div>
<div>
<p className="text-sm text-muted-foreground">{card.title}</p>
<p className="text-2xl font-bold">
{loadingStats ? <Loader2 className="h-5 w-5 animate-spin" /> : card.value}
</p>
</div>
</CardContent>
</Card>
))}
</div>
{/* User Management */}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-2">
<Input
placeholder="搜索邮箱或用户名"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setSkip(0);
}}
className="max-w-sm"
/>
<Button variant="outline" onClick={() => { setSearch(""); setSkip(0); }}>
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingUsers ? (
<TableRow>
<TableCell colSpan={8} className="py-8 text-center text-muted-foreground">
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-8 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.email}</TableCell>
<TableCell>{user.name || "-"}</TableCell>
<TableCell>
<Badge variant="secondary">{user.plan}</Badge>
</TableCell>
<TableCell>{user.query_count}</TableCell>
<TableCell>
{user.email_verified ? (
<span className="inline-flex items-center gap-1 text-xs text-emerald-600">
<CheckCircle className="h-3 w-3" />
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Ban className="h-3 w-3" />
</span>
)}
</TableCell>
<TableCell>
{user.is_active ? (
<Badge variant="default" className="bg-emerald-500 hover:bg-emerald-600">
</Badge>
) : (
<Badge variant="destructive"></Badge>
)}
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(user.created_at)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => openToggleDialog(user)}
>
{user.is_active ? <Ban className="h-3 w-3 mr-1" /> : <UserCheck className="h-3 w-3 mr-1" />}
{user.is_active ? "禁用" : "启用"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => openPlanDialog(user)}
>
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setSkip((p) => Math.max(0, p - LIMIT))}
disabled={skip === 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground">
{currentPage} / {totalPages} ( {totalUsers} )
</span>
<Button
variant="outline"
size="sm"
onClick={() => setSkip((p) => p + LIMIT)}
disabled={skip + LIMIT >= totalUsers}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
{/* Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{dialogType === "toggle"
? selectedUser?.is_active
? "禁用用户"
: "启用用户"
: "修改套餐"}
</DialogTitle>
<DialogDescription>
{dialogType === "toggle"
? `确认${selectedUser?.is_active ? "禁用" : "启用"}用户 ${selectedUser?.email}`
: `请选择用户 ${selectedUser?.email} 的新套餐`}
</DialogDescription>
</DialogHeader>
{dialogType === "plan" && (
<div className="py-2">
<Select value={selectedPlan} onValueChange={setSelectedPlan}>
<SelectTrigger>
<SelectValue placeholder="选择套餐" />
</SelectTrigger>
<SelectContent>
{PLAN_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={actionLoading}>
</Button>
<Button onClick={handleConfirm} disabled={actionLoading}>
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function cn(...classes: (string | undefined | false)[]) {
return classes.filter(Boolean).join(" ");
}

View File

@ -5,6 +5,7 @@ import { useSession } from "next-auth/react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
@ -12,28 +13,71 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/lib/api";
import { Loader2, FileDown, Info, CheckCircle, AlertTriangle } from "lucide-react";
import {
Loader2,
FileDown,
FileText,
Info,
CheckCircle,
AlertTriangle,
Search,
Quote,
Percent,
BarChart3,
} from "lucide-react";
interface QueryOption {
id: string;
keyword: string;
}
interface CitationStats {
total_queries: number;
total_citations: number;
citation_rate: number;
avg_position: number | null;
}
interface CitationRecord {
id: string;
query_id: string;
platform: string;
cited: boolean;
citation_position: number | null;
citation_text: string | null;
competitor_brands: string[];
confidence: number | null;
match_type: string | null;
queried_at: string;
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
export default function ReportsPage() {
const { data: session } = useSession();
const [queries, setQueries] = useState<QueryOption[]>([]);
const [selectedQuery, setSelectedQuery] = useState<string>("");
const [exportFormat, setExportFormat] = useState<string>("csv");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [stats, setStats] = useState<CitationStats | null>(null);
const [recentCitations, setRecentCitations] = useState<CitationRecord[]>([]);
const [previewLoading, setPreviewLoading] = useState(false);
useEffect(() => {
if (!session?.accessToken) return;
loadQueries();
loadPreview();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.accessToken]);
@ -47,38 +91,71 @@ export default function ReportsPage() {
}
}
async function handleExport() {
async function loadPreview() {
if (!session?.accessToken) return;
setPreviewLoading(true);
try {
const statsData = await api.citations.getStats(session.accessToken);
setStats(statsData);
const listData = await api.citations.list(session.accessToken, "limit=10");
setRecentCitations(listData.items || []);
} catch (err) {
// 预览数据加载失败不影响主功能
console.error("预览数据加载失败", err);
} finally {
setPreviewLoading(false);
}
}
async function handleExportCSV() {
if (!session?.accessToken) return;
if (!selectedQuery) {
setError("请先选择要导出的查询词");
return;
}
await handleExport("csv");
}
async function handleExportPDF() {
if (!session?.accessToken) return;
if (!selectedQuery) {
setError("请先选择要导出的查询词");
return;
}
await handleExport("pdf");
}
async function handleExport(format: "csv" | "pdf") {
if (!session?.accessToken) return;
try {
setLoading(true);
setError(null);
setSuccess(false);
// 使用原生fetch下载CSV文件
const query = `?query_id=${selectedQuery}`;
const queryId = selectedQuery;
let blob: Blob;
let filename: string;
if (format === "csv") {
const query = `?query_id=${queryId}`;
const url = `${API_BASE}/api/v1/reports/export/csv${query}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
headers: { Authorization: `Bearer ${session.accessToken}` },
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({ detail: "导出失败" }));
throw new Error(errorData.detail || `HTTP ${res.status}`);
}
blob = await res.blob();
filename = `report_${queryId}_${new Date().toISOString().split("T")[0]}.csv`;
} else {
blob = await api.reports.exportPDF(session.accessToken, queryId);
filename = `report_${queryId}_${new Date().toISOString().split("T")[0]}.pdf`;
}
const blob = await res.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = downloadUrl;
const filename = selectedQuery
? `report_${selectedQuery}_${new Date().toISOString().split("T")[0]}.csv`
: `report_all_${new Date().toISOString().split("T")[0]}.csv`;
a.download = filename;
document.body.appendChild(a);
a.click();
@ -93,8 +170,45 @@ export default function ReportsPage() {
}
}
const statCards = [
{
title: "总查询",
value: stats?.total_queries ?? 0,
icon: Search,
color: "text-blue-600",
bg: "bg-blue-50",
},
{
title: "引用次数",
value: stats?.total_citations ?? 0,
icon: Quote,
color: "text-emerald-600",
bg: "bg-emerald-50",
},
{
title: "引用率",
value: stats ? `${stats.citation_rate.toFixed(1)}%` : "0%",
icon: Percent,
color: "text-violet-600",
bg: "bg-violet-50",
},
{
title: "平均位置",
value: stats?.avg_position ? stats.avg_position.toFixed(1) : "-",
icon: BarChart3,
color: "text-amber-600",
bg: "bg-amber-50",
},
];
function formatDate(dateStr: string) {
if (!dateStr) return "-";
const d = new Date(dateStr);
return d.toLocaleDateString("zh-CN") + " " + d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
}
return (
<div className="space-y-4">
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
@ -126,21 +240,6 @@ export default function ReportsPage() {
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="format-select"></Label>
<Select value={exportFormat} onValueChange={setExportFormat}>
<SelectTrigger id="format-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="csv">CSV </SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
CSV
</p>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 shrink-0" />
@ -155,18 +254,33 @@ export default function ReportsPage() {
</div>
)}
<div className="flex gap-2">
<Button
onClick={handleExport}
onClick={handleExportCSV}
disabled={loading}
className="w-full"
className="flex-1"
>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileDown className="mr-2 h-4 w-4" />
)}
CSV
</Button>
<Button
onClick={handleExportPDF}
disabled={loading}
variant="outline"
className="flex-1"
>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
PDF
</Button>
</div>
</CardContent>
</Card>
@ -179,7 +293,7 @@ export default function ReportsPage() {
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>1. </p>
<p>2. CSV Excel WPS </p>
<p>2. CSV PDF </p>
<p>3. </p>
<ul className="ml-4 list-disc space-y-1">
<li></li>
@ -194,6 +308,94 @@ export default function ReportsPage() {
</CardContent>
</Card>
</div>
{/* Stats Preview */}
<div>
<h3 className="mb-4 text-lg font-semibold"></h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{statCards.map((card) => (
<Card key={card.title}>
<CardContent className="flex items-center gap-4 p-6">
<div className={cn("flex h-12 w-12 items-center justify-center rounded-lg", card.bg)}>
<card.icon className={cn("h-6 w-6", card.color)} />
</div>
<div>
<p className="text-sm text-muted-foreground">{card.title}</p>
<p className="text-2xl font-bold">
{previewLoading ? <Loader2 className="h-5 w-5 animate-spin" /> : card.value}
</p>
</div>
</CardContent>
</Card>
))}
</div>
</div>
{/* Recent Citations */}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription> 10 </CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{previewLoading ? (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
</TableCell>
</TableRow>
) : recentCitations.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
recentCitations.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.platform}</TableCell>
<TableCell>
{c.cited ? (
<Badge variant="default" className="bg-emerald-500 hover:bg-emerald-600">
</Badge>
) : (
<Badge variant="secondary"></Badge>
)}
</TableCell>
<TableCell>{c.citation_position ?? "-"}</TableCell>
<TableCell className="max-w-xs truncate" title={c.citation_text || ""}>
{c.citation_text || "-"}
</TableCell>
<TableCell>
{c.competitor_brands?.length > 0 ? c.competitor_brands.join(", ") : "-"}
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(c.queried_at)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}
function cn(...classes: (string | undefined | false)[]) {
return classes.filter(Boolean).join(" ");
}

View File

@ -1,130 +1,339 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { User, Crown, Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Crown, Check, X, Loader2, AlertTriangle, CheckCircle } from "lucide-react";
import { api } from "@/lib/api";
const PLANS = [
{
key: "free",
name: "免费版",
price: "¥0",
period: "/月",
features: [
{ text: "最多 3 个查询词", included: true },
{ text: "每日查询 1 次", included: true },
{ text: "基础报告导出", included: true },
{ text: "2 个平台监测", included: true },
{ text: "高级分析图表", included: false },
{ text: "竞品对比分析", included: false },
{ text: "API 接口访问", included: false },
{ text: "优先技术支持", included: false },
],
},
{
key: "starter",
name: "入门版",
price: "¥99",
period: "/月",
features: [
{ text: "最多 10 个查询词", included: true },
{ text: "每日查询 3 次", included: true },
{ text: "基础报告导出", included: true },
{ text: "4 个平台监测", included: true },
{ text: "高级分析图表", included: true },
{ text: "竞品对比分析", included: false },
{ text: "API 接口访问", included: false },
{ text: "优先技术支持", included: false },
],
},
{
key: "pro",
name: "专业版",
price: "¥299",
period: "/月",
features: [
{ text: "最多 50 个查询词", included: true },
{ text: "每日查询 10 次", included: true },
{ text: "高级报告导出", included: true },
{ text: "全部平台监测", included: true },
{ text: "高级分析图表", included: true },
{ text: "竞品对比分析", included: true },
{ text: "API 接口访问", included: false },
{ text: "优先技术支持", included: true },
],
},
{
key: "business",
name: "企业版",
price: "¥999",
period: "/月",
features: [
{ text: "无限查询词", included: true },
{ text: "无限查询次数", included: true },
{ text: "定制化报告", included: true },
{ text: "全部平台监测", included: true },
{ text: "高级分析图表", included: true },
{ text: "竞品对比分析", included: true },
{ text: "API 接口访问", included: true },
{ text: "专属客户经理", included: true },
],
},
];
interface PlanFeature {
name: string;
included: boolean;
}
export default function SettingsPage() {
interface PlanDetail {
id: string;
name: string;
price: number;
max_queries: number;
features: PlanFeature[];
}
interface SubscriptionData {
id: string;
plan: string;
status: string;
start_date: string;
end_date: string;
amount: number | null;
payment_method: string | null;
created_at: string;
}
function ProfileTab() {
const { data: session, update } = useSession();
const [name, setName] = useState(session?.user?.name || "");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!session?.accessToken) return;
setLoading(true);
setError("");
setSuccess(false);
try {
await api.auth.updateProfile(session.accessToken, { name });
await update({ name });
setSuccess(true);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "保存失败";
setError(message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSave} className="space-y-4">
{error && <p className="text-sm text-destructive">{error}</p>}
{success && <p className="text-sm text-emerald-600"></p>}
<div className="space-y-2">
<Label htmlFor="profileName"></Label>
<Input
id="profileName"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="请输入用户名"
/>
</div>
<div className="space-y-2">
<Label htmlFor="profileEmail"></Label>
<Input
id="profileEmail"
type="email"
value={session?.user?.email || ""}
disabled
/>
</div>
<Button type="submit" disabled={loading}>
{loading ? "保存中..." : "保存"}
</Button>
</form>
);
}
function PasswordTab() {
const { data: session } = useSession();
const currentPlan = "free"; // MVP阶段默认免费版
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setSuccess(false);
if (newPassword.length < 8) {
setError("新密码至少需要 8 位");
return;
}
if (newPassword !== confirmPassword) {
setError("两次输入的新密码不一致");
return;
}
if (!session?.accessToken) {
setError("登录已过期,请重新登录");
return;
}
setLoading(true);
try {
await api.auth.changePassword(session.accessToken, oldPassword, newPassword);
setSuccess(true);
setOldPassword("");
setNewPassword("");
setConfirmPassword("");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "修改失败";
setError(message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && <p className="text-sm text-destructive">{error}</p>}
{success && <p className="text-sm text-emerald-600"></p>}
<div className="space-y-2">
<Label htmlFor="oldPassword"></Label>
<Input
id="oldPassword"
type="password"
placeholder="请输入当前密码"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword"></Label>
<Input
id="newPassword"
type="password"
placeholder="请输入新密码至少8位"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmNewPassword"></Label>
<Input
id="confirmNewPassword"
type="password"
placeholder="请再次输入新密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
<Button type="submit" disabled={loading}>
{loading ? "修改中..." : "修改密码"}
</Button>
</form>
);
}
function SubscriptionTab() {
const { data: session } = useSession();
const [plans, setPlans] = useState<PlanDetail[]>([]);
const [currentSub, setCurrentSub] = useState<SubscriptionData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<"subscribe" | "cancel">("subscribe");
const [selectedPlan, setSelectedPlan] = useState("");
const [actionLoading, setActionLoading] = useState(false);
const token = session?.accessToken;
const currentPlan = currentSub?.plan || "free";
useEffect(() => {
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function loadData() {
setLoading(true);
setError(null);
try {
const plansData = await api.subscriptions.getPlans();
setPlans(plansData);
if (token) {
try {
const subData = await api.subscriptions.getCurrent(token);
setCurrentSub(subData);
} catch {
// 暂无订阅记录,保持 null
setCurrentSub(null);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "加载失败");
} finally {
setLoading(false);
}
}
function openSubscribeDialog(planId: string) {
setSelectedPlan(planId);
setDialogType("subscribe");
setDialogOpen(true);
}
function openCancelDialog() {
setDialogType("cancel");
setDialogOpen(true);
}
async function handleConfirm() {
if (!token) return;
setActionLoading(true);
setSuccess(null);
try {
if (dialogType === "subscribe") {
await api.subscriptions.subscribe(token, selectedPlan);
setSuccess("订阅成功");
} else {
await api.subscriptions.cancel(token);
setSuccess("订阅已取消");
}
await loadData();
setDialogOpen(false);
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "操作失败");
setDialogOpen(false);
} finally {
setActionLoading(false);
}
}
const currentPlanName = plans.find((p) => p.id === currentPlan)?.name || currentPlan;
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{success && (
<div className="flex items-center gap-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
<CheckCircle className="h-4 w-4 shrink-0" />
<span>{success}</span>
</div>
)}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<User className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium">{session?.user?.name || "未设置姓名"}</p>
<p className="text-sm text-muted-foreground">{session?.user?.email || "—"}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100">
<Crown className="h-5 w-5 text-amber-600" />
</div>
<div>
<p className="font-medium"></p>
<p className="font-medium">{currentPlanName}</p>
<div className="flex items-center gap-2">
<Badge variant="secondary">{PLANS.find((p) => p.key === currentPlan)?.name || "免费版"}</Badge>
<span className="text-xs text-muted-foreground">MVP </span>
<Badge variant="secondary"></Badge>
{currentSub?.status && (
<span className="text-xs text-muted-foreground">
: {currentSub.status === "active" ? "生效中" : currentSub.status}
</span>
)}
</div>
</div>
</div>
{currentPlan !== "free" && currentSub && (
<div className="text-sm text-muted-foreground">
<p>: {currentSub.start_date} {currentSub.end_date}</p>
{currentSub.amount && <p>: ¥{currentSub.amount}</p>}
</div>
)}
{currentPlan !== "free" && (
<Button variant="destructive" size="sm" onClick={openCancelDialog}>
</Button>
)}
</CardContent>
</Card>
<div>
<h3 className="mb-4 text-lg font-semibold"></h3>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{PLANS.map((plan) => (
{plans.map((plan) => {
const isCurrent = plan.id === currentPlan;
const canUpgrade = plan.id !== currentPlan;
return (
<Card
key={plan.key}
className={plan.key === currentPlan ? "border-primary ring-1 ring-primary" : ""}
key={plan.id}
className={isCurrent ? "border-primary ring-1 ring-primary" : ""}
>
<CardHeader className="pb-3">
<CardTitle className="text-base">{plan.name}</CardTitle>
<CardDescription>
<span className="text-2xl font-bold text-foreground">{plan.price}</span>
<span className="text-muted-foreground">{plan.period}</span>
<span className="text-2xl font-bold text-foreground">¥{plan.price}</span>
<span className="text-muted-foreground">/</span>
</CardDescription>
</CardHeader>
<CardContent>
@ -137,35 +346,99 @@ export default function SettingsPage() {
<X className="h-4 w-4 shrink-0 text-muted-foreground/50" />
)}
<span className={feature.included ? "" : "text-muted-foreground/60"}>
{feature.text}
{feature.name}
</span>
</li>
))}
</ul>
{plan.key === currentPlan && (
{isCurrent ? (
<Badge className="mt-4 w-full justify-center" variant="default">
</Badge>
)}
) : canUpgrade ? (
<Button
className="mt-4 w-full"
size="sm"
onClick={() => openSubscribeDialog(plan.id)}
>
</Button>
) : null}
</CardContent>
</Card>
))}
);
})}
</div>
)}
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">MVP </CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<p className="mb-2">
GEO MVP 使线
</p>
<p>
</p>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{dialogType === "subscribe" ? "确认订阅" : "取消订阅"}
</DialogTitle>
<DialogDescription>
{dialogType === "subscribe"
? `确认使用模拟支付订阅「${plans.find((p) => p.id === selectedPlan)?.name || selectedPlan}」套餐?`
: "确认取消当前订阅?取消后将在当前计费周期结束后恢复为免费版。"}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={actionLoading}>
</Button>
<Button onClick={handleConfirm} disabled={actionLoading} variant={dialogType === "cancel" ? "destructive" : "default"}>
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default function SettingsPage() {
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<Tabs defaultValue="profile" className="w-full">
<TabsList>
<TabsTrigger value="profile"></TabsTrigger>
<TabsTrigger value="password"></TabsTrigger>
<TabsTrigger value="subscription"></TabsTrigger>
</TabsList>
<TabsContent value="profile" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<ProfileTab />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="password" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<PasswordTab />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="subscription" className="mt-4">
<SubscriptionTab />
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
@ -9,9 +10,10 @@ import {
Quote,
FileDown,
Settings,
Shield,
} from "lucide-react";
const navItems = [
const baseNavItems = [
{ name: "数据总览", href: "/dashboard", icon: LayoutDashboard },
{ name: "查询管理", href: "/dashboard/queries", icon: Search },
{ name: "引用记录", href: "/dashboard/citations", icon: Quote },
@ -21,6 +23,11 @@ const navItems = [
export function Sidebar() {
const pathname = usePathname();
const { data: session } = useSession();
const navItems = session?.user?.is_admin
? [...baseNavItems, { name: "管理后台", href: "/dashboard/admin", icon: Shield }]
: baseNavItems;
return (
<aside className="fixed left-0 top-0 z-40 h-screen w-64 bg-slate-900 text-white">

7
frontend/cookies.txt Normal file
View File

@ -0,0 +1,7 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1779586904 next-auth.session-token eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..H3E9QhqgbpDJMmkB.yYmEZnejk-WUCZMjLMDJdlVMZal-LyanhgkXx8GhcIv8qcQZvXxGzSpX8L4lV7R9wCqxJVCWBILAimid6d-sv4MpIcZioH8NyGqepvnWJoWhkbAxrxJvrhrB_h0ESo6VuvZ4doW7oV-3EftMNzvOpFl5Opv9Jjyo8vCDdimRaEOwnnxpkuGNTZ7Ca7C2o8HkI4I9h3vtzKFYxZNqCukwq5HY4pCfqE0LysTLXSd5Rg8BG9uUj4TH8UYjSBtoFrkvjDNGazS1kO0PEeq_U_Q0E4F17zg4k8TvVG0az0Q1TAvmcW5FBnb2mF7w0lNvE5b5ESXW1uW_JSEigXusOHbHlIrl9PafsUeek6MN3aKLUxt5cfvS2mKpLA0mWi2bMl5CmjoIzRnHU5Q2BZ2axzLDG_QiUMqaC1ghCklFM46DGkiSGsOVYk1Oz0yN2RJrrkOYsRdC1e9s8Y00imTaF-78EIdpCZQ2jePxk86Fbm5ju0wJFZVaTIx1Rk9WTYwvi_D0Bo1lpxABI1HVI9kz6o2r1VfAWLm_3poPZ0FDC4kKGiE.gzu1FubRqk9RqT1nNADojg
#HttpOnly_localhost FALSE / FALSE 0 next-auth.csrf-token 1966deebfa651641e0fea8b073e51a470ca0f344254ccc2b5112c26bcbc46578%7Cd44c731ad18fa1807b89db213db37e16079933f621105d553b8281ae869a9d7e
#HttpOnly_localhost FALSE / FALSE 0 next-auth.callback-url http%3A%2F%2Flocalhost%3A3000%2Fdashboard

View File

@ -52,6 +52,36 @@ export const api = {
body: JSON.stringify(data),
}),
getMe: (token: string) => fetchWithAuth("/api/v1/auth/me", {}, token),
forgotPassword: (email: string) =>
fetchWithAuth("/api/v1/auth/forgot-password", {
method: "POST",
body: JSON.stringify({ email }),
}),
resetPassword: (token: string, newPassword: string) =>
fetchWithAuth("/api/v1/auth/reset-password", {
method: "POST",
body: JSON.stringify({ token, new_password: newPassword }),
}),
verifyEmail: (email: string, code: string) =>
fetchWithAuth("/api/v1/auth/verify-email", {
method: "POST",
body: JSON.stringify({ email, code }),
}),
resendVerification: (email: string) =>
fetchWithAuth("/api/v1/auth/resend-verification", {
method: "POST",
body: JSON.stringify({ email }),
}),
changePassword: (token: string, oldPassword: string, newPassword: string) =>
fetchWithAuth("/api/v1/auth/change-password", {
method: "PUT",
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
}, token),
updateProfile: (token: string, data: { name?: string; avatar_url?: string }) =>
fetchWithAuth("/api/v1/auth/profile", {
method: "PUT",
body: JSON.stringify(data),
}, token),
},
queries: {
list: (token: string) => fetchWithAuth("/api/v1/queries/", {}, token),
@ -69,10 +99,55 @@ export const api = {
fetchWithAuth(`/api/v1/citations/${params ? `?${params}` : ""}`, {}, token),
getStats: (token: string) => fetchWithAuth("/api/v1/citations/stats/", {}, token),
},
subscriptions: {
getPlans: async () => {
const res = await fetch(`${API_BASE}/api/v1/subscriptions/plans`);
if (!res.ok) throw new Error("获取套餐失败");
return res.json();
},
getCurrent: async (token: string) =>
fetchWithAuth("/api/v1/subscriptions/current", {}, token),
subscribe: async (token: string, plan: string) =>
fetchWithAuth("/api/v1/subscriptions/subscribe", {
method: "POST",
body: JSON.stringify({ plan }),
}, token),
cancel: async (token: string) =>
fetchWithAuth("/api/v1/subscriptions/cancel", { method: "POST" }, token),
getHistory: async (token: string) =>
fetchWithAuth("/api/v1/subscriptions/history", {}, token),
},
admin: {
getStats: async (token: string) =>
fetchWithAuth("/api/v1/admin/stats", {}, token),
getUsers: async (token: string, params?: { skip?: number; limit?: number; search?: string }) => {
const query = params
? "?" + new URLSearchParams(Object.entries(params).filter(([, v]) => v !== undefined) as [string, string][]).toString()
: "";
return fetchWithAuth(`/api/v1/admin/users${query}`, {}, token);
},
getUserDetail: async (token: string, userId: string) =>
fetchWithAuth(`/api/v1/admin/users/${userId}`, {}, token),
toggleUserActive: async (token: string, userId: string) =>
fetchWithAuth(`/api/v1/admin/users/${userId}/toggle-active`, { method: "POST" }, token),
updateUserPlan: async (token: string, userId: string, plan: string) =>
fetchWithAuth(`/api/v1/admin/users/${userId}/update-plan`, {
method: "PUT",
body: JSON.stringify({ plan }),
}, token),
},
reports: {
exportCSV: (token: string, queryId?: string) => {
const query = queryId ? `?query_id=${queryId}` : "";
return fetchWithAuth(`/api/v1/reports/export/csv${query}`, {}, token);
},
exportPDF: async (token: string, queryId?: string) => {
const query = queryId ? `?query_id=${queryId}` : "";
const res = await fetch(`${API_BASE}/api/v1/reports/export/pdf${query}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("导出失败");
return res.blob();
},
},
};

View File

@ -11,22 +11,37 @@ export const authOptions: NextAuthOptions = {
password: { label: "密码", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
console.log("[NextAuth] authorize called with email:", credentials?.email);
if (!credentials?.email || !credentials?.password) {
console.log("[NextAuth] missing credentials");
return null;
}
try {
const res = await api.auth.login({
email: credentials.email,
password: credentials.password,
});
console.log("[NextAuth] login response:", JSON.stringify({
hasAccessToken: !!res.access_token,
userId: res.user?.id,
userEmail: res.user?.email,
isAdmin: res.user?.is_admin,
}));
if (res.access_token) {
return {
const user = {
id: res.user?.id || credentials.email,
name: res.user?.name,
email: res.user?.email,
accessToken: res.access_token,
is_admin: res.user?.is_admin || false,
};
console.log("[NextAuth] returning user:", JSON.stringify(user));
return user;
}
console.log("[NextAuth] no access_token in response");
return null;
} catch {
} catch (error) {
console.error("[NextAuth] authorize error:", error);
return null;
}
},
@ -40,12 +55,14 @@ export const authOptions: NextAuthOptions = {
if (user) {
token.accessToken = user.accessToken;
token.id = user.id;
token.is_admin = user.is_admin;
}
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken as string;
session.user.id = token.id as string;
session.user.is_admin = token.is_admin as boolean;
return session;
},
},

View File

@ -8,12 +8,14 @@ declare module "next-auth" {
name?: string | null;
email?: string | null;
image?: string | null;
is_admin?: boolean;
};
}
interface User {
id: string;
accessToken: string;
is_admin?: boolean;
}
}
@ -21,5 +23,6 @@ declare module "next-auth/jwt" {
interface JWT {
accessToken?: string;
id?: string;
is_admin?: boolean;
}
}

View File

@ -1,15 +0,0 @@
#!/bin/bash
set -e
cd /Users/Chiguyong/Code/GEO
echo "=== Step 1: Check remote ==="
git remote -v
echo "=== Step 2: Git status ==="
git status
echo "=== Step 3: Git log ==="
git log --oneline -5
echo "=== Step 4: Push ==="
git push -u origin main 2>&1 || {
echo "Push failed, trying force push..."
git push -u origin main --force 2>&1
}
echo "=== Done ==="

View File

@ -1 +0,0 @@
hello2