init doc
This commit is contained in:
55
CLAUDE.md
55
CLAUDE.md
@ -52,10 +52,20 @@ cd frontend && make quick
|
|||||||
- `make install` - 安装依赖
|
- `make install` - 安装依赖
|
||||||
- `make dev` - 启动开发服务器
|
- `make dev` - 启动开发服务器
|
||||||
- `make build` - 构建生产版本
|
- `make build` - 构建生产版本
|
||||||
- `make clean` - 清理构建文件
|
- `make start` - 启动生产服务器
|
||||||
|
- `make clean` - 清理构建文件和缓存
|
||||||
- `make status` - 检查项目状态
|
- `make status` - 检查项目状态
|
||||||
- `make add PACKAGE=name` - 添加依赖
|
- `make add PACKAGE=name` - 添加依赖
|
||||||
|
- `make add-dev PACKAGE=name` - 添加开发依赖
|
||||||
- `make remove PACKAGE=name` - 移除依赖
|
- `make remove PACKAGE=name` - 移除依赖
|
||||||
|
- `make type-check` - 运行 TypeScript 类型检查
|
||||||
|
- `make lint` - 运行代码检查
|
||||||
|
- `make format` - 格式化代码
|
||||||
|
- `make setup` - 设置开发环境
|
||||||
|
- `make deploy-prep` - 准备生产部署
|
||||||
|
- `make quick` - 快速启动(安装依赖并启动开发服务器)
|
||||||
|
- `make update` - 更新所有依赖
|
||||||
|
- `make reinstall` - 清理并重新安装依赖
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
@ -90,10 +100,19 @@ photography/
|
|||||||
- `components/navigation.tsx` - 粘性导航,带移动菜单
|
- `components/navigation.tsx` - 粘性导航,带移动菜单
|
||||||
- `components/photo-modal.tsx` - 照片详情模态框,带导航
|
- `components/photo-modal.tsx` - 照片详情模态框,带导航
|
||||||
- `components/timeline-view.tsx` - 基于时间轴的照片展示
|
- `components/timeline-view.tsx` - 基于时间轴的照片展示
|
||||||
|
- `components/timeline-stats.tsx` - 时间轴统计信息
|
||||||
- `components/filter-bar.tsx` - 照片分类过滤
|
- `components/filter-bar.tsx` - 照片分类过滤
|
||||||
|
- `components/about-view.tsx` - 关于页面组件
|
||||||
|
- `components/contact-view.tsx` - 联系页面组件
|
||||||
|
- `components/theme-provider.tsx` - 主题提供者(深色模式支持)
|
||||||
|
- `components/providers/query-provider.tsx` - TanStack Query 提供者
|
||||||
|
- `components/loading-spinner.tsx` - 加载动画组件
|
||||||
- `components/ui/` - 来自 shadcn/ui 的可重用 UI 组件
|
- `components/ui/` - 来自 shadcn/ui 的可重用 UI 组件
|
||||||
- `lib/api.ts` - 使用 axios 的 API 配置
|
- `lib/api.ts` - 使用 axios 的 API 配置
|
||||||
- `lib/queries.ts` - 用于数据获取的 TanStack Query hooks
|
- `lib/queries.ts` - 用于数据获取的 TanStack Query hooks
|
||||||
|
- `lib/utils.ts` - 工具函数
|
||||||
|
- `hooks/use-mobile.tsx` - 移动端检测钩子
|
||||||
|
- `hooks/use-toast.ts` - 通知钩子
|
||||||
|
|
||||||
## 数据结构
|
## 数据结构
|
||||||
|
|
||||||
@ -121,7 +140,14 @@ interface Photo {
|
|||||||
- `GET /api/photos` - 获取所有照片
|
- `GET /api/photos` - 获取所有照片
|
||||||
- `GET /api/photos/:id` - 获取单个照片
|
- `GET /api/photos/:id` - 获取单个照片
|
||||||
- `GET /api/categories` - 获取分类列表
|
- `GET /api/categories` - 获取分类列表
|
||||||
- 标准 CRUD 操作用于照片管理
|
- `POST /api/photos` - 添加新照片
|
||||||
|
- `PUT /api/photos/:id` - 更新照片信息
|
||||||
|
- `DELETE /api/photos/:id` - 删除照片
|
||||||
|
|
||||||
|
启动模拟 API 服务器:
|
||||||
|
```bash
|
||||||
|
cd frontend && node mock-api.js
|
||||||
|
```
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
@ -152,11 +178,19 @@ NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
|||||||
## 开发流程
|
## 开发流程
|
||||||
|
|
||||||
1. 所有开发都在 `frontend/` 目录中进行
|
1. 所有开发都在 `frontend/` 目录中进行
|
||||||
2. 使用 `make dev` 或 `bun run dev` 启动开发服务器
|
2. 首次设置:`make setup` 创建环境变量文件
|
||||||
3. 使用 `make lint` 检查代码规范问题
|
3. 使用 `make dev` 或 `bun run dev` 启动开发服务器
|
||||||
4. 使用 `make type-check` 运行 TypeScript 检查
|
4. 使用 `make lint` 检查代码规范问题
|
||||||
5. 部署前使用 `make build` 构建
|
5. 使用 `make type-check` 运行 TypeScript 检查
|
||||||
6. 使用 `make status` 检查项目健康状况
|
6. 使用 `make format` 格式化代码
|
||||||
|
7. 部署前使用 `make deploy-prep` 进行完整检查和构建
|
||||||
|
8. 使用 `make status` 检查项目健康状况
|
||||||
|
|
||||||
|
### 快速开始工作流程
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
make quick # 安装依赖并启动开发服务器
|
||||||
|
```
|
||||||
|
|
||||||
## Bun 特定说明
|
## Bun 特定说明
|
||||||
|
|
||||||
@ -182,3 +216,10 @@ NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
|||||||
- 构建时忽略 TypeScript 和 ESLint 错误(适用于开发环境)
|
- 构建时忽略 TypeScript 和 ESLint 错误(适用于开发环境)
|
||||||
- 图像未优化设置,便于开发调试
|
- 图像未优化设置,便于开发调试
|
||||||
- 端口自动检测(如果 3000 被占用,会尝试 3001、3002 等)
|
- 端口自动检测(如果 3000 被占用,会尝试 3001、3002 等)
|
||||||
|
- 使用 Prettier 进行代码格式化
|
||||||
|
- 支持通过 `make format` 统一代码风格
|
||||||
|
|
||||||
|
## 文件锁定和包管理
|
||||||
|
- 使用 `bun.lockb` 而非 `package-lock.json` 作为锁定文件
|
||||||
|
- 项目名称:`my-v0-project`(可能需要更新为更合适的名称)
|
||||||
|
- 所有包管理操作都应通过 Makefile 命令执行,确保使用 bun
|
||||||
46
docs/README.md
Normal file
46
docs/README.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# 产品文档
|
||||||
|
|
||||||
|
本目录包含摄影作品集项目的完整产品文档。
|
||||||
|
|
||||||
|
## 文档结构
|
||||||
|
|
||||||
|
### 📐 Design(设计文档)
|
||||||
|
- UI/UX 设计规范
|
||||||
|
- 组件设计系统
|
||||||
|
- 交互设计文档
|
||||||
|
- 品牌指南和视觉规范
|
||||||
|
|
||||||
|
### 🔌 API(API 文档)
|
||||||
|
- API 接口文档
|
||||||
|
- 数据结构定义
|
||||||
|
- 接口调用示例
|
||||||
|
- 错误码说明
|
||||||
|
|
||||||
|
### 👥 User Guide(用户指南)
|
||||||
|
- 用户操作手册
|
||||||
|
- 功能使用说明
|
||||||
|
- 常见问题解答
|
||||||
|
- 最佳实践指南
|
||||||
|
|
||||||
|
### 🛠️ Development(开发文档)
|
||||||
|
- 开发环境搭建
|
||||||
|
- 代码规范和约定
|
||||||
|
- 开发工作流程
|
||||||
|
- 测试指南
|
||||||
|
|
||||||
|
### 🚀 Deployment(部署文档)
|
||||||
|
- 部署配置说明
|
||||||
|
- 环境变量配置
|
||||||
|
- 服务器配置要求
|
||||||
|
- 部署流程和脚本
|
||||||
|
|
||||||
|
## 文档维护
|
||||||
|
|
||||||
|
请在相应的子目录中维护各类文档,确保文档的及时更新和准确性。
|
||||||
|
|
||||||
|
## 贡献指南
|
||||||
|
|
||||||
|
1. 在相应目录下创建或更新文档
|
||||||
|
2. 使用 Markdown 格式编写
|
||||||
|
3. 确保文档结构清晰、内容准确
|
||||||
|
4. 添加适当的图片和示例代码
|
||||||
175
docs/UI设计需求文档.md
Normal file
175
docs/UI设计需求文档.md
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
# 摄影作品集网站 - UI设计需求文档
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
### 1.1 项目定位
|
||||||
|
个人摄影作品展示网站,专注于高品质的视觉呈现和用户体验。
|
||||||
|
|
||||||
|
### 1.2 设计目标
|
||||||
|
- **极简美学**:突出作品本身,减少视觉干扰
|
||||||
|
- **专业感**:体现摄影师的专业水准
|
||||||
|
- **沉浸式体验**:让用户专注于作品欣赏
|
||||||
|
- **响应式设计**:全设备完美适配
|
||||||
|
|
||||||
|
## 2. 设计风格指南
|
||||||
|
|
||||||
|
### 2.1 色彩方案
|
||||||
|
- **主色调**:深色主题(#1a1a1a, #2d2d2d)
|
||||||
|
- **辅助色**:纯白文字(#ffffff)
|
||||||
|
- **强调色**:温暖金色(#d4af37)用于按钮和链接
|
||||||
|
- **背景色**:渐变深灰(#0f0f0f 到 #1e1e1e)
|
||||||
|
|
||||||
|
### 2.2 字体系统
|
||||||
|
- **标题字体**:Modern Sans(如 Inter, SF Pro)
|
||||||
|
- **正文字体**:清晰易读的无衬线字体
|
||||||
|
- **字重层次**:Light(300), Regular(400), Medium(500), Bold(700)
|
||||||
|
|
||||||
|
### 2.3 视觉层次
|
||||||
|
- **一级标题**:32px-48px,Bold
|
||||||
|
- **二级标题**:24px-32px,Medium
|
||||||
|
- **正文**:16px-18px,Regular
|
||||||
|
- **说明文字**:14px,Light
|
||||||
|
|
||||||
|
## 3. 页面布局设计
|
||||||
|
|
||||||
|
### 3.1 首页布局
|
||||||
|
```
|
||||||
|
[导航栏 - 固定在顶部]
|
||||||
|
[英雄区域 - 全屏背景图/视频]
|
||||||
|
- 摄影师姓名
|
||||||
|
- 简短介绍
|
||||||
|
- CTA按钮
|
||||||
|
[精选作品网格 - 3x2]
|
||||||
|
[关于简介预览]
|
||||||
|
[页脚]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 作品集页面
|
||||||
|
```
|
||||||
|
[面包屑导航]
|
||||||
|
[分类筛选器 - 水平排列]
|
||||||
|
[作品网格展示]
|
||||||
|
- 瀑布流布局
|
||||||
|
- 悬停效果
|
||||||
|
- 快速预览
|
||||||
|
[分页导航]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 作品详情页
|
||||||
|
```
|
||||||
|
[返回按钮]
|
||||||
|
[大图展示区域]
|
||||||
|
- 支持缩放
|
||||||
|
- 前后导航
|
||||||
|
[作品信息侧栏]
|
||||||
|
- 标题、描述
|
||||||
|
- 拍摄参数
|
||||||
|
- 时间地点
|
||||||
|
[相关推荐]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 时间线页面
|
||||||
|
```
|
||||||
|
[年份导航]
|
||||||
|
[时间线主体]
|
||||||
|
- 垂直时间轴
|
||||||
|
- 按月份分组
|
||||||
|
- 缩略图预览
|
||||||
|
- 作品数量统计
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 交互设计规范
|
||||||
|
|
||||||
|
### 4.1 悬停效果
|
||||||
|
- **图片悬停**:轻微放大(1.05x) + 蒙层显示标题
|
||||||
|
- **按钮悬停**:颜色渐变 + 轻微阴影
|
||||||
|
- **导航悬停**:下划线动画
|
||||||
|
|
||||||
|
### 4.2 加载动画
|
||||||
|
- **页面加载**:优雅的骨架屏
|
||||||
|
- **图片加载**:从模糊到清晰的渐进式加载
|
||||||
|
- **切换动画**:平滑的页面过渡
|
||||||
|
|
||||||
|
### 4.3 响应式交互
|
||||||
|
- **移动端**:手势滑动支持
|
||||||
|
- **桌面端**:键盘快捷键支持
|
||||||
|
- **触摸优化**:合适的点击区域大小
|
||||||
|
|
||||||
|
## 5. 组件设计规范
|
||||||
|
|
||||||
|
### 5.1 导航组件
|
||||||
|
```css
|
||||||
|
.navigation {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
background: rgba(26, 26, 26, 0.9);
|
||||||
|
padding: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 图片网格组件
|
||||||
|
- **网格间距**:16px-24px
|
||||||
|
- **图片比例**:保持原始比例
|
||||||
|
- **最小尺寸**:移动端 150px,桌面端 250px
|
||||||
|
- **最大列数**:桌面4列,平板3列,手机2列
|
||||||
|
|
||||||
|
### 5.3 模态框组件
|
||||||
|
- **背景蒙层**:rgba(0, 0, 0, 0.8)
|
||||||
|
- **关闭方式**:ESC键 + 点击蒙层 + 关闭按钮
|
||||||
|
- **内容区域**:最大宽度90vw,最大高度90vh
|
||||||
|
|
||||||
|
## 6. 移动端适配
|
||||||
|
|
||||||
|
### 6.1 断点设置
|
||||||
|
- **手机**:< 768px
|
||||||
|
- **平板**:768px - 1024px
|
||||||
|
- **桌面**:> 1024px
|
||||||
|
- **大屏**:> 1440px
|
||||||
|
|
||||||
|
### 6.2 移动端优化
|
||||||
|
- **触摸友好**:按钮最小44px
|
||||||
|
- **滑动操作**:支持左右滑动浏览
|
||||||
|
- **下拉刷新**:原生滑动体验
|
||||||
|
- **底部导航**:重要功能快速访问
|
||||||
|
|
||||||
|
## 7. 性能优化设计
|
||||||
|
|
||||||
|
### 7.1 图片优化
|
||||||
|
- **懒加载**:视口外图片延迟加载
|
||||||
|
- **渐进式**:先显示缩略图再加载高清
|
||||||
|
- **格式优化**:WebP格式优先,降级JPG
|
||||||
|
- **尺寸适配**:根据设备提供合适尺寸
|
||||||
|
|
||||||
|
### 7.2 加载体验
|
||||||
|
- **骨架屏**:内容加载前的占位
|
||||||
|
- **进度指示**:长时间加载的进度反馈
|
||||||
|
- **错误状态**:网络异常的友好提示
|
||||||
|
|
||||||
|
## 8. 辅助功能设计
|
||||||
|
|
||||||
|
### 8.1 可访问性
|
||||||
|
- **键盘导航**:所有功能键盘可操作
|
||||||
|
- **屏幕阅读器**:适当的ARIA标签
|
||||||
|
- **对比度**:满足WCAG 2.1 AA标准
|
||||||
|
- **焦点指示**:清晰的焦点样式
|
||||||
|
|
||||||
|
### 8.2 多语言支持
|
||||||
|
- **语言切换**:顶部导航显眼位置
|
||||||
|
- **文字方向**:支持RTL语言
|
||||||
|
- **日期格式**:本地化日期显示
|
||||||
|
|
||||||
|
## 9. 设计交付物
|
||||||
|
|
||||||
|
### 9.1 设计稿要求
|
||||||
|
- **高保真原型**:Figma/Sketch源文件
|
||||||
|
- **设计规范**:详细的设计系统文档
|
||||||
|
- **交互说明**:动效和交互的详细说明
|
||||||
|
- **响应式方案**:各断点的适配方案
|
||||||
|
|
||||||
|
### 9.2 开发协作
|
||||||
|
- **组件库**:可复用的UI组件
|
||||||
|
- **图标资源**:SVG格式图标集
|
||||||
|
- **字体文件**:web font资源
|
||||||
|
- **样式指南**:CSS变量和类名规范
|
||||||
25
docs/api/README.md
Normal file
25
docs/api/README.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# API 文档
|
||||||
|
|
||||||
|
本目录包含摄影作品集项目的 API 接口文档。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
- `endpoints.md` - API 端点详细说明
|
||||||
|
- `data-models.md` - 数据模型定义
|
||||||
|
- `authentication.md` - 认证机制说明
|
||||||
|
- `examples.md` - API 调用示例
|
||||||
|
- `error-codes.md` - 错误码说明
|
||||||
|
|
||||||
|
## API 基础信息
|
||||||
|
|
||||||
|
- 基础 URL: `http://localhost:3001/api`
|
||||||
|
- 数据格式: JSON
|
||||||
|
- 认证方式: 待实现
|
||||||
|
- 版本: v1
|
||||||
|
|
||||||
|
## 主要端点
|
||||||
|
|
||||||
|
- `/photos` - 照片相关操作
|
||||||
|
- `/categories` - 分类管理
|
||||||
|
- `/upload` - 文件上传(待实现)
|
||||||
|
- `/user` - 用户管理(待实现)
|
||||||
36
docs/deployment/README.md
Normal file
36
docs/deployment/README.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# 部署文档
|
||||||
|
|
||||||
|
本目录包含摄影作品集项目的部署相关文档。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
- `environments.md` - 环境配置说明
|
||||||
|
- `vercel-deployment.md` - Vercel 部署指南
|
||||||
|
- `docker-deployment.md` - Docker 部署指南
|
||||||
|
- `ci-cd.md` - CI/CD 配置说明
|
||||||
|
- `monitoring.md` - 监控和日志配置
|
||||||
|
- `backup.md` - 备份策略
|
||||||
|
|
||||||
|
## 部署准备
|
||||||
|
|
||||||
|
在部署前,请确保完成以下步骤:
|
||||||
|
|
||||||
|
1. 代码检查: `make lint`
|
||||||
|
2. 类型检查: `make type-check`
|
||||||
|
3. 构建测试: `make build`
|
||||||
|
4. 完整部署准备: `make deploy-prep`
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
确保在部署环境中配置以下环境变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PUBLIC_API_URL=your-api-url
|
||||||
|
```
|
||||||
|
|
||||||
|
## 支持的部署平台
|
||||||
|
|
||||||
|
- Vercel (推荐)
|
||||||
|
- Netlify
|
||||||
|
- Docker
|
||||||
|
- 传统服务器部署
|
||||||
18
docs/design/README.md
Normal file
18
docs/design/README.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# 设计文档
|
||||||
|
|
||||||
|
本目录包含摄影作品集项目的设计相关文档。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
- `ui-design.md` - UI 设计规范
|
||||||
|
- `component-system.md` - 组件设计系统
|
||||||
|
- `interaction-design.md` - 交互设计文档
|
||||||
|
- `brand-guide.md` - 品牌指南
|
||||||
|
- `assets/` - 设计资源文件夹
|
||||||
|
|
||||||
|
## 设计原则
|
||||||
|
|
||||||
|
- 简洁优雅的视觉风格
|
||||||
|
- 响应式设计,适配多种设备
|
||||||
|
- 突出摄影作品的展示效果
|
||||||
|
- 良好的用户体验和交互流程
|
||||||
29
docs/development/README.md
Normal file
29
docs/development/README.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# 开发文档
|
||||||
|
|
||||||
|
本目录包含摄影作品集项目的开发相关文档。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
- `setup.md` - 开发环境搭建
|
||||||
|
- `coding-standards.md` - 代码规范
|
||||||
|
- `architecture.md` - 架构设计说明
|
||||||
|
- `components.md` - 组件开发指南
|
||||||
|
- `testing.md` - 测试指南
|
||||||
|
- `troubleshooting.md` - 常见问题解决
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **前端**: Next.js 15 + React 19 + TypeScript
|
||||||
|
- **样式**: Tailwind CSS + shadcn/ui
|
||||||
|
- **状态管理**: TanStack Query + React Hooks
|
||||||
|
- **包管理**: bun
|
||||||
|
- **构建工具**: Next.js 内置构建系统
|
||||||
|
|
||||||
|
## 开发流程
|
||||||
|
|
||||||
|
1. 环境设置: `make setup`
|
||||||
|
2. 安装依赖: `make install`
|
||||||
|
3. 启动开发: `make dev`
|
||||||
|
4. 代码检查: `make lint`
|
||||||
|
5. 类型检查: `make type-check`
|
||||||
|
6. 代码格式化: `make format`
|
||||||
21
docs/user-guide/README.md
Normal file
21
docs/user-guide/README.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# 用户指南
|
||||||
|
|
||||||
|
本目录包含摄影作品集项目的用户使用指南。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
- `getting-started.md` - 快速入门指南
|
||||||
|
- `photo-management.md` - 照片管理功能
|
||||||
|
- `gallery-features.md` - 画廊功能说明
|
||||||
|
- `timeline-usage.md` - 时间轴使用方法
|
||||||
|
- `theme-settings.md` - 主题设置说明
|
||||||
|
- `faq.md` - 常见问题解答
|
||||||
|
|
||||||
|
## 主要功能
|
||||||
|
|
||||||
|
- 📸 照片画廊浏览
|
||||||
|
- 📅 时间轴查看
|
||||||
|
- 🏷️ 分类筛选
|
||||||
|
- 🔍 照片详情查看
|
||||||
|
- 🌙 深色模式切换
|
||||||
|
- 📱 移动端适配
|
||||||
524
docs/前端开发文档.md
Normal file
524
docs/前端开发文档.md
Normal file
@ -0,0 +1,524 @@
|
|||||||
|
# 摄影作品集网站 - 前端开发文档
|
||||||
|
|
||||||
|
## 1. 技术架构
|
||||||
|
|
||||||
|
### 1.1 技术栈选择
|
||||||
|
- **框架**:Next.js 14 (App Router)
|
||||||
|
- **样式**:Tailwind CSS + CSS Modules
|
||||||
|
- **状态管理**:Zustand + React Query
|
||||||
|
- **图片处理**:Sharp + Next.js Image
|
||||||
|
- **动画**:Framer Motion
|
||||||
|
- **UI组件**:Headless UI + 自定义组件
|
||||||
|
|
||||||
|
### 1.2 项目结构
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # App Router页面
|
||||||
|
│ ├── layout.tsx # 根布局
|
||||||
|
│ ├── page.tsx # 首页
|
||||||
|
│ ├── gallery/ # 作品集页面
|
||||||
|
│ ├── timeline/ # 时间线页面
|
||||||
|
│ └── about/ # 关于页面
|
||||||
|
├── components/ # 可复用组件
|
||||||
|
│ ├── ui/ # 基础UI组件
|
||||||
|
│ ├── gallery/ # 作品集相关组件
|
||||||
|
│ ├── timeline/ # 时间线组件
|
||||||
|
│ └── layout/ # 布局组件
|
||||||
|
├── hooks/ # 自定义Hooks
|
||||||
|
├── lib/ # 工具函数
|
||||||
|
├── stores/ # 状态管理
|
||||||
|
├── types/ # TypeScript类型
|
||||||
|
└── styles/ # 全局样式
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 核心功能实现
|
||||||
|
|
||||||
|
### 2.1 图片管理系统
|
||||||
|
|
||||||
|
#### 多格式图片处理
|
||||||
|
```typescript
|
||||||
|
// lib/imageUtils.ts
|
||||||
|
export interface ImageFormats {
|
||||||
|
raw?: string; // RAW文件路径
|
||||||
|
jpg: string; // JPG文件路径
|
||||||
|
webp?: string; // WebP优化版本
|
||||||
|
thumb: string; // 缩略图
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhotoData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
date: string;
|
||||||
|
formats: ImageFormats;
|
||||||
|
metadata: {
|
||||||
|
camera?: string;
|
||||||
|
lens?: string;
|
||||||
|
settings?: {
|
||||||
|
iso: number;
|
||||||
|
aperture: string;
|
||||||
|
shutter: string;
|
||||||
|
focal: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
collections: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 响应式图片组件
|
||||||
|
```typescript
|
||||||
|
// components/ui/ResponsiveImage.tsx
|
||||||
|
interface ResponsiveImageProps {
|
||||||
|
photo: PhotoData;
|
||||||
|
sizes: string;
|
||||||
|
priority?: boolean;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponsiveImage({ photo, sizes, priority, className, onClick }: ResponsiveImageProps) {
|
||||||
|
return (
|
||||||
|
<div className={`relative overflow-hidden ${className}`} onClick={onClick}>
|
||||||
|
<Image
|
||||||
|
src={photo.formats.webp || photo.formats.jpg}
|
||||||
|
alt={photo.title}
|
||||||
|
fill
|
||||||
|
sizes={sizes}
|
||||||
|
priority={priority}
|
||||||
|
className="object-cover transition-transform hover:scale-105"
|
||||||
|
placeholder="blur"
|
||||||
|
blurDataURL={photo.formats.thumb}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 作品集网格系统
|
||||||
|
|
||||||
|
#### 自适应网格布局
|
||||||
|
```typescript
|
||||||
|
// components/gallery/PhotoGrid.tsx
|
||||||
|
export function PhotoGrid({ photos }: { photos: PhotoData[] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6">
|
||||||
|
{photos.map((photo, index) => (
|
||||||
|
<div
|
||||||
|
key={photo.id}
|
||||||
|
className={`aspect-square ${
|
||||||
|
index % 7 === 0 ? 'md:col-span-2 md:row-span-2' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ResponsiveImage
|
||||||
|
photo={photo}
|
||||||
|
sizes="(max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
||||||
|
priority={index < 8}
|
||||||
|
className="w-full h-full cursor-pointer"
|
||||||
|
onClick={() => openLightbox(photo)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 虚拟化长列表
|
||||||
|
```typescript
|
||||||
|
// hooks/useVirtualGrid.ts
|
||||||
|
export function useVirtualGrid(items: PhotoData[], itemHeight: number) {
|
||||||
|
const [visibleItems, setVisibleItems] = useState<PhotoData[]>([]);
|
||||||
|
const [startIndex, setStartIndex] = useState(0);
|
||||||
|
|
||||||
|
const handleScroll = useCallback((scrollTop: number, containerHeight: number) => {
|
||||||
|
const newStartIndex = Math.floor(scrollTop / itemHeight);
|
||||||
|
const endIndex = Math.min(
|
||||||
|
newStartIndex + Math.ceil(containerHeight / itemHeight) + 2,
|
||||||
|
items.length
|
||||||
|
);
|
||||||
|
|
||||||
|
setStartIndex(newStartIndex);
|
||||||
|
setVisibleItems(items.slice(newStartIndex, endIndex));
|
||||||
|
}, [items, itemHeight]);
|
||||||
|
|
||||||
|
return { visibleItems, startIndex, handleScroll };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 时间线功能
|
||||||
|
|
||||||
|
#### 时间线数据结构
|
||||||
|
```typescript
|
||||||
|
// types/timeline.ts
|
||||||
|
export interface TimelineGroup {
|
||||||
|
year: number;
|
||||||
|
months: {
|
||||||
|
[month: number]: {
|
||||||
|
photos: PhotoData[];
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineData {
|
||||||
|
groups: TimelineGroup[];
|
||||||
|
totalPhotos: number;
|
||||||
|
yearRange: [number, number];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 时间线组件
|
||||||
|
```typescript
|
||||||
|
// components/timeline/Timeline.tsx
|
||||||
|
export function Timeline({ data }: { data: TimelineData }) {
|
||||||
|
const [selectedYear, setSelectedYear] = useState<number>(data.yearRange[1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex">
|
||||||
|
{/* 年份导航 */}
|
||||||
|
<YearNavigation
|
||||||
|
years={data.groups.map(g => g.year)}
|
||||||
|
selectedYear={selectedYear}
|
||||||
|
onYearSelect={setSelectedYear}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 时间线内容 */}
|
||||||
|
<div className="flex-1 ml-8">
|
||||||
|
{data.groups
|
||||||
|
.filter(group => group.year === selectedYear)
|
||||||
|
.map(group => (
|
||||||
|
<YearSection key={group.year} group={group} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 灯箱组件
|
||||||
|
|
||||||
|
#### 全功能图片查看器
|
||||||
|
```typescript
|
||||||
|
// components/ui/Lightbox.tsx
|
||||||
|
export function Lightbox() {
|
||||||
|
const { isOpen, currentPhoto, photos, currentIndex } = useLightboxStore();
|
||||||
|
const [isZoomed, setIsZoomed] = useState(false);
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
if (!isOpen || !currentPhoto) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
>
|
||||||
|
{/* 图片区域 */}
|
||||||
|
<div className="relative max-w-full max-h-full">
|
||||||
|
<motion.img
|
||||||
|
src={currentPhoto.formats.jpg}
|
||||||
|
alt={currentPhoto.title}
|
||||||
|
className={`max-w-full max-h-full ${isZoomed ? 'cursor-zoom-out' : 'cursor-zoom-in'}`}
|
||||||
|
style={isZoomed ? { transform: `scale(2) translate(${position.x}px, ${position.y}px)` } : {}}
|
||||||
|
onClick={handleImageClick}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 导航控件 */}
|
||||||
|
<NavigationControls
|
||||||
|
currentIndex={currentIndex}
|
||||||
|
total={photos.length}
|
||||||
|
onPrevious={goToPrevious}
|
||||||
|
onNext={goToNext}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 信息面板 */}
|
||||||
|
<PhotoInfoPanel photo={currentPhoto} />
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 性能优化策略
|
||||||
|
|
||||||
|
### 3.1 图片优化
|
||||||
|
|
||||||
|
#### 渐进式加载
|
||||||
|
```typescript
|
||||||
|
// hooks/useProgressiveImage.ts
|
||||||
|
export function useProgressiveImage(src: string, placeholderSrc: string) {
|
||||||
|
const [currentSrc, setCurrentSrc] = useState(placeholderSrc);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = src;
|
||||||
|
img.onload = () => {
|
||||||
|
setCurrentSrc(src);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
return { src: currentSrc, loading };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 智能预加载
|
||||||
|
```typescript
|
||||||
|
// hooks/useImagePreloader.ts
|
||||||
|
export function useImagePreloader(photos: PhotoData[], currentIndex: number) {
|
||||||
|
useEffect(() => {
|
||||||
|
// 预加载当前图片的前后各2张
|
||||||
|
const preloadRange = 2;
|
||||||
|
const start = Math.max(0, currentIndex - preloadRange);
|
||||||
|
const end = Math.min(photos.length, currentIndex + preloadRange + 1);
|
||||||
|
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
if (i !== currentIndex) {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = photos[i].formats.jpg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [photos, currentIndex]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 状态管理优化
|
||||||
|
|
||||||
|
#### Zustand Store设计
|
||||||
|
```typescript
|
||||||
|
// stores/galleryStore.ts
|
||||||
|
interface GalleryState {
|
||||||
|
photos: PhotoData[];
|
||||||
|
collections: Collection[];
|
||||||
|
currentCollection: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setPhotos: (photos: PhotoData[]) => void;
|
||||||
|
setCurrentCollection: (id: string | null) => void;
|
||||||
|
addPhotos: (photos: PhotoData[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||||
|
photos: [],
|
||||||
|
collections: [],
|
||||||
|
currentCollection: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
setPhotos: (photos) => set({ photos }),
|
||||||
|
setCurrentCollection: (id) => set({ currentCollection: id }),
|
||||||
|
addPhotos: (newPhotos) => set((state) => ({
|
||||||
|
photos: [...state.photos, ...newPhotos]
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 代码分割
|
||||||
|
|
||||||
|
#### 动态导入
|
||||||
|
```typescript
|
||||||
|
// app/gallery/page.tsx
|
||||||
|
const LazyPhotoGrid = dynamic(() => import('@/components/gallery/PhotoGrid'), {
|
||||||
|
loading: () => <PhotoGridSkeleton />,
|
||||||
|
ssr: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const LazyLightbox = dynamic(() => import('@/components/ui/Lightbox'), {
|
||||||
|
ssr: false
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 移动端优化
|
||||||
|
|
||||||
|
### 4.1 触摸手势
|
||||||
|
```typescript
|
||||||
|
// hooks/useSwipeGesture.ts
|
||||||
|
export function useSwipeGesture(onSwipeLeft: () => void, onSwipeRight: () => void) {
|
||||||
|
const touchStart = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const touchEnd = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
const handleTouchStart = (e: TouchEvent) => {
|
||||||
|
touchEnd.current = null;
|
||||||
|
touchStart.current = {
|
||||||
|
x: e.targetTouches[0].clientX,
|
||||||
|
y: e.targetTouches[0].clientY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (!touchStart.current || !touchEnd.current) return;
|
||||||
|
|
||||||
|
const distance = touchStart.current.x - touchEnd.current.x;
|
||||||
|
const isLeftSwipe = distance > 50;
|
||||||
|
const isRightSwipe = distance < -50;
|
||||||
|
|
||||||
|
if (isLeftSwipe) onSwipeLeft();
|
||||||
|
if (isRightSwipe) onSwipeRight();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { handleTouchStart, handleTouchEnd };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 移动端导航
|
||||||
|
```typescript
|
||||||
|
// components/layout/MobileNavigation.tsx
|
||||||
|
export function MobileNavigation() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 汉堡菜单按钮 */}
|
||||||
|
<button
|
||||||
|
className="md:hidden fixed top-4 right-4 z-50"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<HamburgerIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 侧边导航 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.nav
|
||||||
|
initial={{ x: '100%' }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: '100%' }}
|
||||||
|
className="fixed inset-y-0 right-0 w-64 bg-black/95 backdrop-blur z-40"
|
||||||
|
>
|
||||||
|
<NavigationItems />
|
||||||
|
</motion.nav>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. SEO优化
|
||||||
|
|
||||||
|
### 5.1 元数据管理
|
||||||
|
```typescript
|
||||||
|
// app/gallery/[collection]/page.tsx
|
||||||
|
export async function generateMetadata({ params }: { params: { collection: string } }) {
|
||||||
|
const collection = await getCollection(params.collection);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${collection.name} | 摄影作品集`,
|
||||||
|
description: collection.description,
|
||||||
|
openGraph: {
|
||||||
|
title: collection.name,
|
||||||
|
description: collection.description,
|
||||||
|
images: [collection.coverImage],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 结构化数据
|
||||||
|
```typescript
|
||||||
|
// components/seo/StructuredData.tsx
|
||||||
|
export function PhotoStructuredData({ photo }: { photo: PhotoData }) {
|
||||||
|
const structuredData = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Photograph',
|
||||||
|
name: photo.title,
|
||||||
|
description: photo.description,
|
||||||
|
contentUrl: photo.formats.jpg,
|
||||||
|
thumbnail: photo.formats.thumb,
|
||||||
|
dateCreated: photo.date,
|
||||||
|
creator: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: '摄影师姓名',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 部署和监控
|
||||||
|
|
||||||
|
### 6.1 构建优化
|
||||||
|
```javascript
|
||||||
|
// next.config.js
|
||||||
|
module.exports = {
|
||||||
|
images: {
|
||||||
|
domains: ['your-domain.com'],
|
||||||
|
formats: ['image/webp', 'image/avif'],
|
||||||
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
|
||||||
|
imageSizes: [16, 32, 48, 64, 96, 128, 256],
|
||||||
|
},
|
||||||
|
|
||||||
|
experimental: {
|
||||||
|
optimizeCss: true,
|
||||||
|
optimizePackageImports: ['framer-motion'],
|
||||||
|
},
|
||||||
|
|
||||||
|
compiler: {
|
||||||
|
removeConsole: process.env.NODE_ENV === 'production',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 性能监控
|
||||||
|
```typescript
|
||||||
|
// lib/analytics.ts
|
||||||
|
export function trackPageView(url: string) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
gtag('config', 'GA_TRACKING_ID', {
|
||||||
|
page_location: url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackPhotoView(photoId: string) {
|
||||||
|
gtag('event', 'photo_view', {
|
||||||
|
event_category: 'engagement',
|
||||||
|
event_label: photoId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 开发工具配置
|
||||||
|
|
||||||
|
### 7.1 TypeScript配置
|
||||||
|
```json
|
||||||
|
// tsconfig.json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@/components/*": ["./src/components/*"],
|
||||||
|
"@/lib/*": ["./src/lib/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 代码质量工具
|
||||||
|
```json
|
||||||
|
// package.json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"lint": "next lint",
|
||||||
|
"lint:fix": "next lint --fix",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
543
docs/后端开发文档.md
Normal file
543
docs/后端开发文档.md
Normal file
@ -0,0 +1,543 @@
|
|||||||
|
# 摄影作品集网站 - 后端开发文档
|
||||||
|
|
||||||
|
## 1. 技术架构
|
||||||
|
|
||||||
|
### 1.1 技术栈选择
|
||||||
|
- **后端框架**:Node.js + Express/Fastify
|
||||||
|
- **数据库**:PostgreSQL + Redis
|
||||||
|
- **图片处理**:Sharp + ImageMagick
|
||||||
|
- **文件存储**:MinIO/AWS S3
|
||||||
|
- **缓存策略**:Redis + CDN
|
||||||
|
- **任务队列**:Bull Queue + Redis
|
||||||
|
|
||||||
|
### 1.2 系统架构
|
||||||
|
```
|
||||||
|
[客户端] <-> [CDN] <-> [API网关] <-> [应用服务器]
|
||||||
|
├── [图片处理服务]
|
||||||
|
├── [文件管理服务]
|
||||||
|
└── [元数据服务]
|
||||||
|
↓
|
||||||
|
[缓存层 Redis] <-> [数据库 PostgreSQL]
|
||||||
|
↓
|
||||||
|
[对象存储 MinIO/S3]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 多格式图片处理系统
|
||||||
|
|
||||||
|
### 2.1 图片格式管理
|
||||||
|
```javascript
|
||||||
|
// services/ImageProcessingService.js
|
||||||
|
class ImageProcessingService {
|
||||||
|
async processRawPhoto(rawFile, metadata) {
|
||||||
|
const formats = {};
|
||||||
|
|
||||||
|
// 保存原始RAW文件
|
||||||
|
formats.raw = await this.saveRawFile(rawFile);
|
||||||
|
|
||||||
|
// 生成高质量JPG
|
||||||
|
formats.jpg = await this.generateJPG(rawFile, { quality: 95 });
|
||||||
|
|
||||||
|
// 生成WebP格式
|
||||||
|
formats.webp = await this.generateWebP(rawFile, { quality: 85 });
|
||||||
|
|
||||||
|
// 生成多种尺寸的缩略图
|
||||||
|
formats.thumbnails = await this.generateThumbnails(rawFile, [
|
||||||
|
{ name: 'thumb', width: 300 },
|
||||||
|
{ name: 'medium', width: 800 },
|
||||||
|
{ name: 'large', width: 1600 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
return formats;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateWebP(sourceFile, options) {
|
||||||
|
return sharp(sourceFile)
|
||||||
|
.webp(options)
|
||||||
|
.toBuffer()
|
||||||
|
.then(buffer => this.uploadToStorage(buffer, 'webp'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 智能压缩算法
|
||||||
|
```javascript
|
||||||
|
// utils/compressionUtils.js
|
||||||
|
export class SmartCompression {
|
||||||
|
static async optimizeForDevice(imageBuffer, deviceType) {
|
||||||
|
const config = {
|
||||||
|
mobile: { width: 800, quality: 75 },
|
||||||
|
tablet: { width: 1200, quality: 80 },
|
||||||
|
desktop: { width: 1920, quality: 85 }
|
||||||
|
};
|
||||||
|
|
||||||
|
return sharp(imageBuffer)
|
||||||
|
.resize(config[deviceType].width, null, {
|
||||||
|
withoutEnlargement: true
|
||||||
|
})
|
||||||
|
.jpeg({ quality: config[deviceType].quality })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 数据模型设计
|
||||||
|
|
||||||
|
### 3.1 数据库Schema
|
||||||
|
```sql
|
||||||
|
-- 图片表
|
||||||
|
CREATE TABLE photos (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
original_filename VARCHAR(255),
|
||||||
|
file_size BIGINT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- 元数据
|
||||||
|
camera VARCHAR(100),
|
||||||
|
lens VARCHAR(100),
|
||||||
|
iso INTEGER,
|
||||||
|
aperture VARCHAR(10),
|
||||||
|
shutter_speed VARCHAR(20),
|
||||||
|
focal_length VARCHAR(20),
|
||||||
|
|
||||||
|
-- 位置信息
|
||||||
|
latitude DECIMAL(10, 8),
|
||||||
|
longitude DECIMAL(11, 8),
|
||||||
|
location_name VARCHAR(255),
|
||||||
|
|
||||||
|
-- 状态
|
||||||
|
status VARCHAR(20) DEFAULT 'processing',
|
||||||
|
visibility VARCHAR(20) DEFAULT 'public'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 文件格式表
|
||||||
|
CREATE TABLE photo_formats (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
photo_id UUID REFERENCES photos(id) ON DELETE CASCADE,
|
||||||
|
format_type VARCHAR(10) NOT NULL, -- 'raw', 'jpg', 'webp', 'thumb'
|
||||||
|
file_path VARCHAR(500) NOT NULL,
|
||||||
|
file_size BIGINT,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 收藏夹表
|
||||||
|
CREATE TABLE collections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
cover_photo_id UUID REFERENCES photos(id),
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 图片收藏夹关联表
|
||||||
|
CREATE TABLE photo_collections (
|
||||||
|
photo_id UUID REFERENCES photos(id) ON DELETE CASCADE,
|
||||||
|
collection_id UUID REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY (photo_id, collection_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 数据访问层
|
||||||
|
```javascript
|
||||||
|
// models/PhotoModel.js
|
||||||
|
class PhotoModel {
|
||||||
|
static async findWithFormats(photoId) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
p.*,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'format_type', pf.format_type,
|
||||||
|
'file_path', pf.file_path,
|
||||||
|
'width', pf.width,
|
||||||
|
'height', pf.height
|
||||||
|
)
|
||||||
|
) as formats
|
||||||
|
FROM photos p
|
||||||
|
LEFT JOIN photo_formats pf ON p.id = pf.photo_id
|
||||||
|
WHERE p.id = $1
|
||||||
|
GROUP BY p.id
|
||||||
|
`;
|
||||||
|
|
||||||
|
return await db.query(query, [photoId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findByTimeline(year, month = null) {
|
||||||
|
let query = `
|
||||||
|
SELECT * FROM photos
|
||||||
|
WHERE EXTRACT(YEAR FROM created_at) = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = [year];
|
||||||
|
|
||||||
|
if (month) {
|
||||||
|
query += ` AND EXTRACT(MONTH FROM created_at) = $2`;
|
||||||
|
params.push(month);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY created_at DESC`;
|
||||||
|
|
||||||
|
return await db.query(query, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. API设计
|
||||||
|
|
||||||
|
### 4.1 RESTful API结构
|
||||||
|
```javascript
|
||||||
|
// routes/api/photos.js
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取图片列表
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
collection,
|
||||||
|
year,
|
||||||
|
month
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const photos = await PhotoService.getPhotos({
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
collection,
|
||||||
|
year: year ? parseInt(year) : null,
|
||||||
|
month: month ? parseInt(month) : null
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: photos,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: photos.total
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取时间线数据
|
||||||
|
router.get('/timeline', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeline = await PhotoService.getTimeline();
|
||||||
|
res.json({ data: timeline });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 上传图片
|
||||||
|
router.post('/', upload.single('photo'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await PhotoService.uploadPhoto(req.file, req.body);
|
||||||
|
res.status(201).json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 GraphQL Schema (可选)
|
||||||
|
```graphql
|
||||||
|
type Photo {
|
||||||
|
id: ID!
|
||||||
|
title: String!
|
||||||
|
description: String
|
||||||
|
createdAt: DateTime!
|
||||||
|
|
||||||
|
formats: [PhotoFormat!]!
|
||||||
|
metadata: PhotoMetadata
|
||||||
|
collections: [Collection!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type PhotoFormat {
|
||||||
|
type: FormatType!
|
||||||
|
url: String!
|
||||||
|
width: Int
|
||||||
|
height: Int
|
||||||
|
fileSize: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FormatType {
|
||||||
|
RAW
|
||||||
|
JPG
|
||||||
|
WEBP
|
||||||
|
THUMBNAIL
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
photos(
|
||||||
|
first: Int
|
||||||
|
after: String
|
||||||
|
collection: ID
|
||||||
|
year: Int
|
||||||
|
month: Int
|
||||||
|
): PhotoConnection!
|
||||||
|
|
||||||
|
timeline: [TimelineGroup!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
uploadPhoto(input: PhotoUploadInput!): Photo!
|
||||||
|
updatePhoto(id: ID!, input: PhotoUpdateInput!): Photo!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 文件存储管理
|
||||||
|
|
||||||
|
### 5.1 存储策略
|
||||||
|
```javascript
|
||||||
|
// services/StorageService.js
|
||||||
|
class StorageService {
|
||||||
|
constructor() {
|
||||||
|
this.storage = process.env.NODE_ENV === 'production'
|
||||||
|
? new S3Storage()
|
||||||
|
: new LocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadPhoto(buffer, metadata) {
|
||||||
|
const filename = this.generateFilename(metadata);
|
||||||
|
const path = this.getStoragePath(metadata.date, filename);
|
||||||
|
|
||||||
|
// 上传到存储服务
|
||||||
|
const url = await this.storage.upload(buffer, path);
|
||||||
|
|
||||||
|
// 更新CDN缓存
|
||||||
|
await this.invalidateCache(url);
|
||||||
|
|
||||||
|
return { url, path };
|
||||||
|
}
|
||||||
|
|
||||||
|
generateFilename(metadata) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const random = Math.random().toString(36).substr(2, 9);
|
||||||
|
return `${timestamp}_${random}.${metadata.extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStoragePath(date, filename) {
|
||||||
|
const year = new Date(date).getFullYear();
|
||||||
|
const month = String(new Date(date).getMonth() + 1).padStart(2, '0');
|
||||||
|
return `photos/${year}/${month}/${filename}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 CDN集成
|
||||||
|
```javascript
|
||||||
|
// services/CDNService.js
|
||||||
|
class CDNService {
|
||||||
|
static getOptimizedUrl(originalUrl, options = {}) {
|
||||||
|
const {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
quality = 85,
|
||||||
|
format = 'auto'
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// 使用ImageKit或Cloudinary等服务
|
||||||
|
const transformations = [];
|
||||||
|
|
||||||
|
if (width) transformations.push(`w_${width}`);
|
||||||
|
if (height) transformations.push(`h_${height}`);
|
||||||
|
if (quality) transformations.push(`q_${quality}`);
|
||||||
|
if (format !== 'auto') transformations.push(`f_${format}`);
|
||||||
|
|
||||||
|
return `${CDN_BASE_URL}/${transformations.join(',')}/auto/${originalUrl}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 自动化处理流程
|
||||||
|
|
||||||
|
### 6.1 图片上传队列
|
||||||
|
```javascript
|
||||||
|
// jobs/imageProcessingJob.js
|
||||||
|
const Queue = require('bull');
|
||||||
|
const imageQueue = new Queue('image processing');
|
||||||
|
|
||||||
|
imageQueue.process(async (job) => {
|
||||||
|
const { photoId, rawFilePath } = job.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 提取EXIF数据
|
||||||
|
const metadata = await extractMetadata(rawFilePath);
|
||||||
|
|
||||||
|
// 2. 生成多种格式
|
||||||
|
const formats = await generateFormats(rawFilePath);
|
||||||
|
|
||||||
|
// 3. 保存到数据库
|
||||||
|
await savePhotoFormats(photoId, formats);
|
||||||
|
|
||||||
|
// 4. 清理临时文件
|
||||||
|
await cleanupTempFiles(rawFilePath);
|
||||||
|
|
||||||
|
// 5. 发送完成通知
|
||||||
|
await notifyProcessingComplete(photoId);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Image processing failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加任务到队列
|
||||||
|
const addImageProcessingJob = (photoId, rawFilePath) => {
|
||||||
|
return imageQueue.add({
|
||||||
|
photoId,
|
||||||
|
rawFilePath
|
||||||
|
}, {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: 'exponential',
|
||||||
|
delay: 2000
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 自动分类系统
|
||||||
|
```javascript
|
||||||
|
// services/AutoTaggingService.js
|
||||||
|
class AutoTaggingService {
|
||||||
|
static async analyzePhoto(photoBuffer) {
|
||||||
|
// 使用AI服务进行图片分析
|
||||||
|
const analysis = await this.callVisionAPI(photoBuffer);
|
||||||
|
|
||||||
|
const tags = [];
|
||||||
|
|
||||||
|
// 提取颜色主题
|
||||||
|
const colors = analysis.colors.map(c => c.name);
|
||||||
|
tags.push(...colors);
|
||||||
|
|
||||||
|
// 识别物体和场景
|
||||||
|
const objects = analysis.objects.map(o => o.name);
|
||||||
|
tags.push(...objects);
|
||||||
|
|
||||||
|
// 检测拍摄风格
|
||||||
|
const style = this.detectPhotographyStyle(analysis);
|
||||||
|
if (style) tags.push(style);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tags,
|
||||||
|
confidence: analysis.confidence,
|
||||||
|
description: analysis.description
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static detectPhotographyStyle(analysis) {
|
||||||
|
// 基于构图和内容判断摄影风格
|
||||||
|
if (analysis.faces.length > 0) return 'portrait';
|
||||||
|
if (analysis.landscape) return 'landscape';
|
||||||
|
if (analysis.architecture) return 'architecture';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 缓存策略
|
||||||
|
|
||||||
|
### 7.1 多级缓存
|
||||||
|
```javascript
|
||||||
|
// services/CacheService.js
|
||||||
|
class CacheService {
|
||||||
|
constructor() {
|
||||||
|
this.redis = new Redis(process.env.REDIS_URL);
|
||||||
|
this.memCache = new NodeCache({ stdTTL: 300 }); // 5分钟
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPhotos(cacheKey, fetcher) {
|
||||||
|
// L1: 内存缓存
|
||||||
|
let data = this.memCache.get(cacheKey);
|
||||||
|
if (data) return data;
|
||||||
|
|
||||||
|
// L2: Redis缓存
|
||||||
|
data = await this.redis.get(cacheKey);
|
||||||
|
if (data) {
|
||||||
|
data = JSON.parse(data);
|
||||||
|
this.memCache.set(cacheKey, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// L3: 数据库查询
|
||||||
|
data = await fetcher();
|
||||||
|
|
||||||
|
// 缓存结果
|
||||||
|
await this.redis.setex(cacheKey, 3600, JSON.stringify(data)); // 1小时
|
||||||
|
this.memCache.set(cacheKey, data);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidatePattern(pattern) {
|
||||||
|
const keys = await this.redis.keys(pattern);
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await this.redis.del(...keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理内存缓存
|
||||||
|
this.memCache.flushAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 性能监控
|
||||||
|
|
||||||
|
### 8.1 APM集成
|
||||||
|
```javascript
|
||||||
|
// middleware/monitoring.js
|
||||||
|
const performanceMiddleware = (req, res, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
res.on('finish', () => {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
// 记录性能指标
|
||||||
|
metrics.timing('api.response_time', duration, {
|
||||||
|
route: req.route?.path,
|
||||||
|
method: req.method,
|
||||||
|
status: res.statusCode
|
||||||
|
});
|
||||||
|
|
||||||
|
// 慢查询告警
|
||||||
|
if (duration > 1000) {
|
||||||
|
logger.warn('Slow API response', {
|
||||||
|
url: req.url,
|
||||||
|
duration,
|
||||||
|
method: req.method
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 健康检查
|
||||||
|
```javascript
|
||||||
|
// routes/health.js
|
||||||
|
router.get('/health', async (req, res) => {
|
||||||
|
const checks = {
|
||||||
|
database: await checkDatabase(),
|
||||||
|
redis: await checkRedis(),
|
||||||
|
storage: await checkStorage(),
|
||||||
|
imageProcessing: await checkImageService()
|
||||||
|
};
|
||||||
|
|
||||||
|
const healthy = Object.values(checks).every(check => check.status === 'ok');
|
||||||
|
|
||||||
|
res.status(healthy ? 200 : 503).json({
|
||||||
|
status: healthy ? 'healthy' : 'unhealthy',
|
||||||
|
checks,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
562
docs/测试需求文档.md
Normal file
562
docs/测试需求文档.md
Normal file
@ -0,0 +1,562 @@
|
|||||||
|
# 摄影作品集网站 - 测试需求文档
|
||||||
|
|
||||||
|
## 1. 测试策略概述
|
||||||
|
|
||||||
|
### 1.1 测试目标
|
||||||
|
- **功能完整性**:确保所有功能按需求正常工作
|
||||||
|
- **性能稳定性**:验证系统在各种负载下的表现
|
||||||
|
- **用户体验**:保证界面交互流畅自然
|
||||||
|
- **兼容性**:确保跨设备、跨浏览器的一致性
|
||||||
|
- **安全性**:验证数据安全和用户隐私保护
|
||||||
|
|
||||||
|
### 1.2 测试类型分布
|
||||||
|
- **单元测试**:40%(组件、工具函数、API端点)
|
||||||
|
- **集成测试**:30%(模块间交互、数据流)
|
||||||
|
- **端到端测试**:20%(用户完整流程)
|
||||||
|
- **性能测试**:10%(加载速度、响应时间)
|
||||||
|
|
||||||
|
## 2. 功能测试用例
|
||||||
|
|
||||||
|
### 2.1 图片管理功能
|
||||||
|
|
||||||
|
#### 2.1.1 图片上传测试
|
||||||
|
```javascript
|
||||||
|
// tests/upload.test.js
|
||||||
|
describe('图片上传功能', () => {
|
||||||
|
test('应该能上传RAW格式图片', async () => {
|
||||||
|
const file = new File([rawImageBuffer], 'test.cr2', { type: 'image/x-canon-cr2' });
|
||||||
|
const result = await uploadPhoto(file);
|
||||||
|
|
||||||
|
expect(result.status).toBe('success');
|
||||||
|
expect(result.formats).toHaveProperty('raw');
|
||||||
|
expect(result.formats).toHaveProperty('jpg');
|
||||||
|
expect(result.formats).toHaveProperty('webp');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该能上传JPEG格式图片', async () => {
|
||||||
|
const file = new File([jpegBuffer], 'test.jpg', { type: 'image/jpeg' });
|
||||||
|
const result = await uploadPhoto(file);
|
||||||
|
|
||||||
|
expect(result.status).toBe('success');
|
||||||
|
expect(result.formats.jpg).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该拒绝不支持的文件格式', async () => {
|
||||||
|
const file = new File([textBuffer], 'test.txt', { type: 'text/plain' });
|
||||||
|
|
||||||
|
await expect(uploadPhoto(file)).rejects.toThrow('不支持的文件格式');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该拒绝过大的文件', async () => {
|
||||||
|
const largeFile = new File([new ArrayBuffer(100 * 1024 * 1024)], 'large.jpg');
|
||||||
|
|
||||||
|
await expect(uploadPhoto(largeFile)).rejects.toThrow('文件大小超出限制');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.1.2 图片展示测试
|
||||||
|
```javascript
|
||||||
|
describe('图片展示功能', () => {
|
||||||
|
test('应该正确显示图片网格', async () => {
|
||||||
|
render(<PhotoGrid photos={mockPhotos} />);
|
||||||
|
|
||||||
|
// 验证网格布局
|
||||||
|
const grid = screen.getByTestId('photo-grid');
|
||||||
|
expect(grid).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 验证图片数量
|
||||||
|
const images = screen.getAllByRole('img');
|
||||||
|
expect(images).toHaveLength(mockPhotos.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该支持懒加载', async () => {
|
||||||
|
const { container } = render(<PhotoGrid photos={longPhotoList} />);
|
||||||
|
|
||||||
|
// 只有可视区域的图片应该被加载
|
||||||
|
const loadedImages = container.querySelectorAll('img[src]');
|
||||||
|
expect(loadedImages.length).toBeLessThan(longPhotoList.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该响应式适配不同屏幕', () => {
|
||||||
|
// 桌面端
|
||||||
|
Object.defineProperty(window, 'innerWidth', { value: 1200 });
|
||||||
|
render(<PhotoGrid photos={mockPhotos} />);
|
||||||
|
expect(screen.getByTestId('photo-grid')).toHaveClass('lg:grid-cols-4');
|
||||||
|
|
||||||
|
// 移动端
|
||||||
|
Object.defineProperty(window, 'innerWidth', { value: 600 });
|
||||||
|
render(<PhotoGrid photos={mockPhotos} />);
|
||||||
|
expect(screen.getByTestId('photo-grid')).toHaveClass('grid-cols-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 时间线功能测试
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
describe('时间线功能', () => {
|
||||||
|
test('应该按年份正确分组', async () => {
|
||||||
|
const timelineData = await getTimelineData();
|
||||||
|
|
||||||
|
expect(timelineData.groups).toBeDefined();
|
||||||
|
expect(timelineData.groups[0].year).toBe(2024);
|
||||||
|
expect(timelineData.groups[0].months).toHaveProperty('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该支持年份切换', async () => {
|
||||||
|
render(<Timeline data={mockTimelineData} />);
|
||||||
|
|
||||||
|
const yearButton = screen.getByText('2023');
|
||||||
|
fireEvent.click(yearButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('timeline-content')).toHaveTextContent('2023');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该显示正确的照片数量统计', () => {
|
||||||
|
render(<Timeline data={mockTimelineData} />);
|
||||||
|
|
||||||
|
const monthSummary = screen.getByTestId('month-summary-1');
|
||||||
|
expect(monthSummary).toHaveTextContent('15张照片');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 收藏夹功能测试
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
describe('收藏夹功能', () => {
|
||||||
|
test('应该能创建新收藏夹', async () => {
|
||||||
|
render(<CollectionManager />);
|
||||||
|
|
||||||
|
const createButton = screen.getByText('创建收藏夹');
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
|
||||||
|
const nameInput = screen.getByLabelText('收藏夹名称');
|
||||||
|
fireEvent.change(nameInput, { target: { value: '风景摄影' } });
|
||||||
|
|
||||||
|
const saveButton = screen.getByText('保存');
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('风景摄影')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该能添加照片到收藏夹', async () => {
|
||||||
|
render(<PhotoGrid photos={mockPhotos} collections={mockCollections} />);
|
||||||
|
|
||||||
|
const photo = screen.getAllByTestId('photo-item')[0];
|
||||||
|
fireEvent.contextMenu(photo);
|
||||||
|
|
||||||
|
const addToCollection = screen.getByText('添加到收藏夹');
|
||||||
|
fireEvent.click(addToCollection);
|
||||||
|
|
||||||
|
const collection = screen.getByText('风景摄影');
|
||||||
|
fireEvent.click(collection);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('已添加到收藏夹')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 性能测试
|
||||||
|
|
||||||
|
### 3.1 图片加载性能测试
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// tests/performance/imageLoading.test.js
|
||||||
|
describe('图片加载性能', () => {
|
||||||
|
test('首屏图片应在2秒内加载完成', async () => {
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
render(<PhotoGrid photos={mockPhotos.slice(0, 8)} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const images = screen.getAllByRole('img');
|
||||||
|
images.forEach(img => {
|
||||||
|
expect(img).toHaveAttribute('src');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadTime = performance.now() - startTime;
|
||||||
|
expect(loadTime).toBeLessThan(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('大图片应该有渐进式加载', async () => {
|
||||||
|
render(<LightboxImage src="large-image.jpg" placeholder="thumb.jpg" />);
|
||||||
|
|
||||||
|
const img = screen.getByRole('img');
|
||||||
|
|
||||||
|
// 初始应显示占位图
|
||||||
|
expect(img).toHaveAttribute('src', expect.stringContaining('thumb.jpg'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// 加载完成后应显示高清图
|
||||||
|
expect(img).toHaveAttribute('src', expect.stringContaining('large-image.jpg'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 API响应时间测试
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
describe('API性能测试', () => {
|
||||||
|
test('获取照片列表API应在500ms内响应', async () => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const response = await fetch('/api/photos?limit=20');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(responseTime).toBeLessThan(500);
|
||||||
|
expect(data.data).toHaveLength(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('图片上传API应支持大文件', async () => {
|
||||||
|
const largeFile = new File([new ArrayBuffer(20 * 1024 * 1024)], 'large.jpg');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('photo', largeFile);
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const response = await fetch('/api/photos', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(uploadTime).toBeLessThan(30000); // 30秒内完成
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 兼容性测试
|
||||||
|
|
||||||
|
### 4.1 浏览器兼容性测试
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 使用Playwright进行跨浏览器测试
|
||||||
|
const { test, devices } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('浏览器兼容性', () => {
|
||||||
|
['chromium', 'firefox', 'webkit'].forEach(browserName => {
|
||||||
|
test(`应该在${browserName}中正常显示`, async ({ browser }) => {
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// 验证关键元素存在
|
||||||
|
await expect(page.locator('[data-testid="photo-grid"]')).toBeVisible();
|
||||||
|
await expect(page.locator('nav')).toBeVisible();
|
||||||
|
|
||||||
|
// 验证图片加载
|
||||||
|
const firstImage = page.locator('img').first();
|
||||||
|
await expect(firstImage).toBeVisible();
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 设备响应式测试
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
describe('响应式设计测试', () => {
|
||||||
|
const devices = [
|
||||||
|
{ name: 'iPhone 12', width: 390, height: 844 },
|
||||||
|
{ name: 'iPad', width: 768, height: 1024 },
|
||||||
|
{ name: 'Desktop', width: 1920, height: 1080 }
|
||||||
|
];
|
||||||
|
|
||||||
|
devices.forEach(device => {
|
||||||
|
test(`应该在${device.name}上正确显示`, async () => {
|
||||||
|
await page.setViewportSize({
|
||||||
|
width: device.width,
|
||||||
|
height: device.height
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// 验证导航栏适配
|
||||||
|
if (device.width < 768) {
|
||||||
|
await expect(page.locator('[data-testid="mobile-menu"]')).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page.locator('[data-testid="desktop-nav"]')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证网格列数
|
||||||
|
const grid = page.locator('[data-testid="photo-grid"]');
|
||||||
|
const expectedCols = device.width < 768 ? 2 : device.width < 1024 ? 3 : 4;
|
||||||
|
|
||||||
|
await expect(grid).toHaveClass(new RegExp(`grid-cols-${expectedCols}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 用户体验测试
|
||||||
|
|
||||||
|
### 5.1 交互流程测试
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
describe('用户交互流程', () => {
|
||||||
|
test('完整的照片浏览流程', async () => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// 1. 点击照片打开灯箱
|
||||||
|
const firstPhoto = page.locator('[data-testid="photo-item"]').first();
|
||||||
|
await firstPhoto.click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid="lightbox"]')).toBeVisible();
|
||||||
|
|
||||||
|
// 2. 使用键盘导航
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await expect(page.locator('[data-testid="photo-counter"]')).toContainText('2 / ');
|
||||||
|
|
||||||
|
// 3. 缩放功能
|
||||||
|
await page.locator('[data-testid="lightbox-image"]').click();
|
||||||
|
await expect(page.locator('[data-testid="lightbox-image"]')).toHaveClass(/zoomed/);
|
||||||
|
|
||||||
|
// 4. 关闭灯箱
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.locator('[data-testid="lightbox"]')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('移动端手势操作', async () => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const firstPhoto = page.locator('[data-testid="photo-item"]').first();
|
||||||
|
await firstPhoto.click();
|
||||||
|
|
||||||
|
// 模拟左滑手势
|
||||||
|
const lightbox = page.locator('[data-testid="lightbox-image"]');
|
||||||
|
await lightbox.touchAction([
|
||||||
|
{ action: 'touchStart', x: 300, y: 400 },
|
||||||
|
{ action: 'touchMove', x: 100, y: 400 },
|
||||||
|
{ action: 'touchEnd' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid="photo-counter"]')).toContainText('2 / ');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 可访问性测试
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
describe('可访问性测试', () => {
|
||||||
|
test('应该支持键盘导航', async () => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Tab键导航
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await expect(page.locator(':focus')).toBeVisible();
|
||||||
|
|
||||||
|
// 回车键激活
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
// 验证预期的交互结果
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该有正确的ARIA标签', async () => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const grid = page.locator('[data-testid="photo-grid"]');
|
||||||
|
await expect(grid).toHaveAttribute('role', 'grid');
|
||||||
|
|
||||||
|
const photos = page.locator('[data-testid="photo-item"]');
|
||||||
|
await expect(photos.first()).toHaveAttribute('role', 'gridcell');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('应该支持屏幕阅读器', async () => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const firstPhoto = page.locator('[data-testid="photo-item"]').first();
|
||||||
|
await expect(firstPhoto).toHaveAttribute('aria-label');
|
||||||
|
|
||||||
|
const img = firstPhoto.locator('img');
|
||||||
|
await expect(img).toHaveAttribute('alt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 自动化测试配置
|
||||||
|
|
||||||
|
### 6.1 Jest配置
|
||||||
|
```javascript
|
||||||
|
// jest.config.js
|
||||||
|
module.exports = {
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
|
||||||
|
moduleNameMapping: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
'^@/components/(.*)$': '<rootDir>/src/components/$1'
|
||||||
|
},
|
||||||
|
testMatch: [
|
||||||
|
'<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
|
||||||
|
'<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}'
|
||||||
|
],
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/**/*.{js,jsx,ts,tsx}',
|
||||||
|
'!src/**/*.d.ts',
|
||||||
|
'!src/index.js'
|
||||||
|
],
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
branches: 80,
|
||||||
|
functions: 80,
|
||||||
|
lines: 80,
|
||||||
|
statements: 80
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Playwright配置
|
||||||
|
```javascript
|
||||||
|
// playwright.config.js
|
||||||
|
module.exports = {
|
||||||
|
testDir: './e2e',
|
||||||
|
timeout: 30000,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
video: 'retain-on-failure'
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mobile Chrome',
|
||||||
|
use: { ...devices['Pixel 5'] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mobile Safari',
|
||||||
|
use: { ...devices['iPhone 12'] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 性能监控和测试
|
||||||
|
|
||||||
|
### 7.1 Lighthouse CI集成
|
||||||
|
```javascript
|
||||||
|
// lighthouse.config.js
|
||||||
|
module.exports = {
|
||||||
|
ci: {
|
||||||
|
collect: {
|
||||||
|
url: ['http://localhost:3000', 'http://localhost:3000/gallery'],
|
||||||
|
numberOfRuns: 3
|
||||||
|
},
|
||||||
|
assert: {
|
||||||
|
assertions: {
|
||||||
|
'categories:performance': ['error', { minScore: 0.9 }],
|
||||||
|
'categories:accessibility': ['error', { minScore: 0.9 }],
|
||||||
|
'categories:best-practices': ['error', { minScore: 0.9 }],
|
||||||
|
'categories:seo': ['error', { minScore: 0.9 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 负载测试
|
||||||
|
```javascript
|
||||||
|
// load-test.js
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep } from 'k6';
|
||||||
|
|
||||||
|
export let options = {
|
||||||
|
stages: [
|
||||||
|
{ duration: '2m', target: 100 }, // 2分钟内增加到100用户
|
||||||
|
{ duration: '5m', target: 100 }, // 维持100用户5分钟
|
||||||
|
{ duration: '2m', target: 200 }, // 增加到200用户
|
||||||
|
{ duration: '5m', target: 200 }, // 维持200用户5分钟
|
||||||
|
{ duration: '2m', target: 0 }, // 逐步减少到0
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function() {
|
||||||
|
let response = http.get('http://localhost:3000/api/photos');
|
||||||
|
|
||||||
|
check(response, {
|
||||||
|
'status is 200': (r) => r.status === 200,
|
||||||
|
'response time < 500ms': (r) => r.timings.duration < 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 测试报告和持续集成
|
||||||
|
|
||||||
|
### 8.1 GitHub Actions配置
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/test.yml
|
||||||
|
name: Test
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm run test:coverage
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
run: npm run test:e2e
|
||||||
|
|
||||||
|
- name: Run Lighthouse CI
|
||||||
|
run: npm run lighthouse:ci
|
||||||
|
|
||||||
|
- name: Upload coverage to Codecov
|
||||||
|
uses: codecov/codecov-action@v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 测试覆盖率报告
|
||||||
|
```javascript
|
||||||
|
// package.json scripts
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:coverage": "jest --coverage",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"lighthouse:ci": "lhci autorun"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user