430 lines
10 KiB
Plaintext
430 lines
10 KiB
Plaintext
---
|
|
globs: frontend/**/*
|
|
alwaysApply: false
|
|
---
|
|
# Next.js 前端项目结构规则
|
|
|
|
## 🏗️ 项目架构
|
|
|
|
### 目录结构
|
|
```
|
|
frontend/
|
|
├── app/ # Next.js 14 App Router
|
|
│ ├── globals.css # 全局样式
|
|
│ ├── layout.tsx # 根布局
|
|
│ ├── page.tsx # 首页
|
|
│ ├── gallery/ # 照片画廊页面
|
|
│ ├── about/ # 关于页面
|
|
│ └── contact/ # 联系页面
|
|
├── components/ # React 组件
|
|
│ ├── ui/ # shadcn/ui 组件
|
|
│ ├── photo-gallery.tsx # 照片画廊组件
|
|
│ ├── photo-modal.tsx # 照片弹窗
|
|
│ ├── navigation.tsx # 导航组件
|
|
│ └── filter-bar.tsx # 筛选栏
|
|
├── lib/ # 工具库
|
|
│ ├── api.ts # API 客户端
|
|
│ ├── queries.ts # TanStack Query
|
|
│ └── utils.ts # 工具函数
|
|
├── hooks/ # 自定义 Hooks
|
|
├── styles/ # 样式文件
|
|
└── public/ # 静态资源
|
|
```
|
|
|
|
## 🎯 开发规范
|
|
|
|
### App Router 页面组件
|
|
```tsx
|
|
// app/gallery/page.tsx
|
|
import { Metadata } from 'next'
|
|
import { PhotoGallery } from '@/components/photo-gallery'
|
|
import { FilterBar } from '@/components/filter-bar'
|
|
|
|
export const metadata: Metadata = {
|
|
title: '照片画廊 | Photography Portfolio',
|
|
description: '浏览我的摄影作品集',
|
|
}
|
|
|
|
export default function GalleryPage() {
|
|
return (
|
|
<main className="container mx-auto px-4 py-8">
|
|
<h1 className="text-3xl font-bold mb-8">照片画廊</h1>
|
|
<FilterBar />
|
|
<PhotoGallery />
|
|
</main>
|
|
)
|
|
}
|
|
```
|
|
|
|
### 布局组件
|
|
```tsx
|
|
// app/layout.tsx
|
|
import type { Metadata } from 'next'
|
|
import { Inter } from 'next/font/google'
|
|
import { Navigation } from '@/components/navigation'
|
|
import { QueryProvider } from '@/components/providers/query-provider'
|
|
import { ThemeProvider } from '@/components/theme-provider'
|
|
import './globals.css'
|
|
|
|
const inter = Inter({ subsets: ['latin'] })
|
|
|
|
export const metadata: Metadata = {
|
|
title: 'Photography Portfolio',
|
|
description: '专业摄影作品展示',
|
|
}
|
|
|
|
export default function RootLayout({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode
|
|
}) {
|
|
return (
|
|
<html lang="zh-CN">
|
|
<body className={inter.className}>
|
|
<ThemeProvider>
|
|
<QueryProvider>
|
|
<Navigation />
|
|
{children}
|
|
</QueryProvider>
|
|
</ThemeProvider>
|
|
</body>
|
|
</html>
|
|
)
|
|
}
|
|
```
|
|
|
|
## 🧩 组件开发规范
|
|
|
|
### 功能组件模式
|
|
```tsx
|
|
// components/photo-gallery.tsx
|
|
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { usePhotos } from '@/hooks/use-photos'
|
|
import { PhotoCard } from './photo-card'
|
|
import { LoadingSpinner } from './loading-spinner'
|
|
|
|
interface PhotoGalleryProps {
|
|
categoryId?: string
|
|
limit?: number
|
|
}
|
|
|
|
export function PhotoGallery({ categoryId, limit = 20 }: PhotoGalleryProps) {
|
|
const [page, setPage] = useState(1)
|
|
const { data, isLoading, error } = usePhotos({
|
|
page,
|
|
limit,
|
|
categoryId
|
|
})
|
|
|
|
if (isLoading) {
|
|
return <LoadingSpinner />
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="text-center py-8 text-red-600">
|
|
加载照片失败:{error.message}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
{data?.photos.map((photo) => (
|
|
<PhotoCard
|
|
key={photo.id}
|
|
photo={photo}
|
|
onView={() => handlePhotoView(photo)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function handlePhotoView(photo: Photo) {
|
|
// 打开照片详情弹窗
|
|
}
|
|
```
|
|
|
|
### UI组件模式
|
|
```tsx
|
|
// components/photo-card.tsx
|
|
import Image from 'next/image'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
|
|
interface PhotoCardProps {
|
|
photo: Photo
|
|
onView?: () => void
|
|
className?: string
|
|
}
|
|
|
|
export function PhotoCard({ photo, onView, className }: PhotoCardProps) {
|
|
return (
|
|
<Card className={cn("group cursor-pointer hover:shadow-lg transition-shadow", className)}>
|
|
<CardContent className="p-0">
|
|
<div className="relative aspect-square overflow-hidden rounded-t-lg">
|
|
<Image
|
|
src={photo.thumbnail || '/placeholder.jpg'}
|
|
alt={photo.title}
|
|
fill
|
|
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
|
onClick={onView}
|
|
/>
|
|
{photo.category && (
|
|
<Badge className="absolute top-2 left-2">
|
|
{photo.category.name}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="p-4">
|
|
<h3 className="font-semibold truncate">{photo.title}</h3>
|
|
{photo.description && (
|
|
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
|
{photo.description}
|
|
</p>
|
|
)}
|
|
<p className="text-xs text-muted-foreground mt-2">
|
|
{new Date(photo.created_at).toLocaleDateString('zh-CN')}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
```
|
|
|
|
## 🌐 API 集成
|
|
|
|
### API 客户端配置
|
|
```typescript
|
|
// lib/api.ts
|
|
import axios, { AxiosError } from 'axios'
|
|
|
|
export const api = axios.create({
|
|
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8888/api/v1',
|
|
timeout: 10000,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
})
|
|
|
|
// 请求拦截器
|
|
api.interceptors.request.use((config) => {
|
|
// 从localStorage获取token (仅在客户端)
|
|
if (typeof window !== 'undefined') {
|
|
const token = localStorage.getItem('token')
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`
|
|
}
|
|
}
|
|
return config
|
|
})
|
|
|
|
// 响应拦截器
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
(error: AxiosError) => {
|
|
if (error.response?.status === 401) {
|
|
// 清除过期token
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.removeItem('token')
|
|
window.location.href = '/login'
|
|
}
|
|
}
|
|
return Promise.reject(error)
|
|
}
|
|
)
|
|
```
|
|
|
|
### TanStack Query 集成
|
|
```typescript
|
|
// lib/queries.ts
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { api } from './api'
|
|
|
|
// 照片相关查询
|
|
export function usePhotos(params: PhotoListParams = {}) {
|
|
return useQuery({
|
|
queryKey: ['photos', params],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<ApiResponse<PhotoListData>>('/photos', {
|
|
params,
|
|
})
|
|
return data.data
|
|
},
|
|
staleTime: 5 * 60 * 1000, // 5分钟
|
|
})
|
|
}
|
|
|
|
export function usePhoto(id: string) {
|
|
return useQuery({
|
|
queryKey: ['photo', id],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<ApiResponse<Photo>>(`/photos/${id}`)
|
|
return data.data
|
|
},
|
|
enabled: !!id,
|
|
})
|
|
}
|
|
|
|
// 分类查询
|
|
export function useCategories() {
|
|
return useQuery({
|
|
queryKey: ['categories'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<ApiResponse<Category[]>>('/categories')
|
|
return data.data
|
|
},
|
|
staleTime: 10 * 60 * 1000, // 10分钟
|
|
})
|
|
}
|
|
```
|
|
|
|
### 自定义 Hooks
|
|
```typescript
|
|
// hooks/use-photos.ts
|
|
import { useState, useCallback } from 'react'
|
|
import { usePhotos as usePhotosQuery } from '@/lib/queries'
|
|
|
|
export function usePhotos(initialParams: PhotoListParams = {}) {
|
|
const [params, setParams] = useState(initialParams)
|
|
const query = usePhotosQuery(params)
|
|
|
|
const updateParams = useCallback((newParams: Partial<PhotoListParams>) => {
|
|
setParams(prev => ({ ...prev, ...newParams }))
|
|
}, [])
|
|
|
|
const nextPage = useCallback(() => {
|
|
setParams(prev => ({ ...prev, page: (prev.page || 1) + 1 }))
|
|
}, [])
|
|
|
|
const prevPage = useCallback(() => {
|
|
setParams(prev => ({ ...prev, page: Math.max((prev.page || 1) - 1, 1) }))
|
|
}, [])
|
|
|
|
return {
|
|
...query,
|
|
params,
|
|
updateParams,
|
|
nextPage,
|
|
prevPage,
|
|
}
|
|
}
|
|
```
|
|
|
|
## 🎨 样式和主题
|
|
|
|
### Tailwind CSS 配置
|
|
```typescript
|
|
// tailwind.config.ts
|
|
import type { Config } from 'tailwindcss'
|
|
|
|
const config: Config = {
|
|
content: [
|
|
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
|
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
|
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
|
],
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
border: 'hsl(var(--border))',
|
|
background: 'hsl(var(--background))',
|
|
foreground: 'hsl(var(--foreground))',
|
|
// ...其他 shadcn 颜色
|
|
},
|
|
animation: {
|
|
'fade-in': 'fadeIn 0.5s ease-in-out',
|
|
'slide-up': 'slideUp 0.3s ease-out',
|
|
},
|
|
keyframes: {
|
|
fadeIn: {
|
|
'0%': { opacity: '0' },
|
|
'100%': { opacity: '1' },
|
|
},
|
|
slideUp: {
|
|
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
plugins: [require('tailwindcss-animate')],
|
|
}
|
|
```
|
|
|
|
### CSS变量配置
|
|
```css
|
|
/* app/globals.css */
|
|
@tailwind base;
|
|
@tailwind components;
|
|
@tailwind utilities;
|
|
|
|
@layer base {
|
|
:root {
|
|
--background: 0 0% 100%;
|
|
--foreground: 222.2 84% 4.9%;
|
|
--card: 0 0% 100%;
|
|
--card-foreground: 222.2 84% 4.9%;
|
|
/* ...其他CSS变量 */
|
|
}
|
|
|
|
.dark {
|
|
--background: 222.2 84% 4.9%;
|
|
--foreground: 210 40% 98%;
|
|
/* ...暗色模式变量 */
|
|
}
|
|
}
|
|
|
|
@layer components {
|
|
.photo-grid {
|
|
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6;
|
|
}
|
|
|
|
.photo-card-hover {
|
|
@apply hover:shadow-lg hover:scale-105 transition-all duration-300;
|
|
}
|
|
}
|
|
```
|
|
|
|
## 🚀 性能优化
|
|
|
|
### Image 优化
|
|
```tsx
|
|
import Image from 'next/image'
|
|
|
|
// 响应式图片
|
|
<Image
|
|
src={photo.thumbnail}
|
|
alt={photo.title}
|
|
width={400}
|
|
height={300}
|
|
className="w-full h-auto"
|
|
priority={index < 4} // 首屏图片优先加载
|
|
placeholder="blur"
|
|
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
|
/>
|
|
```
|
|
|
|
### 懒加载和分页
|
|
```tsx
|
|
// 无限滚动
|
|
function useInfinitePhotos() {
|
|
return useInfiniteQuery({
|
|
queryKey: ['photos'],
|
|
queryFn: ({ pageParam = 1 }) => fetchPhotos({ page: pageParam }),
|
|
getNextPageParam: (lastPage) =>
|
|
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
|
})
|
|
}
|
|
```
|
|
|
|
参考 [TASK_PROGRESS.md](mdc:TASK_PROGRESS.md) 了解前端开发进度。
|
|
description:
|
|
globs:
|
|
alwaysApply: false
|
|
---
|