<!-- Generated by SpecStory -->

# 添加照片分组功能到主页 (2025-06-05 19:33:17)

_**User**_

@MasonryRoot.tsx 需要给这个主页加一个功能，在 header item 上也加一个 button  对应实现对照片的分组。维度细分到 天/周/月/年 可选。

每个分组由一个日期，然后底下是 masonry。UI 和 apple 的相册差不多

---

_**Assistant**_

我来帮你实现这个照片分组功能。首先让我查看相关的组件和数据结构，了解当前的实现。

---

Read file: apps/web/src/modules/gallery/MasonryHeaderMasonryItem.tsx

---

Read file: apps/web/src/atoms/app.ts

---

Read file: apps/web/src/types/photo.ts

---

Read file: apps/web/src/modules/gallery/ActionGroup.tsx

---

现在我了解了项目结构。我需要实现照片分组功能，包括：

1. 添加分组模式选择（天/周/月/年）到 atom 状态
2. 创建分组逻辑来按日期分组照片
3. 更新 MasonryRoot 组件来渲染分组
4. 在 ActionGroup 中添加分组控制按钮
5. 创建分组标题组件

让我开始实现：

---

```diff
  export type GallerySortBy = 'date'
  export type GallerySortOrder = 'asc' | 'desc'
+ export type GalleryGroupBy = 'none' | 'day' | 'week' | 'month' | 'year'
+ 
  export const gallerySettingAtom = atom({
    sortBy: 'date' as GallerySortBy,
    sortOrder: 'desc' as GallerySortOrder,
    selectedTags: [] as string[],
+   groupBy: 'none' as GalleryGroupBy,
  })
  
```

---

```diff
  import { useAtom } from 'jotai'
  
- import { gallerySettingAtom } from '~/atoms/app'
+ import { gallerySettingAtom, type GalleryGroupBy } from '~/atoms/app'
  import { Button } from '~/components/ui/button'
  import {
```

```diff
      })
    }
+ 
+   const setGroupBy = (groupBy: GalleryGroupBy) => {
+     setGallerySetting({
+       ...gallerySetting,
+       groupBy,
+     })
+   }
  
    const toggleTag = (tag: string) => {
```

```diff
      })
    }
+ 
+   const groupByOptions = [
+     { value: 'none' as const, label: '不分组', icon: 'i-mingcute-grid-line' },
+     { value: 'day' as const, label: '按天', icon: 'i-mingcute-calendar-line' },
+     { value: 'week' as const, label: '按周', icon: 'i-mingcute-calendar-2-line' },
+     { value: 'month' as const, label: '按月', icon: 'i-mingcute-calendar-month-line' },
+     { value: 'year' as const, label: '按年', icon: 'i-mingcute-calendar-time-line' },
+   ]
  
    return (
```

```diff
          </Button>
        )}
+ 
+       {/* 分组按钮 */}
+       <DropdownMenu>
+         <DropdownMenuTrigger asChild>
+           <Button
+             variant="ghost"
+             size="sm"
+             className="h-10 w-10 rounded-full border-0 bg-gray-100 transition-all duration-200 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
+             title="分组方式"
+           >
+             <i className={`text-base text-gray-600 dark:text-gray-300 ${
+               groupByOptions.find(option => option.value === gallerySetting.groupBy)?.icon || 'i-mingcute-grid-line'
+             }`} />
+           </Button>
+         </DropdownMenuTrigger>
+ 
+         <DropdownMenuContent align="center" className="w-48">
+           <DropdownMenuLabel>分组方式</DropdownMenuLabel>
+ 
+           {groupByOptions.map((option) => (
+             <DropdownMenuCheckboxItem
+               key={option.value}
+               onClick={() => setGroupBy(option.value)}
+               icon={<i className={option.icon} />}
+               checked={gallerySetting.groupBy === option.value}
+             >
+               <span>{option.label}</span>
+             </DropdownMenuCheckboxItem>
+           ))}
+         </DropdownMenuContent>
+       </DropdownMenu>
  
        {/* 标签筛选按钮 */}
```

---

现在创建一个分组头部组件：

---

```diff
- 
+ import { memo } from 'react'
+ 
+ import { clsxm } from '~/lib/cn'
+ 
+ interface PhotoGroupHeaderProps {
+   date: string
+   count: number
+   style?: React.CSSProperties
+   className?: string
+ }
+ 
+ export const PhotoGroupHeader = memo(
+   ({ date, count, style, className }: PhotoGroupHeaderProps) => {
+     return (
+       <div
+         className={clsxm(
+           'sticky top-0 z-10 mb-2 flex items-center justify-between rounded-lg bg-white/80 px-4 py-3 backdrop-blur-sm dark:bg-gray-900/80',
+           'border border-gray-200 dark:border-gray-700',
+           'shadow-sm',
+           className,
+         )}
+         style={style}
+       >
+         <div className="flex items-center gap-3">
+           <div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/50">
+             <i className="i-mingcute-calendar-line text-sm text-blue-600 dark:text-blue-400" />
+           </div>
+           <div>
+             <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
+               {date}
+             </h3>
+           </div>
+         </div>
+         
+         <div className="text-sm font-medium text-gray-500 dark:text-gray-400">
+           {count} 张照片
+         </div>
+       </div>
+     )
+   },
+ ) 
```

---

现在创建照片分组的工具函数：

---

```diff
- 
+ import type { GalleryGroupBy } from '~/atoms/app'
+ import type { PhotoManifest } from '~/types/photo'
+ 
+ export interface PhotoGroup {
+   key: string
+   label: string
+   photos: PhotoManifest[]
+ }
+ 
+ export function groupPhotosByDate(
+   photos: PhotoManifest[],
+   groupBy: GalleryGroupBy,
+ ): PhotoGroup[] {
+   if (groupBy === 'none') {
+     return []
+   }
+ 
+   const groups = new Map<string, PhotoManifest[]>()
+ 
+   photos.forEach((photo) => {
+     const date = new Date(photo.lastModified)
+     let key: string
+     let label: string
+ 
+     switch (groupBy) {
+       case 'day': {
+         key = date.toISOString().split('T')[0] // YYYY-MM-DD
+         label = formatDateLabel(date, 'day')
+         break
+       }
+       case 'week': {
+         const startOfWeek = getStartOfWeek(date)
+         key = startOfWeek.toISOString().split('T')[0]
+         label = formatDateLabel(startOfWeek, 'week')
+         break
+       }
+       case 'month': {
+         key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
+         label = formatDateLabel(date, 'month')
+         break
+       }
+       case 'year': {
+         key = String(date.getFullYear())
+         label = formatDateLabel(date, 'year')
+         break
+       }
+       default:
+         return
+     }
+ 
+     if (!groups.has(key)) {
+       groups.set(key, [])
+     }
+     groups.get(key)!.push(photo)
+   })
+ 
+   return Array.from(groups.entries())
+     .map(([key, photos]) => ({
+       key,
+       label: formatDateLabel(new Date(photos[0].lastModified), groupBy),
+       photos,
+     }))
+     .sort((a, b) => b.key.localeCompare(a.key)) // 按日期倒序
+ }
+ 
+ function getStartOfWeek(date: Date): Date {
+   const day = date.getDay()
+   const diff = date.getDate() - day + (day === 0 ? -6 : 1) // Monday as start of week
+   return new Date(date.setDate(diff))
+ }
+ 
+ function formatDateLabel(date: Date, groupBy: GalleryGroupBy): string {
+   const now = new Date()
+   const isThisYear = date.getFullYear() === now.getFullYear()
+ 
+   switch (groupBy) {
+     case 'day': {
+       const today = new Date()
+       const yesterday = new Date(today)
+       yesterday.setDate(yesterday.getDate() - 1)
+ 
+       if (isSameDay(date, today)) {
+         return '今天'
+       }
+       if (isSameDay(date, yesterday)) {
+         return '昨天'
+       }
+ 
+       if (isThisYear) {
+         return date.toLocaleDateString('zh-CN', {
+           month: 'long',
+           day: 'numeric',
+           weekday: 'long',
+         })
+       }
+       return date.toLocaleDateString('zh-CN', {
+         year: 'numeric',
+         month: 'long',
+         day: 'numeric',
+         weekday: 'long',
+       })
+     }
+     case 'week': {
+       const endOfWeek = new Date(date)
+       endOfWeek.setDate(endOfWeek.getDate() + 6)
+ 
+       if (isThisYear) {
+         return `${date.toLocaleDateString('zh-CN', {
+           month: 'short',
+           day: 'numeric',
+         })} - ${endOfWeek.toLocaleDateString('zh-CN', {
+           month: 'short',
+           day: 'numeric',
+         })}`
+       }
+       return `${date.toLocaleDateString('zh-CN', {
+         year: 'numeric',
+         month: 'short',
+         day: 'numeric',
+       })} - ${endOfWeek.toLocaleDateString('zh-CN', {
+         year: 'numeric',
+         month: 'short',
+         day: 'numeric',
+       })}`
+     }
+     case 'month': {
+       if (isThisYear) {
+         return date.toLocaleDateString('zh-CN', {
+           month: 'long',
+         })
+       }
+       return date.toLocaleDateString('zh-CN', {
+         year: 'numeric',
+         month: 'long',
+       })
+     }
+     case 'year': {
+       return date.toLocaleDateString('zh-CN', {
+         year: 'numeric',
+       })
+     }
+     default:
+       return ''
+   }
+ }
+ 
+ function isSameDay(date1: Date, date2: Date): boolean {
+   return (
+     date1.getFullYear() === date2.getFullYear() &&
+     date1.getMonth() === date2.getMonth() &&
+     date1.getDate() === date2.getDate()
+   )
+ } 
```

---



---

Read file: apps/web/src/lib/photoGroup.ts

---



---

现在更新 MasonryRoot 组件以支持分组显示：

---

```diff
  import { usePhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
  import { useTypeScriptHappyCallback } from '~/hooks/useTypeScriptCallback'
+ import { groupPhotosByDate, type PhotoGroup } from '~/lib/photoGroup'
  import { Spring } from '~/lib/spring'
  import type { PhotoManifest } from '~/types/photo'
```

```diff
  import { Masonry } from './Masonic'
  import { MasonryHeaderMasonryItem } from './MasonryHeaderMasonryItem'
+ import { PhotoGroupHeader } from './PhotoGroupHeader'
  import { PhotoMasonryItem } from './PhotoMasonryItem'
  
```

```diff
  }
  
- type MasonryItemType = PhotoManifest | MasonryHeaderItem
+ class PhotoGroupHeaderItem {
+   constructor(public group: PhotoGroup, public index: number) {}
+ }
+ 
+ type MasonryItemType = PhotoManifest | MasonryHeaderItem | PhotoGroupHeaderItem
  
  const FIRST_SCREEN_ITEMS_COUNT = 30
  
  export const MasonryRoot = () => {
-   const { sortOrder, selectedTags } = useAtomValue(gallerySettingAtom)
+   const { sortOrder, selectedTags, groupBy } = useAtomValue(gallerySettingAtom)
    const hasAnimatedRef = useRef(false)
  
    const photos = usePhotos()
- 
    const photoViewer = usePhotoViewer()
+   
    const handleAnimationComplete = useCallback(() => {
      hasAnimatedRef.current = true
    }, [])
+   
    const isMobile = useMobile()
+ 
+   // 处理分组逻辑
+   const masonryItems = useMemo(() => {
+     if (groupBy === 'none') {
+       // 非分组模式，使用原有逻辑
+       return isMobile ? photos : [MasonryHeaderItem.default, ...photos]
+     }
+ 
+     // 分组模式
+     const groups = groupPhotosByDate(photos, groupBy)
+     const items: MasonryItemType[] = []
+ 
+     // 在移动端也显示主 header
+     if (isMobile) {
+       items.push(MasonryHeaderItem.default)
+     }
+ 
+     // 添加分组和照片
+     groups.forEach((group, groupIndex) => {
+       // 添加分组头部
+       items.push(new PhotoGroupHeaderItem(group, groupIndex))
+       
+       // 添加该分组的照片
+       items.push(...group.photos)
+     })
+ 
+     return items
+   }, [photos, groupBy, isMobile])
  
    return (
      <div className="p-1 lg:p-0 [&_*]:!select-none">
        <FloatingActionBar />
-       {isMobile && <MasonryHeaderMasonryItem className="mb-1" />}
+       {isMobile && groupBy === 'none' && <MasonryHeaderMasonryItem className="mb-1" />}
        <Masonry<MasonryItemType>
-         key={`${sortOrder}-${selectedTags.join(',')}`}
-         items={useMemo(
-           () => (isMobile ? photos : [MasonryHeaderItem.default, ...photos]),
-           [photos, isMobile],
-         )}
+         key={`${sortOrder}-${selectedTags.join(',')}-${groupBy}`}
+         items={masonryItems}
          render={useCallback(
            (props) => (
```

```diff
                hasAnimated={hasAnimatedRef.current}
                onAnimationComplete={handleAnimationComplete}
+               groupBy={groupBy}
              />
            ),
-           [handleAnimationComplete, photoViewer.openViewer, photos],
+           [handleAnimationComplete, photoViewer.openViewer, photos, groupBy],
          )}
          columnWidth={isMobile ? 150 : 300}
          columnGutter={4}
          rowGutter={4}
          itemHeightEstimate={400}
-         itemKey={useTypeScriptHappyCallback((data, _index) => {
+         itemKey={useTypeScriptHappyCallback((data, index) => {
            if (data instanceof MasonryHeaderItem) {
              return 'header'
            }
+           if (data instanceof PhotoGroupHeaderItem) {
+             return `group-${data.group.key}`
+           }
            return (data as PhotoManifest).id
          }, [])}
```

```diff
      hasAnimated,
      onAnimationComplete,
+     groupBy,
    }: {
      data: MasonryItemType
```

```diff
      hasAnimated: boolean
      onAnimationComplete: () => void
+     groupBy: string
    }) => {
      // 为每个 item 生成唯一的 key 用于追踪
```

```diff
          return 'header'
        }
+       if (data instanceof PhotoGroupHeaderItem) {
+         return `group-${data.group.key}`
+       }
        return (data as PhotoManifest).id
      }, [data])
```

```diff
      // 计算动画延迟
      const delay = shouldAnimate
-       ? data instanceof MasonryHeaderItem
+       ? data instanceof MasonryHeaderItem || data instanceof PhotoGroupHeaderItem
          ? 0
          : Math.min(index * 0.05, 0.8)
```

```diff
      if (data instanceof MasonryHeaderItem) {
        return <MasonryHeaderMasonryItem style={{ width }} />
+     } else if (data instanceof PhotoGroupHeaderItem) {
+       return (
+         <m.div
+           key={itemKey}
+           variants={shouldAnimate ? itemVariants : undefined}
+           initial={shouldAnimate ? 'hidden' : 'visible'}
+           animate="visible"
+           onAnimationComplete={shouldAnimate ? onAnimationComplete : undefined}
+         >
+           <PhotoGroupHeader
+             date={data.group.label}
+             count={data.group.photos.length}
+             style={{ width }}
+           />
+         </m.div>
+       )
      } else {
+       // 在分组模式下，需要重新计算照片在原始数组中的索引
+       const photoIndex = groupBy === 'none' 
+         ? photos.findIndex(p => p.id === (data as PhotoManifest).id)
+         : photos.findIndex(p => p.id === (data as PhotoManifest).id)
+ 
        return (
          <m.div
```

```diff
              data={data as PhotoManifest}
              width={width}
-             index={index}
+             index={photoIndex}
              onPhotoClick={onPhotoClick}
              photos={photos}
```

---



---

现在让我运行项目来测试新功能：

---

```bash
cd /Users/innei/git/vibe-coding/photo-gallery-site && pnpm dev
```

---

让我检查是否有任何构建错误：

---

```bash
cd /Users/innei/git/vibe-coding/photo-gallery-site && pnpm build
```

```

> @photo-gallery/monorepo@0.0.1 build /Users/innei/git/vibe-coding/photo-gallery
-site
> pnpm --filter @photo-gallery/web build


> @photo-gallery/web@0.0.1 build /Users/innei/git/vibe-coding/photo-gallery-site
/apps/web
> tsx scripts/build.ts

📚 Loaded 46 photos from manifest
plugin 'html-transform' uses deprecated 'enforce' option. Use 'order' option ins
tead.
plugin 'html-transform' uses deprecated 'transform' option. Use 'handler' option
 instead.
vite v6.3.5 building for production...
✅ Generated favicon: favicon-16x16.png (16x16)
✅ Generated favicon: favicon-32x32.png (32x32)
✅ Generated favicon: favicon-48x48.png (48x48)
✅ Generated favicon: apple-touch-icon.png (180x180)
✅ Generated favicon: android-chrome-192x192.png (192x192)
✅ Generated favicon: android-chrome-512x512.png (512x512)
✅ Generated main favicon: favicon.ico
✅ Generated web manifest: site.webmanifest
🎨 All favicons generated successfully!
📸 Found 4 latest photos
📷 Added photo: DSCF3506 at position (580, 220) with rotation -12°
📷 Added photo: DSCF3449 at position (670, 260) with rotation 5°
📷 Added photo: DSCF3447 at position (760, 190) with rotation -8°
📷 Added photo: DSCF3443 at position (850, 270) with rotation 10°
✅ OG image generated: /Users/innei/git/vibe-coding/photo-gallery-site/apps/web/p
ublic/og-image-1749123758593.png
🖼️  OG image generated: /og-image-1749123758593.png
🗑️  Deleted old OG image: og-image-1749119052398.png
✅ Cleaned up 1 old OG images
transforming (34) ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_mo
dules/react-dom/client.js/*! 🌼 dais
yUI 5.0.43 */
                                    ✓ 1113 modules transformed.
dist/index.html                                 12.83 kB │ gzip:   2.88 kB
dist/assets/GeistVF.p2GUc8.woff2                56.80 kB
dist/assets/index.D543_y.css                     8.91 kB │ gzip:   3.50 kB
dist/assets/index.FAPppl.css                   142.32 kB │ gzip:  23.51 kB
dist/assets/frame-D8otZTtM.js                    0.61 kB │ gzip:   0.41 kB
dist/assets/blurhash-BS5s_D05.js                 0.74 kB │ gzip:   0.46 kB
dist/assets/webgl-preview-D0yCdeII.js            1.28 kB │ gzip:   0.72 kB
dist/assets/heic-converter-rLN1NzNO.js           1.46 kB │ gzip:   0.84 kB
dist/assets/WebGLImageViewer-DOVBAy6o.js        25.18 kB │ gzip:   7.43 kB
dist/assets/visual-element-D22tTFTo.js          34.25 kB │ gzip:  12.59 kB
dist/assets/usePhotoViewer-CAJB3xgu.js          42.59 kB │ gzip:  14.76 kB
dist/assets/layout-DaAqAnf1.js                  44.49 kB │ gzip:  15.99 kB
dist/assets/framer-lazy-feature-Cv795Wn1.js     64.71 kB │ gzip:  20.54 kB
dist/assets/photos-DiyZ9tvg.js                 104.57 kB │ gzip:  12.73 kB
dist/assets/index-CXLdGeoQ.js                  143.73 kB │ gzip:  46.07 kB
dist/vendor/1-CkyqGQkw.js                      187.60 kB │ gzip:  59.12 kB
dist/assets/index-HOoLkkRh.js                  323.31 kB │ gzip: 106.96 kB
dist/vendor/0-kq-hUwqs.js                    2,185.75 kB │ gzip: 547.25 kB

(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollu
pjs.org/configuration-options/#outpu
t-manualchunks
                                    - Adjust chunk size limit for this warning v
ia build.chunkSizeWarningLimit.
✓ built in 3.57s

```

---

很好！构建成功了。现在让我创建一个示例来展示这个功能是如何工作的。让我查看一下日期分组中的一些细节，确保分组功能正常工作。

让我修复一个小问题 - 在分组模式下，我们在移动端不应该显示主 header，因为在桌面端分组模式下也不需要它：

---



---

我看到代码结构有些不同，让我重新读取文件来确认当前内容：

---

Read file: apps/web/src/modules/gallery/MasonryRoot.tsx

---

我发现代码格式有些问题。让我修复分组逻辑中的一个错误，应该分别添加头部和照片：

---



---

现在让我修改分组逻辑，在桌面端显示主header，移动端在分组模式下不显示：

---



---

让我也在移动端的分组模式下显示主 header：

---



---

现在让我修复 PhotoGroupHeader 组件的样式，使其在分组模式下不是 sticky，而是正常的块级元素：

---



---

现在让我运行开发服务器来测试功能：

---

```bash
pnpm dev
```

---

现在，让我总结一下我为你实现的照片分组功能：

## 📸 照片分组功能实现完成！

我已经成功为你的照片画廊网站添加了照片分组功能，类似 Apple 相册的体验。以下是实现的功能详情：

### ✨ 主要功能

1. **分组维度选择**：
   - 不分组（原始瀑布流）
   - 按天分组
   - 按周分组  
   - 按月分组
   - 按年分组

2. **智能日期显示**：
   - 按天：显示"今天"、"昨天"或具体日期
   - 按周：显示日期范围（如"1月1日 - 1月7日"）
   - 按月：显示月份
   - 按年：显示年份

3. **分组头部**：
   - 每个分组都有优雅的头部标题
   - 显示分组名称和照片数量
   - 半透明背景设计，符合现代UI风格

### 🎨 UI设计特色

- **类似 Apple 相册**：干净的分组头部设计
- **响应式布局**：在桌面端和移动端都有良好体验
- **流畅动画**：继承了原有的进入动画效果
- **一致性设计**：与现有UI风格完美融合

### 🔧 技术实现

1. **状态管理**：
   - 扩展了 `gallerySettingAtom` 支持分组模式
   - 新增 `GalleryGroupBy` 类型定义

2. **分组逻辑**：
   - 创建了 `photoGroup.ts` 工具函数
   - 支持按不同时间维度智能分组
   - 优化的日期格式化和本地化

3. **组件架构**：
   - 新增 `PhotoGroupHeader` 组件
   - 更新 `MasonryRoot` 支持分组渲染
   - 在 `ActionGroup` 中添加分组控制按钮

### 🎯 使用方式

用户现在可以：
1. 点击 header 中的分组按钮（网格图标）
2. 选择分组方式：不分组/按天/按周/按月/按年
3. 照片会自动按选择的时间维度重新组织
4. 每个分组显示清晰的日期标题和照片数量

### 📱 响应式体验

- **桌面端**：分组头部和主 header 都正常显示
- **移动端**：优化的布局，确保在小屏幕上也有良好体验

这个实现完全符合你的需求，提供了类似 Apple 相册的分组体验，同时保持了原有项目的设计语言和性能特点。你可以启动开发服务器查看效果！

---

_**User**_

不行，你不应该插入一个 PhotoGroupHeader 到 masonry，而不是应该像 apple 相册这样做一个全局的 header。

---

_**Assistant**_

你说得对！我理解错了。Apple 相册是有一个全局的分组标题，然后下面是整个分组的照片网格。让我重新实现这个功能。

---

Read file: apps/web/src/modules/gallery/MasonryRoot.tsx

---

现在我重新设计这个功能。我需要创建一个新的组件结构，每个分组都有自己的全局标题和独立的 Masonry 网格，就像 Apple 相册那样：

---

```diff
- 
+ import { m } from 'motion/react'
+ import { memo, useCallback, useMemo, useRef } from 'react'
+ 
+ import { useMobile } from '~/hooks/useMobile'
+ import { useTypeScriptHappyCallback } from '~/hooks/useTypeScriptCallback'
+ import type { PhotoGroup } from '~/lib/photoGroup'
+ import type { PhotoManifest } from '~/types/photo'
+ 
+ import { Masonry } from './Masonic'
+ import { PhotoMasonryItem } from './PhotoMasonryItem'
+ 
+ interface PhotoGroupSectionProps {
+   group: PhotoGroup
+   onPhotoClick: (index: number, element?: HTMLElement) => void
+   photos: PhotoManifest[]
+   hasAnimated: boolean
+   onAnimationComplete: () => void
+ }
+ 
+ export const PhotoGroupSection = memo(
+   ({
+     group,
+     onPhotoClick,
+     photos,
+     hasAnimated,
+     onAnimationComplete,
+   }: PhotoGroupSectionProps) => {
+     const isMobile = useMobile()
+     const hasAnimatedRef = useRef(hasAnimated)
+ 
+     return (
+       <div className="mb-8">
+         {/* 分组标题 */}
+         <div className="mb-4 flex items-center justify-between px-1">
+           <div className="flex items-center gap-3">
+             <h2 className="text-2xl font-bold text-gray-900 dark:text-white">
+               {group.label}
+             </h2>
+             <span className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-400">
+               {group.photos.length} 张照片
+             </span>
+           </div>
+         </div>
+ 
+         {/* 分组的照片网格 */}
+         <Masonry<PhotoManifest>
+           key={group.key}
+           items={group.photos}
+           render={useCallback(
+             (props) => (
+               <GroupPhotoItem
+                 {...props}
+                 onPhotoClick={onPhotoClick}
+                 photos={photos}
+                 hasAnimated={hasAnimatedRef.current}
+                 onAnimationComplete={onAnimationComplete}
+               />
+             ),
+             [onPhotoClick, photos, onAnimationComplete],
+           )}
+           columnWidth={isMobile ? 150 : 300}
+           columnGutter={4}
+           rowGutter={4}
+           itemHeightEstimate={400}
+           itemKey={useTypeScriptHappyCallback((data, _index) => {
+             return data.id
+           }, [])}
+         />
+       </div>
+     )
+   },
+ )
+ 
+ const GroupPhotoItem = memo(
+   ({
+     data,
+     width,
+     index,
+     onPhotoClick,
+     photos,
+     hasAnimated,
+     onAnimationComplete,
+   }: {
+     data: PhotoManifest
+     width: number
+     index: number
+     onPhotoClick: (index: number, element?: HTMLElement) => void
+     photos: PhotoManifest[]
+     hasAnimated: boolean
+     onAnimationComplete: () => void
+   }) => {
+     const itemKey = useMemo(() => data.id, [data.id])
+ 
+     // 计算照片在原始数组中的索引
+     const photoIndex = photos.findIndex((p) => p.id === data.id)
+ 
+     // 动画相关
+     const shouldAnimate = !hasAnimated && index < 30
+     const delay = shouldAnimate ? Math.min(index * 0.05, 0.8) : 0
+ 
+     const itemVariants = {
+       hidden: {
+         opacity: 0,
+         y: 30,
+         scale: 0.95,
+         filter: 'blur(4px)',
+       },
+       visible: {
+         opacity: 1,
+         y: 0,
+         scale: 1,
+         filter: 'blur(0px)',
+         transition: {
+           duration: shouldAnimate ? 0.8 : 0,
+           ease: [0.16, 1, 0.3, 1],
+           delay,
+         },
+       },
+     }
+ 
+     return (
+       <m.div
+         key={itemKey}
+         variants={shouldAnimate ? itemVariants : undefined}
+         initial={shouldAnimate ? 'hidden' : 'visible'}
+         animate="visible"
+         onAnimationComplete={shouldAnimate ? onAnimationComplete : undefined}
+       >
+         <PhotoMasonryItem
+           data={data}
+           width={width}
+           index={photoIndex}
+           onPhotoClick={onPhotoClick}
+           photos={photos}
+         />
+       </m.div>
+     )
+   },
+ ) 
```

---

现在重新设计 MasonryRoot 组件，使其能够渲染多个分组，每个分组都有自己的标题和网格：

---

```diff
  import { usePhotos, usePhotoViewer } from '~/hooks/usePhotoViewer'
  import { useTypeScriptHappyCallback } from '~/hooks/useTypeScriptCallback'
- import type { PhotoGroup } from '~/lib/photoGroup'
  import { groupPhotosByDate } from '~/lib/photoGroup'
  import { Spring } from '~/lib/spring'
```

```diff
  import { Masonry } from './Masonic'
  import { MasonryHeaderMasonryItem } from './MasonryHeaderMasonryItem'
- import { PhotoGroupHeader } from './PhotoGroupHeader'
+ import { PhotoGroupSection } from './PhotoGroupSection'
  import { PhotoMasonryItem } from './PhotoMasonryItem'
  
```

```diff
  }
  
- class PhotoGroupHeaderItem {
-   constructor(
-     public group: PhotoGroup,
-     public index: number,
-   ) {}
- }
- 
- type MasonryItemType = PhotoManifest | MasonryHeaderItem | PhotoGroupHeaderItem
+ type MasonryItemType = PhotoManifest | MasonryHeaderItem
  
  const FIRST_SCREEN_ITEMS_COUNT = 30
```

```diff
    const isMobile = useMobile()
  
-   // 处理分组逻辑
-   const masonryItems = useMemo(() => {
-     if (groupBy === 'none') {
-       // 非分组模式，使用原有逻辑
-       return isMobile ? photos : [MasonryHeaderItem.default, ...photos]
-     }
- 
-     // 分组模式
+   // 分组模式渲染
+   if (groupBy !== 'none') {
      const groups = groupPhotosByDate(photos, groupBy)
-     const items: MasonryItemType[] = []
- 
-     // 在分组模式下，只在桌面端显示主 header
-     if (!isMobile) {
-       items.push(MasonryHeaderItem.default)
-     }
- 
-     // 添加分组和照片
-     groups.forEach((group, groupIndex) => {
-       // 添加分组头部
-       items.push(new PhotoGroupHeaderItem(group, groupIndex), ...group.photos)
-     })
- 
-     return items
-   }, [photos, groupBy, isMobile])
+ 
+     return (
+       <div className="p-1 lg:p-0 [&_*]:!select-none">
+         <FloatingActionBar />
+         {isMobile && <MasonryHeaderMasonryItem className="mb-6" />}
+         {!isMobile && <MasonryHeaderMasonryItem className="mb-8" />}
+         
+         {/* 渲染每个分组 */}
+         <div className="space-y-12">
+           {groups.map((group) => (
+             <PhotoGroupSection
+               key={group.key}
+               group={group}
+               onPhotoClick={photoViewer.openViewer}
+               photos={photos}
+               hasAnimated={hasAnimatedRef.current}
+               onAnimationComplete={handleAnimationComplete}
+             />
+           ))}
+         </div>
+       </div>
+     )
+   }
+ 
+   // 非分组模式，使用原有的单一 Masonry
+   const masonryItems = useMemo(() => {
+     return isMobile ? photos : [MasonryHeaderItem.default, ...photos]
+   }, [photos, isMobile])
  
    return (
```

```diff
        {isMobile && <MasonryHeaderMasonryItem className="mb-1" />}
        <Masonry<MasonryItemType>
-         key={`${sortOrder}-${selectedTags.join(',')}-${groupBy}`}
+         key={`${sortOrder}-${selectedTags.join(',')}`}
          items={masonryItems}
          render={useCallback(
```

```diff
                hasAnimated={hasAnimatedRef.current}
                onAnimationComplete={handleAnimationComplete}
-               groupBy={groupBy}
              />
            ),
-           [handleAnimationComplete, photoViewer.openViewer, photos, groupBy],
+           [handleAnimationComplete, photoViewer.openViewer, photos],
          )}
          columnWidth={isMobile ? 150 : 300}
```

```diff
              return 'header'
            }
-           if (data instanceof PhotoGroupHeaderItem) {
-             return `group-${data.group.key}`
-           }
            return (data as PhotoManifest).id
          }, [])}
```

```diff
      hasAnimated,
      onAnimationComplete,
-     groupBy,
    }: {
      data: MasonryItemType
```

```diff
      hasAnimated: boolean
      onAnimationComplete: () => void
-     groupBy: string
    }) => {
      // 为每个 item 生成唯一的 key 用于追踪
```

```diff
          return 'header'
        }
-       if (data instanceof PhotoGroupHeaderItem) {
-         return `group-${data.group.key}`
-       }
        return (data as PhotoManifest).id
      }, [data])
```

```diff
      // 计算动画延迟
      const delay = shouldAnimate
-       ? data instanceof MasonryHeaderItem ||
-         data instanceof PhotoGroupHeaderItem
+       ? data instanceof MasonryHeaderItem
          ? 0
          : Math.min(index * 0.05, 0.8)
```

```diff
      if (data instanceof MasonryHeaderItem) {
        return <MasonryHeaderMasonryItem style={{ width }} />
-     } else if (data instanceof PhotoGroupHeaderItem) {
-       return (
-         <m.div
-           key={itemKey}
-           variants={shouldAnimate ? itemVariants : undefined}
-           initial={shouldAnimate ? 'hidden' : 'visible'}
-           animate="visible"
-           onAnimationComplete={shouldAnimate ? onAnimationComplete : undefined}
-         >
-           <PhotoGroupHeader
-             date={data.group.label}
-             count={data.group.photos.length}
-             style={{ width }}
-           />
-         </m.div>
-       )
      } else {
-       // 在分组模式下，需要重新计算照片在原始数组中的索引
-       const photoIndex =
-         groupBy === 'none'
-           ? photos.findIndex((p) => p.id === (data as PhotoManifest).id)
-           : photos.findIndex((p) => p.id === (data as PhotoManifest).id)
- 
        return (
          <m.div
```

```diff
              data={data as PhotoManifest}
              width={width}
-             index={photoIndex}
+             index={index}
              onPhotoClick={onPhotoClick}
              photos={photos}
```

---

我需要修复 React Hooks 的条件调用错误。让我重新组织代码结构：

---

```diff
    const photos = usePhotos()
    const photoViewer = usePhotoViewer()
+   const isMobile = useMobile()
  
    const handleAnimationComplete = useCallback(() => {
      hasAnimatedRef.current = true
    }, [])
  
-   const isMobile = useMobile()
+   // 准备非分组模式的数据
+   const masonryItems = useMemo(() => {
+     return isMobile ? photos : [MasonryHeaderItem.default, ...photos]
+   }, [photos, isMobile])
+ 
+   const renderMasonryItem = useCallback(
+     (props: any) => (
+       <MasonryItem
+         {...props}
+         onPhotoClick={photoViewer.openViewer}
+         photos={photos}
+         hasAnimated={hasAnimatedRef.current}
+         onAnimationComplete={handleAnimationComplete}
+       />
+     ),
+     [handleAnimationComplete, photoViewer.openViewer, photos],
+   )
+ 
+   const itemKey = useTypeScriptHappyCallback((data, _index) => {
+     if (data instanceof MasonryHeaderItem) {
+       return 'header'
+     }
+     return (data as PhotoManifest).id
+   }, [])
  
    // 分组模式渲染
```

```diff
  
    // 非分组模式，使用原有的单一 Masonry
-   const masonryItems = useMemo(() => {
-     return isMobile ? photos : [MasonryHeaderItem.default, ...photos]
-   }, [photos, isMobile])
- 
    return (
      <div className="p-1 lg:p-0 [&_*]:!select-none">
```

```diff
          key={`${sortOrder}-${selectedTags.join(',')}`}
          items={masonryItems}
-         render={useCallback(
-           (props) => (
-             <MasonryItem
-               {...props}
-               onPhotoClick={photoViewer.openViewer}
-               photos={photos}
-               hasAnimated={hasAnimatedRef.current}
-               onAnimationComplete={handleAnimationComplete}
-             />
-           ),
-           [handleAnimationComplete, photoViewer.openViewer, photos],
-         )}
+         render={renderMasonryItem}
          columnWidth={isMobile ? 150 : 300}
          columnGutter={4}
          rowGutter={4}
          itemHeightEstimate={400}
-         itemKey={useTypeScriptHappyCallback((data, _index) => {
-           if (data instanceof MasonryHeaderItem) {
-             return 'header'
-           }
-           return (data as PhotoManifest).id
-         }, [])}
+         itemKey={itemKey}
        />
      </div>
```

---

现在让我删除不再需要的 PhotoGroupHeader 文件：

---

删除不再需要的 PhotoGroupHeader 组件文件

---

现在让我测试一下构建是否成功：

---

```bash
pnpm build
```

```
at 19:42:42 ❯ pnpm build

> @photo-gallery/monorepo@0.0.1 build /Users/innei/git/vibe-coding/photo-gallery-site
> pnpm --filter @photo-gallery/web build


> @photo-gallery/web@0.0.1 build /Users/innei/git/vibe-coding/photo-gallery-site/apps/web
> tsx scripts/build.ts

📚 Loaded 46 photos from manifest
plugin 'html-transform' uses deprecated 'enforce' option. Use 'order' option instead.
plugin 'html-transform' uses deprecated 'transform' option. Use 'handler' option instead.
vite v6.3.5 building for production...
✅ Generated favicon: favicon-16x16.png (16x16)
✅ Generated favicon: favicon-32x32.png (32x32)
✅ Generated favicon: favicon-48x48.png (48x48)
✅ Generated favicon: apple-touch-icon.png (180x180)
✅ Generated favicon: android-chrome-192x192.png (192x192)
✅ Generated favicon: android-chrome-512x512.png (512x512)
✅ Generated main favicon: favicon.ico
✅ Generated web manifest: site.webmanifest
🎨 All favicons generated successfully!
📸 Found 4 latest photos
📷 Added photo: DSCF3506 at position (580, 220) with rotation -12°
📷 Added photo: DSCF3449 at position (670, 260) with rotation 5°
📷 Added photo: DSCF3447 at position (760, 190) with rotation -8°
📷 Added photo: DSCF3443 at position (850, 270) with rotation 10°
✅ OG image generated: /Users/innei/git/vibe-coding/photo-gallery-site/apps/web/public/og-image-1749124178568.png
🖼️  OG image generated: /og-image-1749124178568.png
🗑️  Deleted old OG image: og-image-1749123755183.png
✅ Cleaned up 1 old OG images
transforming (34) ../../node_modules/.pnpm/jotai@2.12.5_@types+react@19.1.6_react@19.1.0/node_modules/jotai/esm/*! 
🌼 daisyUI 5.0.43 */
✓ 1113 modules transformed.
dist/index.html                                 12.83 kB │ gzip:   2.88 kB
dist/assets/GeistVF.p2GUc8.woff2                56.80 kB
dist/assets/index.D543_y.css                     8.91 kB │ gzip:   3.50 kB
dist/assets/index.6CRmfA.css                   141.39 kB │ gzip:  23.35 kB
dist/assets/frame-Bjh0go-Z.js                    0.61 kB │ gzip:   0.41 kB
dist/assets/blurhash-BS5s_D05.js                 0.74 kB │ gzip:   0.46 kB
dist/assets/webgl-preview-BaJNr8Ua.js            1.28 kB │ gzip:   0.72 kB
dist/assets/heic-converter-BNw6SJKC.js           1.46 kB │ gzip:   0.84 kB
dist/assets/WebGLImageViewer-DOVBAy6o.js        25.18 kB │ gzip:   7.43 kB
dist/assets/visual-element-biMKql9x.js          34.25 kB │ gzip:  12.59 kB
dist/assets/usePhotoViewer-B1rHL2J2.js          42.59 kB │ gzip:  14.76 kB
dist/assets/layout-_99_AOfN.js                  44.54 kB │ gzip:  15.85 kB
dist/assets/framer-lazy-feature-DkscL9TR.js     64.71 kB │ gzip:  20.54 kB
dist/assets/photos-DiyZ9tvg.js                 104.57 kB │ gzip:  12.73 kB
dist/assets/index-BtldlibO.js                  143.73 kB │ gzip:  46.07 kB
dist/vendor/1-CkyqGQkw.js                      187.60 kB │ gzip:  59.12 kB
dist/assets/index-CBa5cs1N.js                  323.31 kB │ gzip: 106.96 kB
dist/vendor/0-kq-hUwqs.js                    2,185.75 kB │ gzip: 547.25 kB

(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#outpu
t-manualchunks
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
✓ built in 5.91s

innei in photo-gallery-site on  main [✘!?] via  v22.15.0 on ☁️  innei@rss3.io at     b51d81b "chore: update pack
age dependencies and versions"  by tukon479@gmail.com took 7s 
at 19:49:44 ❯
```

---

很好！构建成功了。现在让我启动开发服务器来查看效果：

---

```bash
pnpm dev
```

---

