init doc
This commit is contained in:
57
CLAUDE.md
57
CLAUDE.md
@ -52,10 +52,20 @@ cd frontend && make quick
|
||||
- `make install` - 安装依赖
|
||||
- `make dev` - 启动开发服务器
|
||||
- `make build` - 构建生产版本
|
||||
- `make clean` - 清理构建文件
|
||||
- `make start` - 启动生产服务器
|
||||
- `make clean` - 清理构建文件和缓存
|
||||
- `make status` - 检查项目状态
|
||||
- `make add PACKAGE=name` - 添加依赖
|
||||
- `make add-dev 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/photo-modal.tsx` - 照片详情模态框,带导航
|
||||
- `components/timeline-view.tsx` - 基于时间轴的照片展示
|
||||
- `components/timeline-stats.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 组件
|
||||
- `lib/api.ts` - 使用 axios 的 API 配置
|
||||
- `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/:id` - 获取单个照片
|
||||
- `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/` 目录中进行
|
||||
2. 使用 `make dev` 或 `bun run dev` 启动开发服务器
|
||||
3. 使用 `make lint` 检查代码规范问题
|
||||
4. 使用 `make type-check` 运行 TypeScript 检查
|
||||
5. 部署前使用 `make build` 构建
|
||||
6. 使用 `make status` 检查项目健康状况
|
||||
2. 首次设置:`make setup` 创建环境变量文件
|
||||
3. 使用 `make dev` 或 `bun run dev` 启动开发服务器
|
||||
4. 使用 `make lint` 检查代码规范问题
|
||||
5. 使用 `make type-check` 运行 TypeScript 检查
|
||||
6. 使用 `make format` 格式化代码
|
||||
7. 部署前使用 `make deploy-prep` 进行完整检查和构建
|
||||
8. 使用 `make status` 检查项目健康状况
|
||||
|
||||
### 快速开始工作流程
|
||||
```bash
|
||||
cd frontend
|
||||
make quick # 安装依赖并启动开发服务器
|
||||
```
|
||||
|
||||
## Bun 特定说明
|
||||
|
||||
@ -181,4 +215,11 @@ NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
||||
### 开发环境配置
|
||||
- 构建时忽略 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