This commit is contained in:
iriver
2025-07-09 00:13:41 +08:00
parent 0bfcbe54b1
commit 9376a67052
11 changed files with 2028 additions and 8 deletions

View File

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

@ -0,0 +1,46 @@
# 产品文档
本目录包含摄影作品集项目的完整产品文档。
## 文档结构
### 📐 Design设计文档
- UI/UX 设计规范
- 组件设计系统
- 交互设计文档
- 品牌指南和视觉规范
### 🔌 APIAPI 文档)
- API 接口文档
- 数据结构定义
- 接口调用示例
- 错误码说明
### 👥 User Guide用户指南
- 用户操作手册
- 功能使用说明
- 常见问题解答
- 最佳实践指南
### 🛠️ Development开发文档
- 开发环境搭建
- 代码规范和约定
- 开发工作流程
- 测试指南
### 🚀 Deployment部署文档
- 部署配置说明
- 环境变量配置
- 服务器配置要求
- 部署流程和脚本
## 文档维护
请在相应的子目录中维护各类文档,确保文档的及时更新和准确性。
## 贡献指南
1. 在相应目录下创建或更新文档
2. 使用 Markdown 格式编写
3. 确保文档结构清晰、内容准确
4. 添加适当的图片和示例代码

View 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-48pxBold
- **二级标题**24px-32pxMedium
- **正文**16px-18pxRegular
- **说明文字**14pxLight
## 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
View 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
View 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
View File

@ -0,0 +1,18 @@
# 设计文档
本目录包含摄影作品集项目的设计相关文档。
## 目录结构
- `ui-design.md` - UI 设计规范
- `component-system.md` - 组件设计系统
- `interaction-design.md` - 交互设计文档
- `brand-guide.md` - 品牌指南
- `assets/` - 设计资源文件夹
## 设计原则
- 简洁优雅的视觉风格
- 响应式设计,适配多种设备
- 突出摄影作品的展示效果
- 良好的用户体验和交互流程

View 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
View 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
View 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
View 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
View 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"
}
}
```