feat: init
128
.cursor/rules/builder-storage.mdc
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
description:
|
||||
globs: src/core/**/*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
# 存储抽象层
|
||||
|
||||
本模块使用适配器模式提供了统一的存储接口,支持多种存储提供商。目前已实现 S3 存储提供商,后续可以方便地扩展其他存储服务。
|
||||
|
||||
## 架构设计
|
||||
|
||||
- **`interfaces.ts`**: 定义通用的存储接口和数据结构
|
||||
- **`providers/`**: 具体的存储提供商实现
|
||||
- **`s3-provider.ts`**: S3 存储提供商实现
|
||||
- **`factory.ts`**: 存储提供商工厂,负责创建具体的存储实例
|
||||
- **`manager.ts`**: 存储管理器,提供统一的存储操作接口
|
||||
- **`adapters.ts`**: 适配器函数,保持与原有 API 的兼容性
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 推荐方式:使用 StorageManager
|
||||
|
||||
```typescript
|
||||
import { defaultStorageManager, StorageManager } from '@/core/storage'
|
||||
|
||||
// 使用默认存储管理器(基于环境变量配置)
|
||||
const buffer = await defaultStorageManager.getFile('path/to/image.jpg')
|
||||
const images = await defaultStorageManager.listImages()
|
||||
const allFiles = await defaultStorageManager.listAllFiles()
|
||||
const publicUrl = defaultStorageManager.generatePublicUrl('path/to/image.jpg')
|
||||
const livePhotos = await defaultStorageManager.detectLivePhotos()
|
||||
|
||||
// 或者创建自定义配置的存储管理器
|
||||
const customManager = new StorageManager({
|
||||
provider: 's3',
|
||||
bucket: 'my-bucket',
|
||||
region: 'us-east-1',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
prefix: 'photos/',
|
||||
customDomain: 'https://cdn.example.com'
|
||||
})
|
||||
```
|
||||
|
||||
### 兼容性方式:使用原有 API
|
||||
|
||||
```typescript
|
||||
// 这些函数仍然可用,但标记为 deprecated
|
||||
import {
|
||||
getImageFromS3,
|
||||
listImagesFromS3,
|
||||
listAllFilesFromS3,
|
||||
detectLivePhotos,
|
||||
generateS3Url
|
||||
} from '@/core/s3/operations'
|
||||
|
||||
const buffer = await getImageFromS3('path/to/image.jpg')
|
||||
const images = await listImagesFromS3()
|
||||
```
|
||||
|
||||
### 直接使用存储提供商
|
||||
|
||||
```typescript
|
||||
import { S3StorageProvider } from '@/core/storage'
|
||||
|
||||
const s3Provider = new S3StorageProvider({
|
||||
provider: 's3',
|
||||
bucket: 'my-bucket',
|
||||
region: 'us-east-1',
|
||||
// ... 其他配置
|
||||
})
|
||||
|
||||
const buffer = await s3Provider.getFile('path/to/image.jpg')
|
||||
```
|
||||
|
||||
## 扩展新的存储提供商
|
||||
|
||||
1. 实现 `StorageProvider` 接口:
|
||||
|
||||
```typescript
|
||||
import { StorageProvider, StorageObject } from '@/core/storage/interfaces'
|
||||
|
||||
export class MyStorageProvider implements StorageProvider {
|
||||
async getFile(key: string, logger?: Logger['s3']): Promise<Buffer | null> {
|
||||
// 实现文件获取逻辑
|
||||
}
|
||||
|
||||
async listImages(): Promise<StorageObject[]> {
|
||||
// 实现图片列表逻辑
|
||||
}
|
||||
|
||||
// ... 实现其他接口方法
|
||||
}
|
||||
```
|
||||
|
||||
2. 在工厂类中注册新的提供商:
|
||||
|
||||
```typescript
|
||||
// 在 factory.ts 中添加新的 case
|
||||
case 'my-storage':
|
||||
return new MyStorageProvider(config)
|
||||
```
|
||||
|
||||
3. 更新 `StorageConfig` 接口的 `provider` 类型。
|
||||
|
||||
## 优势
|
||||
|
||||
1. **解耦**: 业务逻辑与具体存储实现分离
|
||||
2. **可扩展**: 轻松添加新的存储提供商
|
||||
3. **统一接口**: 所有存储操作使用相同的 API
|
||||
4. **向后兼容**: 保持原有 API 的兼容性
|
||||
5. **类型安全**: 完整的 TypeScript 类型支持
|
||||
6. **配置灵活**: 支持多种配置方式
|
||||
|
||||
## 迁移指南
|
||||
|
||||
现有代码可以继续使用原有的 S3 函数,无需立即修改。建议在新代码中使用 `StorageManager` API,并逐步迁移现有代码。
|
||||
|
||||
旧代码:
|
||||
```typescript
|
||||
import { getImageFromS3 } from '@/core/s3/operations'
|
||||
const buffer = await getImageFromS3('key')
|
||||
```
|
||||
|
||||
新代码:
|
||||
```typescript
|
||||
import { defaultStorageManager } from '@/core/storage'
|
||||
const buffer = await defaultStorageManager.getFile('key')
|
||||
```
|
||||
191
.cursor/rules/color.mdc
Normal file
@@ -0,0 +1,191 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# UIKit Colors for Tailwind CSS
|
||||
|
||||
You should use @https://github.com/Innei/apple-uikit-colors/blob/main/packages/uikit-colors/macos.ts TailwindCSS atom classname.
|
||||
|
||||
## System Colors
|
||||
red
|
||||
orange
|
||||
yellow
|
||||
green
|
||||
mint
|
||||
teal
|
||||
cyan
|
||||
blue
|
||||
indigo
|
||||
purple
|
||||
pink
|
||||
brown
|
||||
gray
|
||||
|
||||
## Fill Colors
|
||||
fill
|
||||
fill-secondary
|
||||
fill-tertiary
|
||||
fill-quaternary
|
||||
fill-quinary
|
||||
fill-vibrant
|
||||
fill-vibrant-secondary
|
||||
fill-vibrant-tertiary
|
||||
fill-vibrant-quaternary
|
||||
fill-vibrant-quinary
|
||||
|
||||
## Text Colors
|
||||
text
|
||||
text-secondary
|
||||
text-tertiary
|
||||
text-quaternary
|
||||
text-quinary
|
||||
text-vibrant
|
||||
text-vibrant-secondary
|
||||
text-vibrant-tertiary
|
||||
text-vibrant-quaternary
|
||||
text-vibrant-quinary
|
||||
|
||||
## Material Colors
|
||||
material-ultra-thick
|
||||
material-thick
|
||||
material-medium
|
||||
material-thin
|
||||
material-ultra-thin
|
||||
material-opaque
|
||||
|
||||
## Control Colors
|
||||
control-enabled
|
||||
control-disabled
|
||||
|
||||
## Interface Colors
|
||||
menu
|
||||
popover
|
||||
titlebar
|
||||
sidebar
|
||||
selection-focused
|
||||
selection-focused-fill
|
||||
selection-unfocused
|
||||
selection-unfocused-fill
|
||||
header-view
|
||||
tooltip
|
||||
under-window-background
|
||||
|
||||
|
||||
## Applied Colors
|
||||
All above tailwind atom will match this colors.
|
||||
|
||||
```css
|
||||
@media (prefers-color-scheme: light) {
|
||||
html {
|
||||
--color-red: 255 69 58;
|
||||
--color-orange: 255 149 0;
|
||||
--color-yellow: 255 204 0;
|
||||
--color-green: 40 205 65;
|
||||
--color-mint: 0 199 190;
|
||||
--color-teal: 89 173 196;
|
||||
--color-cyan: 85 190 240;
|
||||
--color-blue: 0 122 255;
|
||||
--color-indigo: 88 86 214;
|
||||
--color-purple: 175 82 222;
|
||||
--color-pink: 255 45 85;
|
||||
--color-brown: 162 132 94;
|
||||
--color-gray: 142 142 147;
|
||||
--color-fill: 0 0 0 / 0.1;
|
||||
--color-fillSecondary: 0 0 0 / 0.08;
|
||||
--color-fillTertiary: 0 0 0 / 0.05;
|
||||
--color-fillQuaternary: 0 0 0 / 0.03;
|
||||
--color-fillQuinary: 0 0 0 / 0.02;
|
||||
--color-fillVibrant: 217 217 217;
|
||||
--color-fillVibrantSecondary: 230 230 230;
|
||||
--color-fillVibrantTertiary: 242 242 242;
|
||||
--color-fillVibrantQuaternary: 247 247 247;
|
||||
--color-fillVibrantQuinary: 251 251 251;
|
||||
--color-text: 0 0 0 / 0.85;
|
||||
--color-textSecondary: 0 0 0 / 0.5;
|
||||
--color-textTertiary: 0 0 0 / 0.25;
|
||||
--color-textQuaternary: 0 0 0 / 0.1;
|
||||
--color-textQuinary: 0 0 0 / 0.05;
|
||||
--color-textVibrant: 76 76 76;
|
||||
--color-textVibrantSecondary: 128 128 128;
|
||||
--color-textVibrantTertiary: 191 191 191;
|
||||
--color-textVibrantQuaternary: 230 230 230;
|
||||
--color-textVibrantQuinary: 242 242 242;
|
||||
--color-materialUltraThick: 246 246 246 / 0.84;
|
||||
--color-materialThick: 246 246 246 / 0.72;
|
||||
--color-materialMedium: 246 246 246 / 0.6;
|
||||
--color-materialThin: 246 246 246 / 0.48;
|
||||
--color-materialUltraThin: 246 246 246 / 0.36;
|
||||
--color-materialOpaque: 246 246 246;
|
||||
--color-controlEnabled: 251 251 251;
|
||||
--color-controlDisabled: 243 243 243;
|
||||
--color-menu: 40 40 40 / 0.58;
|
||||
--color-popover: 0 0 0 / 0.28;
|
||||
--color-titlebar: 234 234 234 / 0.8;
|
||||
--color-sidebar: 234 234 234 / 0.84;
|
||||
--color-selectionFocused: 10 130 255 / 0.75;
|
||||
--color-selectionFocusedFill: 10 130 255;
|
||||
--color-selectionUnfocused: 0 0 0 / 0.1;
|
||||
--color-selectionUnfocusedFill: 246 246 246 / 0.84;
|
||||
--color-headerView: 255 255 255 / 0.8;
|
||||
--color-tooltip: 246 246 246 / 0.6;
|
||||
--color-underWindowBackground: 246 246 246 / 0.84;
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
--color-red: 255 69 58;
|
||||
--color-orange: 255 159 10;
|
||||
--color-yellow: 255 214 10;
|
||||
--color-green: 50 215 75;
|
||||
--color-mint: 106 196 220;
|
||||
--color-teal: 106 196 220;
|
||||
--color-cyan: 90 200 245;
|
||||
--color-blue: 10 132 255;
|
||||
--color-indigo: 94 92 230;
|
||||
--color-purple: 191 90 242;
|
||||
--color-pink: 255 55 95;
|
||||
--color-brown: 172 142 104;
|
||||
--color-gray: 152 152 157;
|
||||
--color-fill: 255 255 255 / 0.1;
|
||||
--color-fillSecondary: 255 255 255 / 0.08;
|
||||
--color-fillTertiary: 255 255 255 / 0.05;
|
||||
--color-fillQuaternary: 255 255 255 / 0.03;
|
||||
--color-fillQuinary: 255 255 255 / 0.02;
|
||||
--color-fillVibrant: 36 36 36;
|
||||
--color-fillVibrantSecondary: 20 20 20;
|
||||
--color-fillVibrantTertiary: 13 13 13;
|
||||
--color-fillVibrantQuaternary: 9 9 9;
|
||||
--color-fillVibrantQuinary: 7 7 7;
|
||||
--color-text: 255 255 255 / 0.85;
|
||||
--color-textSecondary: 255 255 255 / 0.5;
|
||||
--color-textTertiary: 255 255 255 / 0.25;
|
||||
--color-textQuaternary: 255 255 255 / 0.1;
|
||||
--color-textQuinary: 255 255 255 / 0.05;
|
||||
--color-textVibrant: 229 229 229;
|
||||
--color-textVibrantSecondary: 124 124 124;
|
||||
--color-textVibrantTertiary: 65 65 65;
|
||||
--color-textVibrantQuaternary: 35 35 35;
|
||||
--color-textVibrantQuinary: 17 17 17;
|
||||
--color-materialUltraThick: 40 40 40 / 0.84;
|
||||
--color-materialThick: 40 40 40 / 0.72;
|
||||
--color-materialMedium: 40 40 40 / 0.6;
|
||||
--color-materialThin: 40 40 40 / 0.48;
|
||||
--color-materialUltraThin: 40 40 40 / 0.36;
|
||||
--color-materialOpaque: 40 40 40;
|
||||
--color-controlEnabled: 255 255 255 / 0.2;
|
||||
--color-controlDisabled: 255 255 255 / 0.1;
|
||||
--color-menu: 246 246 246 / 0.72;
|
||||
--color-popover: 246 246 246 / 0.6;
|
||||
--color-titlebar: 60 60 60 / 0.8;
|
||||
--color-sidebar: 0 0 0 / 0.45;
|
||||
--color-selectionFocused: 10 130 255 / 0.75;
|
||||
--color-selectionFocusedFill: 10 130 255;
|
||||
--color-selectionUnfocused: 255 255 255 / 0.1;
|
||||
--color-selectionUnfocusedFill: 40 40 40 / 0.65;
|
||||
--color-headerView: 30 30 30 / 0.8;
|
||||
--color-tooltip: 0 0 0 / 0.35;
|
||||
--color-underWindowBackground: 0 0 0 / 0.45;
|
||||
}
|
||||
}
|
||||
```
|
||||
7
.env.template
Normal file
@@ -0,0 +1,7 @@
|
||||
S3_REGION=us-east-1
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_BUCKET_NAME=images
|
||||
S3_PREFIX=
|
||||
S3_ENDPOINT=
|
||||
S3_CUSTOM_DOMAIN=
|
||||
41
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [lts/*]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Cache pnpm modules
|
||||
uses: actions/cache@v4
|
||||
env:
|
||||
cache-name: cache-pnpm-modules
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: true
|
||||
- run: pnpm run build
|
||||
20
.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
public/og-image-*.png
|
||||
test-svg-font-rendering.png
|
||||
test-traditional-font-rendering.png
|
||||
thomas-x2d-xcd-25v-1.jpg
|
||||
thomas-x2d-xcd-25v-1.webp
|
||||
|
||||
config.json
|
||||
.env
|
||||
builder.config.json
|
||||
|
||||
apps/web/assets-git
|
||||
apps/web/public/thumbnails
|
||||
apps/web/src/data/photos-manifest.json
|
||||
.vercel
|
||||
5
.prettierrc.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
import { factory } from '@innei/prettier'
|
||||
|
||||
export default factory({
|
||||
importSort: false,
|
||||
})
|
||||
39
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"[javascript][javascriptreact][typescript][typescriptreact][json][jsonc]": {
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
// If you do not want to autofix some rules on save
|
||||
// You can put this in your user settings or workspace settings
|
||||
"eslint.codeActionsOnSave.rules": [
|
||||
"!unused-imports/no-unused-imports",
|
||||
"*"
|
||||
],
|
||||
// If you want to silent stylistic rules
|
||||
// You can put this in your user settings or workspace settings
|
||||
"eslint.rules.customizations": [
|
||||
{
|
||||
"rule": "@stylistic/*",
|
||||
"severity": "off",
|
||||
"fixable": true
|
||||
},
|
||||
{
|
||||
"rule": "antfu/consistent-list-newline",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "hyoban/jsx-attribute-spacing",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "simple-import-sort/*",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "unused-imports/no-unused-imports",
|
||||
"severity": "off"
|
||||
}
|
||||
]
|
||||
}
|
||||
119
README.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Photo Gallery Site
|
||||
|
||||
⚠️警告:此项目多数代码都由 Claude 4 生成,请谨慎使用。
|
||||
|
||||
一个现代化的照片画廊网站,支持从 S3 存储自动同步照片,具有瀑布流布局、EXIF 信息展示、缩略图生成等功能。
|
||||
|
||||
Preview: https://gallery.innei.in
|
||||
|
||||
## 特点
|
||||
|
||||
- 高性能 WebGL 图像渲染器
|
||||
- HEIC/HEIF 格式支持
|
||||
- 支持缩略图生成
|
||||
- 支持 EXIF 信息展示
|
||||
- 瀑布流布局
|
||||
- 支持富士胶片模拟信息读取
|
||||
|
||||
## 环境配置
|
||||
|
||||
创建 `.env` 文件并配置以下环境变量:
|
||||
|
||||
```env
|
||||
# S3 配置
|
||||
S3_REGION=us-east-1
|
||||
S3_ACCESS_KEY_ID=your_access_key_id
|
||||
S3_SECRET_ACCESS_KEY=your_secret_access_key
|
||||
S3_ENDPOINT=https://s3.us-east-1.amazonaws.com
|
||||
S3_BUCKET_NAME=your_bucket_name
|
||||
S3_PREFIX=photos/
|
||||
S3_CUSTOM_DOMAIN=your_custom_domain.com
|
||||
```
|
||||
|
||||
## Photo Gallery Builder
|
||||
|
||||
基于适配器模式重构的照片库构建器,提供灵活的存储抽象和可配置的构建选项。
|
||||
|
||||
### 配置文件
|
||||
|
||||
在项目根目录的 `builder.config.ts` 中可以配置构建器的各种选项:
|
||||
|
||||
```typescript
|
||||
export const builderConfig: BuilderConfig = {
|
||||
storage: {
|
||||
provider: 's3',
|
||||
bucket: 'my-bucket',
|
||||
region: 'us-east-1',
|
||||
// ... 其他存储配置
|
||||
},
|
||||
|
||||
options: {
|
||||
defaultConcurrency: 8, // 默认并发数
|
||||
maxPhotos: 5000, // 最大照片数量限制
|
||||
enableLivePhotoDetection: true, // 启用 Live Photo 检测
|
||||
showProgress: true, // 显示进度
|
||||
showDetailedStats: true, // 显示详细统计
|
||||
},
|
||||
|
||||
logging: {
|
||||
verbose: true, // 详细日志
|
||||
level: 'debug', // 日志级别
|
||||
outputToFile: false, // 是否输出到文件
|
||||
},
|
||||
|
||||
performance: {
|
||||
worker: {
|
||||
timeout: 30000, // Worker 超时时间
|
||||
},
|
||||
memoryLimit: 512, // 内存限制(MB)
|
||||
enableCache: true, // 启用缓存
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### 自定义存储提供商
|
||||
|
||||
如果需要使用其他存储服务(如阿里云 OSS),可以:
|
||||
|
||||
1. 实现新的存储提供商类
|
||||
2. 在配置中指定使用新的提供商
|
||||
|
||||
```typescript
|
||||
const builder = new PhotoGalleryBuilder({
|
||||
storage: {
|
||||
provider: 'oss', // 假设已经实现了 OSS 提供商
|
||||
bucket: 'my-oss-bucket',
|
||||
// ... OSS 特定配置
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## 🚀 使用
|
||||
|
||||
### 开发模式
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 构建照片清单
|
||||
|
||||
```bash
|
||||
# 增量更新(默认)
|
||||
pnpm run build:manifest
|
||||
|
||||
# 全量更新
|
||||
pnpm run build:manifest -- --force
|
||||
```
|
||||
|
||||
### 构建生产版本
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
2025 © Innei, Released under the MIT License.
|
||||
|
||||
> [Personal Website](https://innei.in/) · GitHub [@Innei](https://github.com/innei/)
|
||||
6
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
public/og-image-*.png
|
||||
test-svg-font-rendering.png
|
||||
test-traditional-font-rendering.png
|
||||
thomas-x2d-xcd-25v-1.jpg
|
||||
thomas-x2d-xcd-25v-1.webp
|
||||
399
apps/web/index.html
Normal file
@@ -0,0 +1,399 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<title>Photo Gallery</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<!-- Splash Screen - 现代黑色调设计 -->
|
||||
<div
|
||||
id="splash-screen"
|
||||
style="
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #0a0a0a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
font-family:
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
'Roboto',
|
||||
sans-serif;
|
||||
overflow: hidden;
|
||||
"
|
||||
>
|
||||
<!-- 几何背景装饰 -->
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 20% 30%,
|
||||
rgba(64, 64, 64, 0.1) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 70%,
|
||||
rgba(64, 64, 64, 0.08) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 40% 80%,
|
||||
rgba(96, 96, 96, 0.05) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
"
|
||||
></div>
|
||||
|
||||
<!-- 网格背景 -->
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.02;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.1) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 30px 30px;
|
||||
animation: gridMove 20s linear infinite;
|
||||
"
|
||||
></div>
|
||||
|
||||
<!-- 动态光线效果 -->
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.01),
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.02),
|
||||
transparent
|
||||
);
|
||||
animation: rotate 30s linear infinite;
|
||||
"
|
||||
></div>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<div
|
||||
style="
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2.5rem;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<!-- Logo Container -->
|
||||
<div
|
||||
style="
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
"
|
||||
>
|
||||
<!-- Logo 背景光晕 -->
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
width: 8rem;
|
||||
height: 8rem;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(255, 255, 255, 0.1) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
border-radius: 50%;
|
||||
animation: glowPulse 3s ease-in-out infinite;
|
||||
"
|
||||
></div>
|
||||
|
||||
<!-- Logo -->
|
||||
<div
|
||||
style="
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
background: linear-gradient(135deg, #1a1a1a, #2d2d2d);
|
||||
border: 1px solid #333;
|
||||
border-radius: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
animation: logoFloat 4s ease-in-out infinite;
|
||||
backdrop-filter: blur(10px);
|
||||
"
|
||||
>
|
||||
<!-- 现代化相机图标 -->
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#e5e5e5"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M3 9a2 2 0 0 1 2-2h.93a2 2 0 0 0 1.664-.89l.812-1.22A2 2 0 0 1 10.07 4h3.86a2 2 0 0 1 1.664.89l.812 1.22A2 2 0 0 0 18.07 7H19a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9z"
|
||||
/>
|
||||
<circle cx="12" cy="13" r="3" />
|
||||
<circle cx="12" cy="13" r="1" fill="#e5e5e5" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 应用标题 -->
|
||||
<div style="display: flex; flex-direction: column; gap: 1rem">
|
||||
<h1
|
||||
style="
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
animation: titleSlide 1s ease-out;
|
||||
"
|
||||
>
|
||||
Photo Gallery
|
||||
</h1>
|
||||
<div
|
||||
style="
|
||||
width: 3rem;
|
||||
height: 2px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
#666,
|
||||
transparent
|
||||
);
|
||||
margin: 0 auto;
|
||||
animation: lineGrow 1.5s ease-out both;
|
||||
"
|
||||
></div>
|
||||
<p
|
||||
style="
|
||||
color: #a3a3a3;
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.025em;
|
||||
animation: subtitleFade 1.5s ease-out both;
|
||||
opacity: 0;
|
||||
"
|
||||
>
|
||||
Capturing moments, creating memories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 现代加载动画 -->
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
"
|
||||
>
|
||||
<!-- 进度条 -->
|
||||
<div
|
||||
style="
|
||||
width: 12rem;
|
||||
height: 2px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 1px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
width: 30%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
#666,
|
||||
#999,
|
||||
#666,
|
||||
transparent
|
||||
);
|
||||
animation: progressMove 2s ease-in-out infinite;
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 加载文字 -->
|
||||
<p
|
||||
style="
|
||||
color: #666;
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
animation: loadingText 2s ease-in-out infinite;
|
||||
"
|
||||
>
|
||||
LOADING
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSS 动画 -->
|
||||
<style>
|
||||
@keyframes gridMove {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate(30px, 30px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes logoFloat {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes titleSlide {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lineGrow {
|
||||
0% {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
width: 3rem;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes subtitleFade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progressMove {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loadingText {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 640px) {
|
||||
#splash-screen h1 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
#splash-screen p {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
#splash-screen [style*='width: 12rem'] {
|
||||
width: 10rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
#splash-screen h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
#splash-screen [style*='gap: 2.5rem'] {
|
||||
gap: 2rem !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
95
apps/web/package.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"name": "@photo-gallery/web",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"packageManager": "pnpm@10.11.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/innei/photo-gallery"
|
||||
},
|
||||
"scripts": {
|
||||
"analyze": "analyzer=1 vite build",
|
||||
"build": "tsx scripts/build.ts",
|
||||
"build:manifest": "tsx src/core/cli.ts",
|
||||
"dev": "tsx scripts/dev.ts",
|
||||
"format": "prettier --write \"src/**/*.ts\" ",
|
||||
"lint": "eslint --fix",
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.823.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.823.0",
|
||||
"@essentials/request-timeout": "1.3.0",
|
||||
"@headlessui/react": "2.2.4",
|
||||
"@photo-gallery/webgl-viewer": "workspace:*",
|
||||
"@radix-ui/react-context-menu": "2.2.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.15",
|
||||
"@radix-ui/react-popover": "1.1.14",
|
||||
"@radix-ui/react-scroll-area": "1.2.9",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-tooltip": "1.2.7",
|
||||
"@react-hook/window-size": "3.1.1",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@t3-oss/env-core": "0.13.6",
|
||||
"@tanstack/react-query": "5.80.2",
|
||||
"@use-gesture/react": "10.3.1",
|
||||
"@vercel/analytics": "1.5.0",
|
||||
"blurhash": "2.0.5",
|
||||
"clsx": "2.1.1",
|
||||
"consola": "3.4.2",
|
||||
"dotenv": "16.5.0",
|
||||
"es-toolkit": "1.38.0",
|
||||
"exif-reader": "2.0.2",
|
||||
"foxact": "0.2.45",
|
||||
"fuji-recipes": "1.0.2",
|
||||
"heic-convert": "2.1.0",
|
||||
"heic-to": "1.1.13",
|
||||
"immer": "10.1.1",
|
||||
"jotai": "2.12.5",
|
||||
"masonic": "4.1.0",
|
||||
"motion": "12.16.0",
|
||||
"ofetch": "1.4.1",
|
||||
"react": "19.1.0",
|
||||
"react-blurhash": "0.3.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-image-gallery": "1.4.0",
|
||||
"react-intersection-observer": "9.16.0",
|
||||
"react-remove-scroll": "2.7.1",
|
||||
"react-router": "7.6.2",
|
||||
"react-scan": "0.3.4",
|
||||
"react-zoom-pan-pinch": "3.7.0",
|
||||
"sharp": "0.34.2",
|
||||
"sonner": "2.0.5",
|
||||
"swiper": "11.2.8",
|
||||
"tailwind-merge": "3.3.0",
|
||||
"usehooks-ts": "3.1.1",
|
||||
"zod": "3.25.51",
|
||||
"zustand": "5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@egoist/tailwindcss-icons": "1.9.0",
|
||||
"@iconify-json/mingcute": "1.2.3",
|
||||
"@tailwindcss/container-queries": "0.1.1",
|
||||
"@tailwindcss/postcss": "4.1.8",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@tailwindcss/vite": "4.1.8",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react-dom": "19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"code-inspector-plugin": "0.20.12",
|
||||
"daisyui": "5.0.43",
|
||||
"execa": "9.6.0",
|
||||
"postcss": "8.5.4",
|
||||
"postcss-import": "16.1.0",
|
||||
"postcss-js": "4.0.1",
|
||||
"react-compiler-runtime": "19.1.0-rc.2",
|
||||
"simple-git-hooks": "2.13.0",
|
||||
"tailwind-scrollbar": "4.0.2",
|
||||
"tailwind-variants": "1.0.0",
|
||||
"tailwindcss": "4.1.8",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"tailwindcss-safe-area": "0.6.0",
|
||||
"tailwindcss-uikit-colors": "1.0.0-alpha.0"
|
||||
}
|
||||
}
|
||||
BIN
apps/web/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
apps/web/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
apps/web/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
apps/web/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 547 B |
BIN
apps/web/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
apps/web/public/favicon-48x48.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
apps/web/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
19
apps/web/public/site.webmanifest
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Photo Gallery",
|
||||
"short_name": "Gallery",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#0a0a0a",
|
||||
"background_color": "#0a0a0a",
|
||||
"display": "standalone"
|
||||
}
|
||||
16
apps/web/scripts/build.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { $ } from 'execa'
|
||||
|
||||
import { precheck } from './precheck'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workdir = path.resolve(__dirname, '..')
|
||||
|
||||
async function main() {
|
||||
await precheck()
|
||||
await $({ cwd: workdir, stdio: 'inherit' })`vite build`
|
||||
}
|
||||
|
||||
main()
|
||||
18
apps/web/scripts/dev.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { $ } from 'execa'
|
||||
|
||||
import { precheck } from './precheck'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workdir = path.resolve(__dirname, '..')
|
||||
|
||||
async function main() {
|
||||
await precheck()
|
||||
// Get command line arguments excluding node and script name
|
||||
const args = process.argv.slice(2)
|
||||
await $({ cwd: workdir, stdio: 'inherit' })`vite ${args}`
|
||||
}
|
||||
|
||||
main()
|
||||
35
apps/web/scripts/precheck.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { builderConfig } from '@builder'
|
||||
import { $ } from 'execa'
|
||||
|
||||
import { pullAndLinkRemoteRepo } from './pull-remote'
|
||||
|
||||
export const precheck = async () => {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workdir = path.resolve(__dirname, '..')
|
||||
|
||||
// 检查是否存在 public/thumbnails 和 src/data/photos-manifest.json
|
||||
const thumbnailsDir = path.resolve(workdir, 'public', 'thumbnails')
|
||||
const photosManifestPath = path.resolve(
|
||||
workdir,
|
||||
'src',
|
||||
'data',
|
||||
'photos-manifest.json',
|
||||
)
|
||||
const isExistThumbnails = existsSync(thumbnailsDir)
|
||||
const isExistPhotosManifest = existsSync(photosManifestPath)
|
||||
|
||||
const shouldDoBuildOrClone = !isExistThumbnails || !isExistPhotosManifest
|
||||
|
||||
// 检查 builder 配置
|
||||
if (shouldDoBuildOrClone) {
|
||||
if (builderConfig.repo.enable) {
|
||||
await pullAndLinkRemoteRepo()
|
||||
} else {
|
||||
await $({ cwd: workdir, stdio: 'inherit' })`pnpm build:manifest`
|
||||
}
|
||||
}
|
||||
}
|
||||
49
apps/web/scripts/pull-remote.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { builderConfig } from '@builder'
|
||||
import { $ } from 'execa'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workdir = path.resolve(__dirname, '..')
|
||||
|
||||
export const pullAndLinkRemoteRepo = async () => {
|
||||
const hasExist = existsSync(path.resolve(workdir, 'assets-git'))
|
||||
if (!hasExist) {
|
||||
await $({
|
||||
cwd: workdir,
|
||||
stdio: 'inherit',
|
||||
})`git clone ${builderConfig.repo.url} assets-git`
|
||||
} else {
|
||||
await $({
|
||||
cwd: path.resolve(workdir, 'assets-git'),
|
||||
stdio: 'inherit',
|
||||
})`git pull --rebase`
|
||||
}
|
||||
|
||||
// 删除 public/thumbnails 目录,并建立软连接到 assets-git/thumbnails
|
||||
const thumbnailsDir = path.resolve(workdir, 'public', 'thumbnails')
|
||||
if (existsSync(thumbnailsDir)) {
|
||||
await $({ cwd: workdir, stdio: 'inherit' })`rm -rf ${thumbnailsDir}`
|
||||
}
|
||||
await $({
|
||||
cwd: workdir,
|
||||
stdio: 'inherit',
|
||||
})`ln -s ${path.resolve(workdir, 'assets-git', 'thumbnails')} ${thumbnailsDir}`
|
||||
// 删除src/data/photos-manifest.json,并建立软连接到 assets-git/photos-manifest.json
|
||||
const photosManifestPath = path.resolve(
|
||||
workdir,
|
||||
'src',
|
||||
'data',
|
||||
'photos-manifest.json',
|
||||
)
|
||||
if (existsSync(photosManifestPath)) {
|
||||
await $({ cwd: workdir, stdio: 'inherit' })`rm -rf ${photosManifestPath}`
|
||||
}
|
||||
await $({ cwd: workdir, stdio: 'inherit' })`ln -s ${path.resolve(
|
||||
workdir,
|
||||
'assets-git',
|
||||
'photos-manifest.json',
|
||||
)} ${photosManifestPath}`
|
||||
}
|
||||
18
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { inject } from '@vercel/analytics'
|
||||
import { Outlet } from 'react-router'
|
||||
|
||||
import { RootProviders } from './providers/root-providers'
|
||||
|
||||
inject()
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<RootProviders>
|
||||
<div className="h-svh">
|
||||
<Outlet />
|
||||
</div>
|
||||
</RootProviders>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
BIN
apps/web/src/assets/fonts/GeistVF.woff2
Normal file
9
apps/web/src/atoms/app.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
export type GallerySortBy = 'date'
|
||||
export type GallerySortOrder = 'asc' | 'desc'
|
||||
export const gallerySettingAtom = atom({
|
||||
sortBy: 'date' as GallerySortBy,
|
||||
sortOrder: 'desc' as GallerySortOrder,
|
||||
selectedTags: [] as string[],
|
||||
})
|
||||
195
apps/web/src/atoms/context-menu.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { atom } from 'jotai'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { createAtomHooks } from '~/lib/jotai'
|
||||
|
||||
// Atom
|
||||
|
||||
type ContextMenuState =
|
||||
| { open: false }
|
||||
| {
|
||||
open: true
|
||||
position: { x: number; y: number }
|
||||
menuItems: FollowMenuItem[]
|
||||
// Just for abort callback
|
||||
// Also can be optimized by using the `atomWithListeners`
|
||||
abortController: AbortController
|
||||
}
|
||||
|
||||
export const [
|
||||
contextMenuAtom,
|
||||
useContextMenuState,
|
||||
useContextMenuValue,
|
||||
useSetContextMenu,
|
||||
] = createAtomHooks(atom<ContextMenuState>({ open: false }))
|
||||
|
||||
const useShowWebContextMenu = () => {
|
||||
const setContextMenu = useSetContextMenu()
|
||||
|
||||
const showWebContextMenu = useCallback(
|
||||
async (
|
||||
menuItems: Array<FollowMenuItem>,
|
||||
e: MouseEvent | React.MouseEvent,
|
||||
) => {
|
||||
const abortController = new AbortController()
|
||||
const resolvers = Promise.withResolvers<void>()
|
||||
setContextMenu({
|
||||
open: true,
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
menuItems,
|
||||
abortController,
|
||||
})
|
||||
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
resolvers.resolve()
|
||||
})
|
||||
return resolvers.promise
|
||||
},
|
||||
[setContextMenu],
|
||||
)
|
||||
|
||||
return showWebContextMenu
|
||||
}
|
||||
|
||||
// Menu
|
||||
|
||||
export type FollowMenuItem = MenuItemText | MenuItemSeparator
|
||||
|
||||
export type MenuItemInput = MenuItemText | MenuItemSeparator | NilValue
|
||||
|
||||
function filterNullableMenuItems(items: MenuItemInput[]): FollowMenuItem[] {
|
||||
return items
|
||||
.filter(
|
||||
(item) =>
|
||||
item !== null && item !== undefined && item !== false && item !== '',
|
||||
)
|
||||
.filter((item) => !item.hide)
|
||||
.map((item) => {
|
||||
if (item instanceof MenuItemSeparator) {
|
||||
return MENU_ITEM_SEPARATOR
|
||||
}
|
||||
|
||||
if (item.submenu && item.submenu.length > 0) {
|
||||
return item.extend({
|
||||
submenu: filterNullableMenuItems(item.submenu),
|
||||
})
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
export enum MenuItemType {
|
||||
Separator,
|
||||
Action,
|
||||
}
|
||||
|
||||
export const useShowContextMenu = () => {
|
||||
const showWebContextMenu = useShowWebContextMenu()
|
||||
|
||||
const showContextMenu = useCallback(
|
||||
async (
|
||||
inputMenu: Array<MenuItemInput>,
|
||||
e: MouseEvent | React.MouseEvent,
|
||||
) => {
|
||||
const menuItems = filterNullableMenuItems(inputMenu)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
await showWebContextMenu(menuItems, e)
|
||||
},
|
||||
[showWebContextMenu],
|
||||
)
|
||||
|
||||
return showContextMenu
|
||||
}
|
||||
|
||||
export class MenuItemSeparator {
|
||||
readonly type = MenuItemType.Separator
|
||||
constructor(public hide = false) {}
|
||||
static default = new MenuItemSeparator()
|
||||
}
|
||||
|
||||
const noop = () => void 0
|
||||
export type BaseMenuItemTextConfig = {
|
||||
label: string
|
||||
click?: () => void
|
||||
/** only work in web app */
|
||||
icon?: React.ReactNode
|
||||
shortcut?: string
|
||||
disabled?: boolean
|
||||
checked?: boolean
|
||||
supportMultipleSelection?: boolean
|
||||
}
|
||||
|
||||
export class BaseMenuItemText {
|
||||
readonly type = MenuItemType.Action
|
||||
|
||||
private __sortedShortcut: string | null = null
|
||||
|
||||
constructor(private configs: BaseMenuItemTextConfig) {
|
||||
this.__sortedShortcut = this.configs.shortcut || null
|
||||
}
|
||||
|
||||
public get label() {
|
||||
return this.configs.label
|
||||
}
|
||||
|
||||
public get click() {
|
||||
return this.configs.click?.bind(this.configs) || noop
|
||||
}
|
||||
|
||||
public get onClick() {
|
||||
return this.click
|
||||
}
|
||||
public get icon() {
|
||||
return this.configs.icon
|
||||
}
|
||||
|
||||
public get shortcut() {
|
||||
return this.__sortedShortcut
|
||||
}
|
||||
|
||||
public get disabled() {
|
||||
return this.configs.disabled || false
|
||||
}
|
||||
|
||||
public get checked() {
|
||||
return this.configs.checked
|
||||
}
|
||||
|
||||
public get supportMultipleSelection() {
|
||||
return this.configs.supportMultipleSelection
|
||||
}
|
||||
}
|
||||
|
||||
export type MenuItemTextConfig = BaseMenuItemTextConfig & {
|
||||
hide?: boolean
|
||||
submenu?: MenuItemInput[]
|
||||
}
|
||||
|
||||
export class MenuItemText extends BaseMenuItemText {
|
||||
protected __submenu: FollowMenuItem[]
|
||||
constructor(protected config: MenuItemTextConfig) {
|
||||
super(config)
|
||||
|
||||
this.__submenu = this.config.submenu
|
||||
? filterNullableMenuItems(this.config.submenu)
|
||||
: []
|
||||
}
|
||||
|
||||
public get submenu() {
|
||||
return this.__submenu
|
||||
}
|
||||
|
||||
public get hide() {
|
||||
return this.config.hide || false
|
||||
}
|
||||
|
||||
extend(config: Partial<MenuItemTextConfig>) {
|
||||
return new MenuItemText({
|
||||
...this.config,
|
||||
...config,
|
||||
})
|
||||
}
|
||||
}
|
||||
export const MENU_ITEM_SEPARATOR = MenuItemSeparator.default
|
||||
43
apps/web/src/atoms/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { atom, useAtomValue } from 'jotai'
|
||||
import { selectAtom } from 'jotai/utils'
|
||||
import { useMemo } from 'react'
|
||||
import type { Location, NavigateFunction, Params } from 'react-router'
|
||||
|
||||
import { createAtomHooks } from '~/lib/jotai'
|
||||
|
||||
interface RouteAtom {
|
||||
params: Readonly<Params<string>>
|
||||
searchParams: URLSearchParams
|
||||
location: Location<any>
|
||||
}
|
||||
|
||||
export const [routeAtom, , , , getReadonlyRoute, setRoute] = createAtomHooks(
|
||||
atom<RouteAtom>({
|
||||
params: {},
|
||||
searchParams: new URLSearchParams(),
|
||||
location: {
|
||||
pathname: '',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: '',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const noop = []
|
||||
export const useReadonlyRouteSelector = <T>(
|
||||
selector: (route: RouteAtom) => T,
|
||||
deps: any[] = noop,
|
||||
): T =>
|
||||
useAtomValue(
|
||||
useMemo(() => selectAtom(routeAtom, (route) => selector(route)), deps),
|
||||
)
|
||||
|
||||
// Vite HMR will create new router instance, but RouterProvider always stable
|
||||
|
||||
const [, , , , navigate, setNavigate] = createAtomHooks(
|
||||
atom<{ fn: NavigateFunction | null }>({ fn() {} }),
|
||||
)
|
||||
const getStableRouterNavigate = () => navigate().fn
|
||||
export { getStableRouterNavigate, setNavigate }
|
||||
38
apps/web/src/atoms/viewport.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
const { innerWidth: w, innerHeight: h } = window
|
||||
const sm = w >= 640
|
||||
const md = w >= 768
|
||||
const lg = w >= 1024
|
||||
const xl = w >= 1280
|
||||
const _2xl = w >= 1536
|
||||
|
||||
export const viewportAtom = atom({
|
||||
/**
|
||||
* 640px
|
||||
*/
|
||||
sm,
|
||||
|
||||
/**
|
||||
* 768px
|
||||
*/
|
||||
md,
|
||||
|
||||
/**
|
||||
* 1024px
|
||||
*/
|
||||
lg,
|
||||
|
||||
/**
|
||||
* 1280px
|
||||
*/
|
||||
xl,
|
||||
|
||||
/**
|
||||
* 1536px
|
||||
*/
|
||||
'2xl': _2xl,
|
||||
|
||||
h,
|
||||
w,
|
||||
})
|
||||
77
apps/web/src/components/common/ErrorElement.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { repository } from '@pkg'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { isRouteErrorResponse, useRouteError } from 'react-router'
|
||||
|
||||
import { attachOpenInEditor } from '~/lib/dev'
|
||||
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
export function ErrorElement() {
|
||||
const error = useRouteError()
|
||||
const message = isRouteErrorResponse(error)
|
||||
? `${error.status} ${error.statusText}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: JSON.stringify(error)
|
||||
const stack = error instanceof Error ? error.stack : null
|
||||
|
||||
useEffect(() => {
|
||||
console.error('Error handled by React Router default ErrorBoundary:', error)
|
||||
}, [error])
|
||||
|
||||
const reloadRef = useRef(false)
|
||||
if (
|
||||
message.startsWith('Failed to fetch dynamically imported module') &&
|
||||
window.sessionStorage.getItem('reload') !== '1'
|
||||
) {
|
||||
if (reloadRef.current) return null
|
||||
window.sessionStorage.setItem('reload', '1')
|
||||
window.location.reload()
|
||||
reloadRef.current = true
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="m-auto flex min-h-full max-w-prose flex-col p-8 pt-12 select-text">
|
||||
<div className="fixed inset-x-0 top-0 h-12" />
|
||||
<div className="center flex flex-col">
|
||||
<i className="i-mingcute-bug-fill size-12 text-red-400" />
|
||||
<h2 className="mt-12 text-2xl">
|
||||
Sorry, the app has encountered an error
|
||||
</h2>
|
||||
</div>
|
||||
<h3 className="text-xl">{message}</h3>
|
||||
{import.meta.env.DEV && stack ? (
|
||||
<div className="mt-4 cursor-text overflow-auto rounded-md bg-red-50 p-4 text-left font-mono text-sm whitespace-pre text-red-600">
|
||||
{attachOpenInEditor(stack)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="my-8">
|
||||
The App has a temporary problem, click the button below to try reloading
|
||||
the app or another solution?
|
||||
</p>
|
||||
|
||||
<div className="center gap-4">
|
||||
<Button onClick={() => (window.location.href = '/')}>Reload</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-8">
|
||||
Still having this issue? Please give feedback in Github, thanks!
|
||||
<a
|
||||
className="text-accent ml-2 cursor-pointer duration-200"
|
||||
href={`${repository.url}/issues/new?title=${encodeURIComponent(
|
||||
`Error: ${message}`,
|
||||
)}&body=${encodeURIComponent(
|
||||
`### Error\n\n${message}\n\n### Stack\n\n\`\`\`\n${stack}\n\`\`\``,
|
||||
)}&label=bug`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Submit Issue
|
||||
</a>
|
||||
</p>
|
||||
<div className="grow" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
apps/web/src/components/common/LoadRemixAsyncComponent.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { createElement, useEffect, useState } from 'react'
|
||||
|
||||
import { LoadingCircle } from '../ui/loading'
|
||||
|
||||
export const LoadRemixAsyncComponent: FC<{
|
||||
loader: () => Promise<any>
|
||||
Header: FC<{ loader: () => any; [key: string]: any }>
|
||||
}> = ({ loader, Header }) => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const [Component, setComponent] = useState<{ c: () => ReactNode }>({
|
||||
c: () => null,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let isUnmounted = false
|
||||
setLoading(true)
|
||||
loader()
|
||||
.then((module) => {
|
||||
if (!module.Component) {
|
||||
return
|
||||
}
|
||||
if (isUnmounted) return
|
||||
|
||||
const { loader } = module
|
||||
setComponent({
|
||||
c: () => (
|
||||
<>
|
||||
<Header loader={loader} />
|
||||
<module.Component />
|
||||
</>
|
||||
),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
isUnmounted = true
|
||||
}
|
||||
}, [Header, loader])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="center absolute inset-0 h-full">
|
||||
<LoadingCircle size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return createElement(Component.c)
|
||||
}
|
||||
25
apps/web/src/components/common/NotFound.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
export const NotFound = () => {
|
||||
const location = useLocation()
|
||||
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className="prose center dark:prose-invert m-auto size-full flex-col">
|
||||
<main className="flex grow flex-col items-center justify-center">
|
||||
<p className="font-semibold">
|
||||
You have come to a desert of knowledge where there is nothing.
|
||||
</p>
|
||||
<p>
|
||||
Current path: <code>{location.pathname}</code>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
apps/web/src/components/common/PassiveFragmenet.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
export const PassiveFragment: FC<PropsWithChildren> = ({
|
||||
children,
|
||||
...rest
|
||||
}) => <Fragment {...rest}>{children}</Fragment>
|
||||
10
apps/web/src/components/common/ProviderComposer.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { JSX } from 'react'
|
||||
import { cloneElement } from 'react'
|
||||
|
||||
export const ProviderComposer: Component<{
|
||||
contexts: JSX.Element[]
|
||||
}> = ({ contexts, children }) =>
|
||||
contexts.reduceRight(
|
||||
(kids: any, parent: any) => cloneElement(parent, { children: kids }),
|
||||
children,
|
||||
)
|
||||
135
apps/web/src/components/ui/button/Button.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
// Tremor Button [v0.2.0]
|
||||
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { m } from 'motion/react'
|
||||
import * as React from 'react'
|
||||
import type { VariantProps } from 'tailwind-variants'
|
||||
import { tv } from 'tailwind-variants'
|
||||
|
||||
import { cx, focusRing } from '~/lib/cn'
|
||||
|
||||
const buttonVariants = tv({
|
||||
base: [
|
||||
'relative inline-flex items-center justify-center whitespace-nowrap rounded-md text-center font-medium transition-all duration-100 ease-in-out',
|
||||
'disabled:pointer-events-none',
|
||||
focusRing,
|
||||
],
|
||||
variants: {
|
||||
variant: {
|
||||
primary: [
|
||||
'border-transparent',
|
||||
'text-white dark:text-white',
|
||||
'bg-accent dark:bg-accent',
|
||||
'hover:bg-accent/90 dark:hover:bg-accent/90',
|
||||
'disabled:bg-accent/50 disabled:text-white/70',
|
||||
'disabled:dark:bg-accent/30 disabled:dark:text-white/50',
|
||||
],
|
||||
secondary: [
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'text-gray-700 dark:text-gray-200',
|
||||
'bg-gray-50 dark:bg-gray-800',
|
||||
'hover:bg-gray-100 dark:hover:bg-gray-750',
|
||||
'disabled:bg-gray-50 disabled:text-gray-400',
|
||||
'disabled:dark:bg-gray-800 disabled:dark:text-gray-500',
|
||||
],
|
||||
light: [
|
||||
'shadow-none',
|
||||
'border-transparent',
|
||||
'text-gray-900 dark:text-gray-50',
|
||||
'bg-gray-200 dark:bg-gray-900',
|
||||
'hover:bg-gray-300/70 dark:hover:bg-gray-800/80',
|
||||
'disabled:bg-gray-100 disabled:text-gray-400',
|
||||
'disabled:dark:bg-gray-800 disabled:dark:text-gray-600',
|
||||
],
|
||||
ghost: [
|
||||
'shadow-none',
|
||||
'border-transparent',
|
||||
'text-gray-900 dark:text-gray-50',
|
||||
'bg-transparent dark:hover:bg-fill-tertiary',
|
||||
'disabled:text-gray-400',
|
||||
'disabled:dark:text-gray-600',
|
||||
],
|
||||
destructive: [
|
||||
'text-white',
|
||||
'border-transparent',
|
||||
'bg-red-600 dark:bg-red-700',
|
||||
'hover:bg-red-700 dark:hover:bg-red-600',
|
||||
'disabled:bg-red-300 disabled:text-white',
|
||||
'disabled:dark:bg-red-950 disabled:dark:text-red-400',
|
||||
],
|
||||
},
|
||||
size: {
|
||||
xs: 'h-6 px-2 text-xs',
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
md: 'h-10 px-4 text-sm',
|
||||
lg: 'h-11 px-8 text-base',
|
||||
xl: 'h-12 px-8 text-base',
|
||||
},
|
||||
flat: {
|
||||
true: 'shadow-none',
|
||||
false: 'shadow-sm',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'sm',
|
||||
flat: false,
|
||||
},
|
||||
})
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ComponentPropsWithoutRef<'button'>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
isLoading?: boolean
|
||||
loadingText?: string
|
||||
}
|
||||
|
||||
const Button = ({
|
||||
ref: forwardedRef,
|
||||
asChild,
|
||||
isLoading = false,
|
||||
loadingText,
|
||||
className,
|
||||
disabled,
|
||||
variant,
|
||||
size,
|
||||
flat,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps & {
|
||||
ref?: React.RefObject<HTMLButtonElement>
|
||||
}) => {
|
||||
const Component = asChild ? Slot : m.button
|
||||
return (
|
||||
// @ts-expect-error
|
||||
<Component
|
||||
ref={forwardedRef}
|
||||
className={cx(buttonVariants({ variant, size, flat }), className)}
|
||||
disabled={disabled || isLoading}
|
||||
data-tremor-id="tremor-raw"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="pointer-events-none inline-flex items-center justify-center gap-1.5">
|
||||
<i
|
||||
className={cx(
|
||||
'shrink-0 animate-spin i-mingcute-loading-3-line',
|
||||
size === 'xs' || size === 'sm' ? 'size-3' : 'size-4',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{loadingText ?? 'Loading'}</span>
|
||||
<span className="inline-block">{loadingText ?? children}</span>
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, type ButtonProps, buttonVariants }
|
||||
25
apps/web/src/components/ui/button/MotionButton.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { HTMLMotionProps } from 'motion/react'
|
||||
import { m } from 'motion/react'
|
||||
|
||||
export const MotionButtonBase = ({
|
||||
ref,
|
||||
children,
|
||||
...rest
|
||||
}: HTMLMotionProps<'button'> & {
|
||||
ref?: React.RefObject<HTMLButtonElement>
|
||||
}) => {
|
||||
return (
|
||||
<m.button
|
||||
initial={true}
|
||||
whileFocus={{ scale: 1.02 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
{...rest}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</m.button>
|
||||
)
|
||||
}
|
||||
|
||||
MotionButtonBase.displayName = 'MotionButtonBase'
|
||||
2
apps/web/src/components/ui/button/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './Button'
|
||||
export * from './MotionButton'
|
||||
51
apps/web/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { clsxm } from '~/lib/cn'
|
||||
|
||||
export interface CheckboxProps {
|
||||
checked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const Checkbox = ({
|
||||
ref,
|
||||
checked = false,
|
||||
onCheckedChange,
|
||||
disabled = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CheckboxProps & { ref?: React.RefObject<HTMLButtonElement | null> }) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
className={clsxm(
|
||||
'inline-flex items-center gap-2 text-sm cursor-pointer disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={clsxm(
|
||||
'flex h-4 w-4 items-center justify-center rounded border transition-colors',
|
||||
checked
|
||||
? 'bg-accent border-accent text-white'
|
||||
: 'border-border bg-background hover:border-accent/50',
|
||||
)}
|
||||
>
|
||||
{checked && <i className="i-mingcute-check-line size-3" />}
|
||||
</div>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
Checkbox.displayName = 'Checkbox'
|
||||
202
apps/web/src/components/ui/context-menu/context-menu.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
|
||||
import * as React from 'react'
|
||||
|
||||
import { clsxm } from '~/lib/cn'
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||
const RootPortal = ContextMenuPrimitive.Portal
|
||||
|
||||
const ContextMenuSubTrigger = ({
|
||||
ref,
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
} & {
|
||||
ref?: React.Ref<React.ElementRef<
|
||||
typeof ContextMenuPrimitive.SubTrigger
|
||||
> | null>
|
||||
}) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={clsxm(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex select-none items-center rounded-[5px] px-2.5 py-1.5 outline-none',
|
||||
inset && 'pl-8',
|
||||
'flex items-center justify-center gap-2',
|
||||
className,
|
||||
props.disabled && 'cursor-not-allowed opacity-30',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<i className="i-mingcute-right-line -mr-1 ml-auto size-3.5" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const ContextMenuSubContent = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> & {
|
||||
ref?: React.Ref<React.ElementRef<
|
||||
typeof ContextMenuPrimitive.SubContent
|
||||
> | null>
|
||||
}) => (
|
||||
<RootPortal>
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={clsxm(
|
||||
'bg-material-medium backdrop-blur-[70px] text-text text-body',
|
||||
'min-w-32 overflow-hidden',
|
||||
'rounded-[6px] border p-1',
|
||||
'shadow-context-menu',
|
||||
'z-[10061]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</RootPortal>
|
||||
)
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||
|
||||
const ContextMenuContent = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {
|
||||
ref?: React.Ref<React.ElementRef<typeof ContextMenuPrimitive.Content> | null>
|
||||
}) => (
|
||||
<RootPortal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={clsxm(
|
||||
'bg-material-medium backdrop-blur-[70px] text-text shadow-context-menu z-[10060] min-w-32 overflow-hidden rounded-[6px] border border-border p-1',
|
||||
'motion-scale-in-75 motion-duration-150 text-body lg:animate-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</RootPortal>
|
||||
)
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||
|
||||
const ContextMenuItem = ({
|
||||
ref,
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
} & {
|
||||
ref?: React.Ref<React.ElementRef<typeof ContextMenuPrimitive.Item> | null>
|
||||
}) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={clsxm(
|
||||
'cursor-menu focus:bg-accent text-sm focus:text-accent-foreground relative flex select-none items-center rounded-[5px] px-2.5 py-1 outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'data-[highlighted]:bg-theme-selection-hover focus-within:outline-transparent',
|
||||
'h-[28px]',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||
|
||||
const ContextMenuCheckboxItem = ({
|
||||
ref,
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
|
||||
ref?: React.Ref<React.ElementRef<
|
||||
typeof ContextMenuPrimitive.CheckboxItem
|
||||
> | null>
|
||||
}) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={clsxm(
|
||||
'cursor-checkbox focus:bg-accent text-sm focus:text-accent-foreground relative flex select-none items-center rounded-[5px] px-8 py-1.5 outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'focus-within:outline-transparent',
|
||||
'h-[28px]',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator asChild>
|
||||
<i className="i-mgc-check-filled size-3" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const ContextMenuLabel = ({
|
||||
ref,
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
} & {
|
||||
ref?: React.Ref<React.ElementRef<typeof ContextMenuPrimitive.Label> | null>
|
||||
}) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={clsxm(
|
||||
'text-text px-2 py-1.5 font-semibold',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||
|
||||
const ContextMenuSeparator = ({
|
||||
ref,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> & {
|
||||
ref?: React.Ref<React.ElementRef<
|
||||
typeof ContextMenuPrimitive.Separator
|
||||
> | null>
|
||||
}) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
className="mx-2 my-1 h-px backdrop-blur-[70px]"
|
||||
asChild
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border mr-2 h-px" />
|
||||
</ContextMenuPrimitive.Separator>
|
||||
)
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
RootPortal as ContextMenuPortal,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
}
|
||||
1
apps/web/src/components/ui/context-menu/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './context-menu'
|
||||
205
apps/web/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import * as React from 'react'
|
||||
|
||||
import { clsxm } from '~/lib/cn'
|
||||
|
||||
const DropdownMenu: typeof DropdownMenuPrimitive.Root = (props) => {
|
||||
return <DropdownMenuPrimitive.Root {...props} />
|
||||
}
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = ({
|
||||
ref,
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
} & {
|
||||
ref?: React.Ref<React.ElementRef<
|
||||
typeof DropdownMenuPrimitive.SubTrigger
|
||||
> | null>
|
||||
}) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={clsxm(
|
||||
'cursor-menu focus:bg-theme-selection-active focus:text-theme-selection-foreground data-[state=open]:bg-theme-selection-active data-[state=open]:text-theme-selection-foreground flex select-none items-center rounded-[5px] px-2.5 py-1.5 outline-none',
|
||||
inset && 'pl-8',
|
||||
'center gap-2',
|
||||
className,
|
||||
props.disabled && 'cursor-not-allowed opacity-30',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<i className="i-mingcute-right-line -mr-1 ml-auto size-3.5" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuContent = ({
|
||||
ref,
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||
ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Content> | null>
|
||||
}) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={clsxm(
|
||||
'bg-material-medium backdrop-blur-[70px] text-text shadow z-[60] min-w-32 overflow-hidden rounded-[6px] border border-border p-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = ({
|
||||
ref,
|
||||
className,
|
||||
inset,
|
||||
icon,
|
||||
active,
|
||||
highlightColor = 'accent',
|
||||
shortcut,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
icon?: React.ReactNode | ((props?: { isActive?: boolean }) => React.ReactNode)
|
||||
active?: boolean
|
||||
highlightColor?: 'accent' | 'gray'
|
||||
shortcut?: string
|
||||
} & {
|
||||
ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Item> | null>
|
||||
}) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={clsxm(
|
||||
'cursor-menu relative flex select-none items-center rounded-[5px] px-2.5 py-1 outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'focus-within:outline-transparent text-sm my-0.5',
|
||||
highlightColor === 'accent'
|
||||
? 'data-[highlighted]:bg-accent/10 focus:bg-accent/10 focus:text-accent data-[highlighted]:text-accent'
|
||||
: 'data-[highlighted]:bg-accent/10 focus:bg-accent/10 focus:text-accent',
|
||||
|
||||
'h-[28px]',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!!icon && (
|
||||
<span className="mr-1.5 inline-flex size-4 items-center justify-center">
|
||||
{typeof icon === 'function' ? icon({ isActive: active }) : icon}
|
||||
</span>
|
||||
)}
|
||||
{props.children}
|
||||
|
||||
{/* Justify Fill */}
|
||||
{!!icon && <span className="ml-1.5 size-4" />}
|
||||
</DropdownMenuPrimitive.Item>
|
||||
)
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = ({
|
||||
ref,
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
icon,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
icon?: React.ReactNode
|
||||
ref?: React.Ref<React.ElementRef<
|
||||
typeof DropdownMenuPrimitive.CheckboxItem
|
||||
> | null>
|
||||
}) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={clsxm(
|
||||
'cursor-menu relative flex select-none items-center rounded-[5px] py-1.5 pl-2 pr-2 text-sm outline-none transition-colors',
|
||||
'focus:bg-accent/10 focus:text-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
{!!icon && (
|
||||
<span className="mr-1.5 inline-flex size-4 items-center justify-center">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
<span className="ml-auto flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator className="ml-1 flex items-center justify-center">
|
||||
<i className="i-mingcute-check-line size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuLabel = ({
|
||||
ref,
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
} & {
|
||||
ref?: React.Ref<React.ElementRef<typeof DropdownMenuPrimitive.Label> | null>
|
||||
}) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={clsxm(
|
||||
'text-text px-2 py-1 text-sm font-semibold',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = ({
|
||||
ref,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
|
||||
ref?: React.Ref<React.ElementRef<
|
||||
typeof DropdownMenuPrimitive.Separator
|
||||
> | null>
|
||||
}) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
className="bg-border mx-2 my-1 h-px px-2"
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
}
|
||||
19
apps/web/src/components/ui/loading.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { clsxm } from '~/lib/cn'
|
||||
|
||||
interface LoadingCircleProps {
|
||||
size: 'small' | 'medium' | 'large'
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
small: 'text-md',
|
||||
medium: 'text-xl',
|
||||
large: 'text-3xl',
|
||||
}
|
||||
export const LoadingCircle: Component<LoadingCircleProps> = ({
|
||||
className,
|
||||
size,
|
||||
}) => (
|
||||
<div className={clsxm(sizeMap[size], className)}>
|
||||
<i className="i-mingcute-loading-3-line animate-spin" />
|
||||
</div>
|
||||
)
|
||||
909
apps/web/src/components/ui/photo-viewer/ExifPanel.tsx
Normal file
@@ -0,0 +1,909 @@
|
||||
import './PhotoViewer.css'
|
||||
|
||||
import type { Exif } from 'exif-reader'
|
||||
import { m } from 'motion/react'
|
||||
import type { FC } from 'react'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea'
|
||||
import { useMobile } from '~/hooks/useMobile'
|
||||
import {
|
||||
CarbonIsoOutline,
|
||||
MaterialSymbolsExposure,
|
||||
MaterialSymbolsShutterSpeed,
|
||||
StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens,
|
||||
TablerAperture,
|
||||
} from '~/icons'
|
||||
import { getImageFormat } from '~/lib/image-utils'
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
import { MotionButtonBase } from '../button'
|
||||
import { EllipsisHorizontalTextWithTooltip } from '../typography'
|
||||
|
||||
export const ExifPanel: FC<{
|
||||
currentPhoto: PhotoManifest
|
||||
exifData: Exif | null
|
||||
|
||||
onClose?: () => void
|
||||
}> = ({ currentPhoto, exifData, onClose }) => {
|
||||
const isMobile = useMobile()
|
||||
const formattedExifData = formatExifData(exifData)
|
||||
|
||||
// 使用通用的图片格式提取函数
|
||||
const imageFormat = getImageFormat(
|
||||
currentPhoto.originalUrl || currentPhoto.s3Key || '',
|
||||
)
|
||||
|
||||
return (
|
||||
<m.div
|
||||
className={`${
|
||||
isMobile
|
||||
? 'exif-panel-mobile fixed right-0 bottom-0 left-0 max-h-[60vh] w-full rounded-t-2xl'
|
||||
: 'w-80 shrink-0'
|
||||
} bg-material-medium z-10 flex flex-col text-white backdrop-blur-3xl`}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
...(isMobile ? { y: 100 } : { x: 100 }),
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
...(isMobile ? { y: 0 } : { x: 0 }),
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
...(isMobile ? { y: 100 } : { x: 100 }),
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="mb-4 flex shrink-0 items-center justify-between p-4 pb-0">
|
||||
<h3 className={`${isMobile ? 'text-base' : 'text-lg'} font-semibold`}>
|
||||
图片信息
|
||||
</h3>
|
||||
{isMobile && onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-6 items-center justify-center rounded-full text-white/70 duration-200 hover:bg-white/10 hover:text-white"
|
||||
onClick={onClose}
|
||||
>
|
||||
<i className="i-mingcute-close-line text-sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea
|
||||
rootClassName="flex-1 min-h-0 overflow-auto lg:overflow-hidden"
|
||||
viewportClassName="px-4 pb-4"
|
||||
>
|
||||
<div className={`space-y-${isMobile ? '3' : '4'}`}>
|
||||
{/* 基本信息和标签 - 合并到一个 section */}
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-white/80">基本信息</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
<Row label="文件名" value={currentPhoto.title} ellipsis />
|
||||
<Row label="格式" value={imageFormat} />
|
||||
<Row
|
||||
label="尺寸"
|
||||
value={`${currentPhoto.width} × ${currentPhoto.height}`}
|
||||
/>
|
||||
<Row
|
||||
label="文件大小"
|
||||
value={`${(currentPhoto.size / 1024 / 1024).toFixed(1)}MB`}
|
||||
/>
|
||||
{formattedExifData?.megaPixels && (
|
||||
<Row
|
||||
label="像素"
|
||||
value={`${Math.floor(
|
||||
Number.parseFloat(formattedExifData.megaPixels),
|
||||
)} MP`}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData?.colorSpace && (
|
||||
<Row label="色彩空间" value={formattedExifData.colorSpace} />
|
||||
)}
|
||||
|
||||
{formattedExifData?.dateTime && (
|
||||
<Row label="拍摄时间" value={formattedExifData.dateTime} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标签信息 - 移到基本信息 section 内 */}
|
||||
{currentPhoto.tags && currentPhoto.tags.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 text-sm text-white/80">标签</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{currentPhoto.tags.map((tag) => (
|
||||
<MotionButtonBase
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.open(
|
||||
`/?tags=${tag}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
}}
|
||||
key={tag}
|
||||
className="bg-material-medium hover:bg-material-thin inline-flex cursor-pointer items-center rounded-full px-2 py-1 text-xs text-white/90 backdrop-blur-sm"
|
||||
>
|
||||
{tag}
|
||||
</MotionButtonBase>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formattedExifData && (
|
||||
<Fragment>
|
||||
{(formattedExifData.camera || formattedExifData.lens) && (
|
||||
<div>
|
||||
<h4 className="my-2 text-sm font-medium text-white/80">
|
||||
设备信息
|
||||
</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
{formattedExifData.camera && (
|
||||
<Row label="相机" value={formattedExifData.camera} />
|
||||
)}
|
||||
{formattedExifData.lens && (
|
||||
<Row label="镜头" value={formattedExifData.lens} />
|
||||
)}
|
||||
|
||||
{formattedExifData.focalLength && (
|
||||
<Row
|
||||
label="实际焦距"
|
||||
value={`${formattedExifData.focalLength}mm`}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.focalLength35mm && (
|
||||
<Row
|
||||
label="等效焦距"
|
||||
value={`${formattedExifData.focalLength35mm}mm`}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.maxAperture && (
|
||||
<Row
|
||||
label="最大光圈"
|
||||
value={`f/${formattedExifData.maxAperture}`}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.digitalZoom && (
|
||||
<Row
|
||||
label="数字变焦"
|
||||
value={`${formattedExifData.digitalZoom.toFixed(2)}x`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="my-2 text-sm font-medium text-white/80">
|
||||
拍摄参数
|
||||
</h4>
|
||||
<div className={`grid grid-cols-2 gap-3`}>
|
||||
{formattedExifData.focalLength35mm && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-white/10 px-2 py-1">
|
||||
<StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens className="text-sm text-white/70" />
|
||||
<span className="text-xs">
|
||||
{formattedExifData.focalLength35mm}mm
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formattedExifData.aperture && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-white/10 px-2 py-1">
|
||||
<TablerAperture className="text-sm text-white/70" />
|
||||
<span className="text-xs">
|
||||
{formattedExifData.aperture}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formattedExifData.shutterSpeed && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-white/10 px-2 py-1">
|
||||
<MaterialSymbolsShutterSpeed className="text-sm text-white/70" />
|
||||
<span className="text-xs">
|
||||
{formattedExifData.shutterSpeed}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formattedExifData.iso && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-white/10 px-2 py-1">
|
||||
<CarbonIsoOutline className="text-sm text-white/70" />
|
||||
<span className="text-xs">
|
||||
ISO {formattedExifData.iso}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formattedExifData.exposureBias && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-white/10 px-2 py-1">
|
||||
<MaterialSymbolsExposure className="text-sm text-white/70" />
|
||||
<span className="text-xs">
|
||||
{formattedExifData.exposureBias}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 新增:拍摄模式信息 */}
|
||||
{(formattedExifData.exposureMode ||
|
||||
formattedExifData.meteringMode ||
|
||||
formattedExifData.whiteBalance ||
|
||||
formattedExifData.lightSource ||
|
||||
formattedExifData.flash) && (
|
||||
<div>
|
||||
<h4 className="my-2 text-sm font-medium text-white/80">
|
||||
拍摄模式
|
||||
</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
{formattedExifData.exposureMode && (
|
||||
<Row
|
||||
label="曝光模式"
|
||||
value={formattedExifData.exposureMode}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.meteringMode && (
|
||||
<Row
|
||||
label="测光模式"
|
||||
value={formattedExifData.meteringMode}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.whiteBalance && (
|
||||
<Row
|
||||
label="白平衡"
|
||||
value={formattedExifData.whiteBalance}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.whiteBalanceBias && (
|
||||
<Row
|
||||
label="白平衡偏移"
|
||||
value={`${formattedExifData.whiteBalanceBias} Mired`}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.wbShiftAB && (
|
||||
<Row
|
||||
label="白平衡偏移 (琥珀-蓝)"
|
||||
value={formattedExifData.wbShiftAB}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.wbShiftGM && (
|
||||
<Row
|
||||
label="白平衡偏移 (绿-洋红)"
|
||||
value={formattedExifData.wbShiftGM}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.whiteBalanceFineTune && (
|
||||
<Row
|
||||
label="白平衡微调"
|
||||
value={formattedExifData.whiteBalanceFineTune}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.wbGRBLevels && (
|
||||
<Row
|
||||
label="白平衡 GRB 级别"
|
||||
value={
|
||||
Array.isArray(formattedExifData.wbGRBLevels)
|
||||
? formattedExifData.wbGRBLevels.join(' ')
|
||||
: formattedExifData.wbGRBLevels
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.wbGRBLevelsStandard && (
|
||||
<Row
|
||||
label="标准白平衡 GRB"
|
||||
value={
|
||||
Array.isArray(formattedExifData.wbGRBLevelsStandard)
|
||||
? formattedExifData.wbGRBLevelsStandard.join(' ')
|
||||
: formattedExifData.wbGRBLevelsStandard
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.wbGRBLevelsAuto && (
|
||||
<Row
|
||||
label="自动白平衡 GRB"
|
||||
value={
|
||||
Array.isArray(formattedExifData.wbGRBLevelsAuto)
|
||||
? formattedExifData.wbGRBLevelsAuto.join(' ')
|
||||
: formattedExifData.wbGRBLevelsAuto
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.flash && (
|
||||
<Row label="闪光灯" value={formattedExifData.flash} />
|
||||
)}
|
||||
{formattedExifData.lightSource && (
|
||||
<Row label="光源" value={formattedExifData.lightSource} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formattedExifData.fujiRecipe && (
|
||||
<div>
|
||||
<h4 className="my-2 text-sm font-medium text-white/80">
|
||||
富士胶片模拟
|
||||
</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
{formattedExifData.fujiRecipe.FilmMode && (
|
||||
<Row
|
||||
label="胶片模式"
|
||||
value={formattedExifData.fujiRecipe.FilmMode}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.fujiRecipe.DynamicRange && (
|
||||
<Row
|
||||
label="动态范围"
|
||||
value={formattedExifData.fujiRecipe.DynamicRange}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.fujiRecipe.WhiteBalance && (
|
||||
<Row
|
||||
label="白平衡"
|
||||
value={formattedExifData.fujiRecipe.WhiteBalance}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.fujiRecipe.HighlightTone && (
|
||||
<Row
|
||||
label="高光色调"
|
||||
value={formattedExifData.fujiRecipe.HighlightTone}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.fujiRecipe.ShadowTone && (
|
||||
<Row
|
||||
label="阴影色调"
|
||||
value={formattedExifData.fujiRecipe.ShadowTone}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.fujiRecipe.Saturation && (
|
||||
<Row
|
||||
label="饱和度"
|
||||
value={formattedExifData.fujiRecipe.Saturation}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.fujiRecipe.Sharpness && (
|
||||
<Row
|
||||
label="锐度"
|
||||
value={formattedExifData.fujiRecipe.Sharpness}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.fujiRecipe.NoiseReduction && (
|
||||
<Row
|
||||
label="降噪"
|
||||
value={formattedExifData.fujiRecipe.NoiseReduction}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.fujiRecipe.Clarity && (
|
||||
<Row
|
||||
label="清晰度"
|
||||
value={formattedExifData.fujiRecipe.Clarity}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.fujiRecipe.ColorChromeEffect && (
|
||||
<Row
|
||||
label="色彩效果"
|
||||
value={formattedExifData.fujiRecipe.ColorChromeEffect}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.fujiRecipe.ColorChromeFxBlue && (
|
||||
<Row
|
||||
label="蓝色色彩效果"
|
||||
value={formattedExifData.fujiRecipe.ColorChromeFxBlue}
|
||||
/>
|
||||
)}
|
||||
{(formattedExifData.fujiRecipe.GrainEffectRoughness ||
|
||||
formattedExifData.fujiRecipe.GrainEffectSize) && (
|
||||
<>
|
||||
{formattedExifData.fujiRecipe.GrainEffectRoughness && (
|
||||
<Row
|
||||
label="颗粒效果强度"
|
||||
value={
|
||||
formattedExifData.fujiRecipe.GrainEffectRoughness
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.fujiRecipe.GrainEffectSize && (
|
||||
<Row
|
||||
label="颗粒效果大小"
|
||||
value={formattedExifData.fujiRecipe.GrainEffectSize}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(formattedExifData.fujiRecipe.Red ||
|
||||
formattedExifData.fujiRecipe.Blue) && (
|
||||
<>
|
||||
{formattedExifData.fujiRecipe.Red && (
|
||||
<Row
|
||||
label="红色调整"
|
||||
value={formattedExifData.fujiRecipe.Red}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.fujiRecipe.Blue && (
|
||||
<Row
|
||||
label="蓝色调整"
|
||||
value={formattedExifData.fujiRecipe.Blue}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{formattedExifData.gps && (
|
||||
<div>
|
||||
<h4 className="my-2 text-sm font-medium text-white/80">
|
||||
位置信息
|
||||
</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
<Row label="纬度" value={formattedExifData.gps.latitude} />
|
||||
<Row label="经度" value={formattedExifData.gps.longitude} />
|
||||
{formattedExifData.gps.altitude && (
|
||||
<Row
|
||||
label="海拔"
|
||||
value={`${formattedExifData.gps.altitude}m`}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-2 text-right">
|
||||
<a
|
||||
href={`https://uri.amap.com/marker?position=${formattedExifData.gps.longitude},${formattedExifData.gps.latitude}&name=拍摄位置`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue inline-flex items-center gap-1 text-xs underline transition-colors hover:text-blue-300"
|
||||
>
|
||||
在高德地图中查看
|
||||
<i className="i-mingcute-external-link-line" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新增:技术参数 */}
|
||||
{(formattedExifData.brightnessValue ||
|
||||
formattedExifData.shutterSpeedValue ||
|
||||
formattedExifData.apertureValue ||
|
||||
formattedExifData.sensingMethod ||
|
||||
formattedExifData.customRendered ||
|
||||
formattedExifData.focalPlaneXResolution ||
|
||||
formattedExifData.focalPlaneYResolution) && (
|
||||
<div>
|
||||
<h4 className="my-2 text-sm font-medium text-white/80">
|
||||
技术参数
|
||||
</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
{formattedExifData.brightnessValue && (
|
||||
<Row
|
||||
label="亮度值"
|
||||
value={formattedExifData.brightnessValue}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.shutterSpeedValue && (
|
||||
<Row
|
||||
label="快门速度值"
|
||||
value={formattedExifData.shutterSpeedValue}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.apertureValue && (
|
||||
<Row
|
||||
label="光圈值"
|
||||
value={formattedExifData.apertureValue}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.sensingMethod && (
|
||||
<Row
|
||||
label="感光方法"
|
||||
value={formattedExifData.sensingMethod}
|
||||
/>
|
||||
)}
|
||||
{formattedExifData.customRendered && (
|
||||
<Row
|
||||
label="图像处理"
|
||||
value={formattedExifData.customRendered}
|
||||
/>
|
||||
)}
|
||||
{(formattedExifData.focalPlaneXResolution ||
|
||||
formattedExifData.focalPlaneYResolution) && (
|
||||
<Row
|
||||
label="焦平面分辨率"
|
||||
value={`${formattedExifData.focalPlaneXResolution || 'N/A'} × ${formattedExifData.focalPlaneYResolution || 'N/A'}${formattedExifData.focalPlaneResolutionUnit ? ` (${formattedExifData.focalPlaneResolutionUnit})` : ''}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
|
||||
const formatExifData = (exif: Exif | null) => {
|
||||
if (!exif) return null
|
||||
|
||||
const photo = exif.Photo || {}
|
||||
const image = exif.Image || {}
|
||||
const gps = exif.GPSInfo || {}
|
||||
|
||||
// 等效焦距 (35mm)
|
||||
const focalLength35mm = photo.FocalLengthIn35mmFilm
|
||||
? Math.round(photo.FocalLengthIn35mmFilm)
|
||||
: null
|
||||
|
||||
// 实际焦距
|
||||
const focalLength = photo.FocalLength ? Math.round(photo.FocalLength) : null
|
||||
|
||||
// ISO
|
||||
const iso = photo.ISOSpeedRatings || image.ISOSpeedRatings
|
||||
|
||||
// 快门速度
|
||||
const exposureTime = photo.ExposureTime
|
||||
const shutterSpeed = exposureTime
|
||||
? exposureTime >= 1
|
||||
? `${exposureTime}s`
|
||||
: `1/${Math.round(1 / exposureTime)}`
|
||||
: null
|
||||
|
||||
// 光圈
|
||||
const aperture = photo.FNumber ? `f/${photo.FNumber}` : null
|
||||
|
||||
// 最大光圈
|
||||
const maxAperture = photo.MaxApertureValue
|
||||
? `${Math.round(Math.pow(Math.sqrt(2), photo.MaxApertureValue) * 10) / 10}`
|
||||
: null
|
||||
|
||||
// 相机信息
|
||||
const camera =
|
||||
image.Make && image.Model ? `${image.Make} ${image.Model}` : null
|
||||
|
||||
// 镜头信息
|
||||
const lens =
|
||||
photo.LensModel || photo.LensSpecification || photo.LensMake || null
|
||||
|
||||
// 软件信息
|
||||
const software = image.Software || null
|
||||
|
||||
const offsetTimeOriginal = photo.OffsetTimeOriginal || photo.OffsetTime
|
||||
// 拍摄时间
|
||||
const dateTime: string | null = (() => {
|
||||
const originalDateTimeStr =
|
||||
(photo.DateTimeOriginal as unknown as string) ||
|
||||
(photo.DateTime as unknown as string)
|
||||
|
||||
if (!originalDateTimeStr) return null
|
||||
|
||||
const date = new Date(originalDateTimeStr)
|
||||
|
||||
if (offsetTimeOriginal) {
|
||||
// 解析时区偏移,例如 "+08:00" 或 "-05:00"
|
||||
const offsetMatch = offsetTimeOriginal.match(/([+-])(\d{2}):(\d{2})/)
|
||||
if (offsetMatch) {
|
||||
const [, sign, hours, minutes] = offsetMatch
|
||||
const offsetMinutes =
|
||||
(Number.parseInt(hours) * 60 + Number.parseInt(minutes)) *
|
||||
(sign === '+' ? 1 : -1)
|
||||
|
||||
// 减去偏移量,将本地时间转换为 UTC 时间
|
||||
const utcTime = new Date(date.getTime() - offsetMinutes * 60 * 1000)
|
||||
return formatDateTime(utcTime)
|
||||
}
|
||||
|
||||
return formatDateTime(date)
|
||||
}
|
||||
|
||||
return formatDateTime(date)
|
||||
})()
|
||||
|
||||
// 曝光模式
|
||||
const exposureModeMap: Record<number, string> = {
|
||||
0: '自动曝光',
|
||||
1: '手动曝光',
|
||||
2: '自动包围曝光',
|
||||
}
|
||||
const exposureMode =
|
||||
photo.ExposureMode !== undefined
|
||||
? exposureModeMap[photo.ExposureMode] || `未知 (${photo.ExposureMode})`
|
||||
: null
|
||||
|
||||
// 测光模式
|
||||
const meteringModeMap: Record<number, string> = {
|
||||
0: '未知',
|
||||
1: '平均测光',
|
||||
2: '中央重点测光',
|
||||
3: '点测光',
|
||||
4: '多点测光',
|
||||
5: '评价测光',
|
||||
6: '局部测光',
|
||||
}
|
||||
const meteringMode =
|
||||
photo.MeteringMode !== undefined
|
||||
? meteringModeMap[photo.MeteringMode] || `未知 (${photo.MeteringMode})`
|
||||
: null
|
||||
|
||||
// 白平衡
|
||||
const whiteBalanceMap: Record<number, string> = {
|
||||
0: '自动白平衡',
|
||||
1: '手动白平衡',
|
||||
}
|
||||
const whiteBalance =
|
||||
photo.WhiteBalance !== undefined
|
||||
? whiteBalanceMap[photo.WhiteBalance] || `未知 (${photo.WhiteBalance})`
|
||||
: null
|
||||
|
||||
// 闪光灯
|
||||
const flashMap: Record<number, string> = {
|
||||
0: '未闪光',
|
||||
1: '闪光',
|
||||
5: '闪光,未检测到回闪',
|
||||
7: '闪光,检测到回闪',
|
||||
9: '强制闪光',
|
||||
13: '强制闪光,未检测到回闪',
|
||||
15: '强制闪光,检测到回闪',
|
||||
16: '未闪光,强制关闭',
|
||||
24: '未闪光,自动模式',
|
||||
25: '闪光,自动模式',
|
||||
29: '闪光,自动模式,未检测到回闪',
|
||||
31: '闪光,自动模式,检测到回闪',
|
||||
32: '未提供闪光功能',
|
||||
}
|
||||
const flash =
|
||||
photo.Flash !== undefined
|
||||
? flashMap[photo.Flash] || `未知 (${photo.Flash})`
|
||||
: null
|
||||
|
||||
// 数字变焦
|
||||
const digitalZoom = photo.DigitalZoomRatio || null
|
||||
|
||||
// 曝光补偿
|
||||
const exposureBias = photo.ExposureBiasValue
|
||||
? `${photo.ExposureBiasValue > 0 ? '+' : ''}${photo.ExposureBiasValue.toFixed(1)} EV`
|
||||
: null
|
||||
|
||||
// 亮度值
|
||||
const brightnessValue = photo.BrightnessValue
|
||||
? `${photo.BrightnessValue.toFixed(1)} EV`
|
||||
: null
|
||||
|
||||
// 快门速度值
|
||||
const shutterSpeedValue = photo.ShutterSpeedValue
|
||||
? `${photo.ShutterSpeedValue.toFixed(1)} EV`
|
||||
: null
|
||||
|
||||
// 光圈值
|
||||
const apertureValue = photo.ApertureValue
|
||||
? `${photo.ApertureValue.toFixed(1)} EV`
|
||||
: null
|
||||
|
||||
// 光源类型
|
||||
const lightSourceMap: Record<number, string> = {
|
||||
0: '自动',
|
||||
1: '日光',
|
||||
2: '荧光灯',
|
||||
3: '钨丝灯',
|
||||
4: '闪光灯',
|
||||
9: '晴天',
|
||||
10: '阴天',
|
||||
11: '阴影',
|
||||
12: '日光荧光灯 (D 5700 – 7100K)',
|
||||
13: '日白荧光灯 (N 4600 – 5400K)',
|
||||
14: '冷白荧光灯 (W 3900 – 4500K)',
|
||||
15: '白荧光灯 (WW 3200 – 3700K)',
|
||||
17: '标准光源 A',
|
||||
18: '标准光源 B',
|
||||
19: '标准光源 C',
|
||||
20: 'D55',
|
||||
21: 'D65',
|
||||
22: 'D75',
|
||||
23: 'D50',
|
||||
24: 'ISO 钨丝灯',
|
||||
255: '其他光源',
|
||||
}
|
||||
const lightSource =
|
||||
photo.LightSource !== undefined
|
||||
? lightSourceMap[photo.LightSource] || `未知 (${photo.LightSource})`
|
||||
: null
|
||||
|
||||
// 白平衡偏移/微调相关字段
|
||||
const whiteBalanceBias = (photo as any).WhiteBalanceBias || null
|
||||
const wbShiftAB = (photo as any).WBShiftAB || null
|
||||
const wbShiftGM = (photo as any).WBShiftGM || null
|
||||
const whiteBalanceFineTune = (photo as any).WhiteBalanceFineTune || null
|
||||
|
||||
// 富士相机特有的白平衡字段
|
||||
const wbGRBLevels =
|
||||
(photo as any).WBGRBLevels || (photo as any)['WB GRB Levels'] || null
|
||||
const wbGRBLevelsStandard =
|
||||
(photo as any).WBGRBLevelsStandard ||
|
||||
(photo as any)['WB GRB Levels Standard'] ||
|
||||
null
|
||||
const wbGRBLevelsAuto =
|
||||
(photo as any).WBGRBLevelsAuto ||
|
||||
(photo as any)['WB GRB Levels Auto'] ||
|
||||
null
|
||||
|
||||
// 感光方法
|
||||
const sensingMethodMap: Record<number, string> = {
|
||||
1: '未定义',
|
||||
2: '单芯片彩色区域传感器',
|
||||
3: '双芯片彩色区域传感器',
|
||||
4: '三芯片彩色区域传感器',
|
||||
5: '彩色顺序区域传感器',
|
||||
7: '三线传感器',
|
||||
8: '彩色顺序线性传感器',
|
||||
}
|
||||
const sensingMethod =
|
||||
photo.SensingMethod !== undefined
|
||||
? sensingMethodMap[photo.SensingMethod] || `未知 (${photo.SensingMethod})`
|
||||
: null
|
||||
|
||||
// 自定义渲染
|
||||
const customRenderedMap: Record<number, string> = {
|
||||
0: '正常处理',
|
||||
1: '自定义处理',
|
||||
}
|
||||
const customRendered =
|
||||
photo.CustomRendered !== undefined
|
||||
? customRenderedMap[photo.CustomRendered] ||
|
||||
`未知 (${photo.CustomRendered})`
|
||||
: null
|
||||
|
||||
// 焦平面分辨率
|
||||
const focalPlaneXResolution = photo.FocalPlaneXResolution
|
||||
? Math.round(photo.FocalPlaneXResolution)
|
||||
: null
|
||||
const focalPlaneYResolution = photo.FocalPlaneYResolution
|
||||
? Math.round(photo.FocalPlaneYResolution)
|
||||
: null
|
||||
|
||||
// 焦平面分辨率单位
|
||||
const focalPlaneResolutionUnitMap: Record<number, string> = {
|
||||
1: '无单位',
|
||||
2: '英寸',
|
||||
3: '厘米',
|
||||
}
|
||||
const focalPlaneResolutionUnit =
|
||||
photo.FocalPlaneResolutionUnit !== undefined
|
||||
? focalPlaneResolutionUnitMap[photo.FocalPlaneResolutionUnit] ||
|
||||
`未知 (${photo.FocalPlaneResolutionUnit})`
|
||||
: null
|
||||
|
||||
// 像素信息
|
||||
const pixelXDimension = photo.PixelXDimension || null
|
||||
const pixelYDimension = photo.PixelYDimension || null
|
||||
const totalPixels =
|
||||
pixelXDimension && pixelYDimension
|
||||
? pixelXDimension * pixelYDimension
|
||||
: null
|
||||
const megaPixels = totalPixels
|
||||
? `${(totalPixels / 1000000).toFixed(1)}MP`
|
||||
: null
|
||||
|
||||
// 色彩空间
|
||||
const colorSpaceMap: Record<number, string> = {
|
||||
1: 'sRGB',
|
||||
65535: 'Adobe RGB',
|
||||
}
|
||||
const colorSpace =
|
||||
photo.ColorSpace !== undefined
|
||||
? colorSpaceMap[photo.ColorSpace] || `未知 (${photo.ColorSpace})`
|
||||
: null
|
||||
|
||||
// GPS 信息
|
||||
let gpsInfo: {
|
||||
latitude: string | undefined
|
||||
longitude: string | undefined
|
||||
altitude: number | null
|
||||
} | null = null
|
||||
if (gps.GPSLatitude && gps.GPSLongitude) {
|
||||
const latitude = convertDMSToDD(gps.GPSLatitude, gps.GPSLatitudeRef || '')
|
||||
const longitude = convertDMSToDD(
|
||||
gps.GPSLongitude,
|
||||
gps.GPSLongitudeRef || '',
|
||||
)
|
||||
const altitude = gps.GPSAltitude || null
|
||||
|
||||
gpsInfo = {
|
||||
latitude: latitude?.toFixed(6),
|
||||
longitude: longitude?.toFixed(6),
|
||||
altitude: altitude ? Math.round(altitude) : null,
|
||||
}
|
||||
}
|
||||
|
||||
// 富士相机 Recipe 信息
|
||||
const fujiRecipe = (exif as any).FujiRecipe || null
|
||||
|
||||
return {
|
||||
focalLength35mm,
|
||||
focalLength,
|
||||
iso,
|
||||
shutterSpeed,
|
||||
aperture,
|
||||
maxAperture,
|
||||
camera,
|
||||
lens,
|
||||
software,
|
||||
dateTime,
|
||||
exposureMode,
|
||||
meteringMode,
|
||||
whiteBalance,
|
||||
flash,
|
||||
digitalZoom,
|
||||
colorSpace,
|
||||
gps: gpsInfo,
|
||||
exposureBias,
|
||||
brightnessValue,
|
||||
shutterSpeedValue,
|
||||
apertureValue,
|
||||
lightSource,
|
||||
sensingMethod,
|
||||
customRendered,
|
||||
focalPlaneXResolution,
|
||||
focalPlaneYResolution,
|
||||
focalPlaneResolutionUnit,
|
||||
megaPixels,
|
||||
pixelXDimension,
|
||||
pixelYDimension,
|
||||
whiteBalanceBias,
|
||||
wbShiftAB,
|
||||
wbShiftGM,
|
||||
whiteBalanceFineTune,
|
||||
wbGRBLevels,
|
||||
wbGRBLevelsStandard,
|
||||
wbGRBLevelsAuto,
|
||||
fujiRecipe,
|
||||
}
|
||||
}
|
||||
|
||||
// 将度分秒格式转换为十进制度数
|
||||
const convertDMSToDD = (dms: number[], ref: string): number | null => {
|
||||
if (!dms || dms.length !== 3) return null
|
||||
|
||||
const [degrees, minutes, seconds] = dms
|
||||
let dd = degrees + minutes / 60 + seconds / 3600
|
||||
|
||||
if (ref === 'S' || ref === 'W') {
|
||||
dd = dd * -1
|
||||
}
|
||||
|
||||
return dd
|
||||
}
|
||||
|
||||
const Row: FC<{
|
||||
label: string
|
||||
value: string | number | null | undefined | number[]
|
||||
ellipsis?: boolean
|
||||
}> = ({ label, value, ellipsis }) => {
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-text-secondary shrink-0">{label}</span>
|
||||
|
||||
{ellipsis ? (
|
||||
<span className="relative min-w-0 flex-1 shrink">
|
||||
<span className="absolute inset-0">
|
||||
<EllipsisHorizontalTextWithTooltip className="text-text min-w-0 text-right">
|
||||
{Array.isArray(value) ? value.join(' ') : value}
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text min-w-0 text-right">
|
||||
{Array.isArray(value) ? value.join(' ') : value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const datetimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
})
|
||||
|
||||
const formatDateTime = (date: Date | null | undefined) => {
|
||||
if (!date) return ''
|
||||
|
||||
return datetimeFormatter.format(date)
|
||||
}
|
||||
98
apps/web/src/components/ui/photo-viewer/GalleryThumbnail.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { m } from 'motion/react'
|
||||
import type { FC } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { useMobile } from '~/hooks/useMobile'
|
||||
import { clsxm } from '~/lib/cn'
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
export const GalleryThumbnail: FC<{
|
||||
currentIndex: number
|
||||
photos: PhotoManifest[]
|
||||
onIndexChange: (index: number) => void
|
||||
}> = ({ currentIndex, photos, onIndexChange }) => {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const thumbnailRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const isMobile = useMobile()
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
const currentThumbnail = thumbnailRefs.current[currentIndex]
|
||||
|
||||
if (scrollContainer && currentThumbnail) {
|
||||
const containerWidth = scrollContainer.clientWidth
|
||||
const thumbnailLeft = currentThumbnail.offsetLeft
|
||||
const thumbnailWidth = currentThumbnail.clientWidth
|
||||
|
||||
const scrollLeft = thumbnailLeft - containerWidth / 2 + thumbnailWidth / 2
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
left: scrollLeft,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}, [currentIndex])
|
||||
|
||||
// 处理鼠标滚轮事件,映射为横向滚动
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current
|
||||
if (!scrollContainer) return
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// 阻止默认的垂直滚动
|
||||
e.preventDefault()
|
||||
|
||||
// 优先使用触控板的横向滚动 (deltaX)
|
||||
// 如果没有横向滚动,则将垂直滚动 (deltaY) 转换为横向滚动
|
||||
const scrollAmount =
|
||||
Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
|
||||
scrollContainer.scrollLeft += scrollAmount
|
||||
}
|
||||
|
||||
scrollContainer.addEventListener('wheel', handleWheel, { passive: false })
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('wheel', handleWheel)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<m.div
|
||||
className="bg-material-medium pb-safe z-10 shrink-0 backdrop-blur-3xl"
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 100 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="bg-material-medium backdrop-blur-[70px]">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={`gallery-thumbnail-container flex ${isMobile ? 'gap-2' : 'gap-3'} overflow-x-auto ${isMobile ? 'p-3' : 'p-4'} scrollbar-none`}
|
||||
>
|
||||
{photos.map((photo, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={photo.id}
|
||||
ref={(el) => {
|
||||
thumbnailRefs.current[index] = el
|
||||
}}
|
||||
className={clsxm(
|
||||
`flex-shrink-0 ${isMobile ? 'w-12 h-12' : 'w-16 h-16'} rounded-lg overflow-hidden ring-2 transition-all contain-intrinsic-size`,
|
||||
index === currentIndex
|
||||
? 'ring-accent scale-110'
|
||||
: 'ring-transparent hover:ring-accent',
|
||||
)}
|
||||
onClick={() => onIndexChange(index)}
|
||||
>
|
||||
<img
|
||||
src={photo.thumbnailUrl}
|
||||
alt={photo.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
316
apps/web/src/components/ui/photo-viewer/LivePhoto.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { m, useAnimationControls } from 'motion/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { clsxm } from '~/lib/cn'
|
||||
import { isMobileDevice } from '~/lib/device-viewport'
|
||||
import type { ImageLoaderManager } from '~/lib/image-loader-manager'
|
||||
import { isWebCodecsSupported } from '~/lib/video-converter'
|
||||
|
||||
import type { LoadingIndicatorRef } from './LoadingIndicator'
|
||||
|
||||
interface LivePhotoProps {
|
||||
/** Live Photo 视频 URL */
|
||||
videoUrl: string
|
||||
/** 图片加载管理器实例 */
|
||||
imageLoaderManager: ImageLoaderManager
|
||||
/** 加载指示器引用 */
|
||||
loadingIndicatorRef: React.RefObject<LoadingIndicatorRef | null>
|
||||
/** 是否是当前图片 */
|
||||
isCurrentImage: boolean
|
||||
/** 自定义样式类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const LivePhoto = ({
|
||||
videoUrl,
|
||||
imageLoaderManager,
|
||||
loadingIndicatorRef,
|
||||
isCurrentImage,
|
||||
className,
|
||||
}: LivePhotoProps) => {
|
||||
// Live Photo 相关状态
|
||||
const [isPlayingLivePhoto, setIsPlayingLivePhoto] = useState(false)
|
||||
const [livePhotoVideoLoaded, setLivePhotoVideoLoaded] = useState(false)
|
||||
const [isConvertingVideo, setIsConvertingVideo] = useState(false)
|
||||
const [conversionMethod, setConversionMethod] = useState<string>('')
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const videoAnimateController = useAnimationControls()
|
||||
|
||||
// Live Photo hover 相关
|
||||
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const [isLongPressing, setIsLongPressing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isCurrentImage ||
|
||||
livePhotoVideoLoaded ||
|
||||
isConvertingVideo ||
|
||||
!videoRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsConvertingVideo(true)
|
||||
|
||||
const processVideo = async () => {
|
||||
try {
|
||||
const videoResult = await imageLoaderManager.processLivePhotoVideo(
|
||||
videoUrl,
|
||||
videoRef.current!,
|
||||
{
|
||||
onLoadingStateUpdate: (state) => {
|
||||
loadingIndicatorRef.current?.updateLoadingState(state)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if (videoResult.conversionMethod) {
|
||||
setConversionMethod(videoResult.conversionMethod)
|
||||
}
|
||||
|
||||
setLivePhotoVideoLoaded(true)
|
||||
} catch (videoError) {
|
||||
console.error('Failed to process Live Photo video:', videoError)
|
||||
} finally {
|
||||
setIsConvertingVideo(false)
|
||||
}
|
||||
}
|
||||
|
||||
processVideo()
|
||||
}, [
|
||||
isCurrentImage,
|
||||
livePhotoVideoLoaded,
|
||||
isConvertingVideo,
|
||||
videoUrl,
|
||||
imageLoaderManager,
|
||||
loadingIndicatorRef,
|
||||
])
|
||||
|
||||
// 清理函数
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clean up timers
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current)
|
||||
hoverTimerRef.current = null
|
||||
}
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current)
|
||||
longPressTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 重置状态(当不是当前图片时)
|
||||
useEffect(() => {
|
||||
if (!isCurrentImage) {
|
||||
setIsPlayingLivePhoto(false)
|
||||
setLivePhotoVideoLoaded(false)
|
||||
setIsConvertingVideo(false)
|
||||
setConversionMethod('')
|
||||
setIsLongPressing(false)
|
||||
|
||||
// Clean up timers
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current)
|
||||
hoverTimerRef.current = null
|
||||
}
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current)
|
||||
longPressTimerRef.current = null
|
||||
}
|
||||
|
||||
// Reset video animation
|
||||
videoAnimateController.set({ opacity: 0 })
|
||||
}
|
||||
}, [isCurrentImage, videoAnimateController])
|
||||
|
||||
// Live Photo hover 处理
|
||||
const handleBadgeMouseEnter = useCallback(() => {
|
||||
if (!livePhotoVideoLoaded || isPlayingLivePhoto || isConvertingVideo) return
|
||||
|
||||
hoverTimerRef.current = setTimeout(async () => {
|
||||
setIsPlayingLivePhoto(true)
|
||||
|
||||
// 开始淡入动画
|
||||
await videoAnimateController.start({
|
||||
opacity: 1,
|
||||
transition: { duration: 0.15, ease: 'easeOut' },
|
||||
})
|
||||
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
video.currentTime = 0
|
||||
video.play()
|
||||
}
|
||||
}, 200) // 200ms hover 延迟
|
||||
}, [
|
||||
livePhotoVideoLoaded,
|
||||
isPlayingLivePhoto,
|
||||
isConvertingVideo,
|
||||
videoAnimateController,
|
||||
])
|
||||
|
||||
const handleBadgeMouseLeave = useCallback(async () => {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current)
|
||||
hoverTimerRef.current = null
|
||||
}
|
||||
|
||||
if (isPlayingLivePhoto) {
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
video.pause()
|
||||
video.currentTime = 0
|
||||
}
|
||||
|
||||
// 开始淡出动画
|
||||
await videoAnimateController.start({
|
||||
opacity: 0,
|
||||
transition: { duration: 0.2, ease: 'easeIn' },
|
||||
})
|
||||
|
||||
setIsPlayingLivePhoto(false)
|
||||
}
|
||||
}, [isPlayingLivePhoto, videoAnimateController])
|
||||
|
||||
// 视频播放结束处理
|
||||
const handleVideoEnded = useCallback(async () => {
|
||||
// 播放结束时淡出
|
||||
await videoAnimateController.start({
|
||||
opacity: 0,
|
||||
transition: { duration: 0.2, ease: 'easeIn' },
|
||||
})
|
||||
|
||||
setIsPlayingLivePhoto(false)
|
||||
}, [videoAnimateController])
|
||||
|
||||
// Live Photo 长按处理(移动端)
|
||||
const handleTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (
|
||||
!livePhotoVideoLoaded ||
|
||||
isPlayingLivePhoto ||
|
||||
isConvertingVideo ||
|
||||
e.touches.length > 1 // 多指触摸不触发长按
|
||||
)
|
||||
return
|
||||
|
||||
longPressTimerRef.current = setTimeout(async () => {
|
||||
setIsLongPressing(true)
|
||||
setIsPlayingLivePhoto(true)
|
||||
|
||||
// 开始淡入动画
|
||||
await videoAnimateController.start({
|
||||
opacity: 1,
|
||||
transition: { duration: 0.15, ease: 'easeOut' },
|
||||
})
|
||||
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
video.currentTime = 0
|
||||
video.play()
|
||||
}
|
||||
}, 500) // 500ms 长按延迟
|
||||
},
|
||||
[
|
||||
livePhotoVideoLoaded,
|
||||
isPlayingLivePhoto,
|
||||
isConvertingVideo,
|
||||
videoAnimateController,
|
||||
],
|
||||
)
|
||||
|
||||
const handleTouchEnd = useCallback(async () => {
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current)
|
||||
longPressTimerRef.current = null
|
||||
}
|
||||
|
||||
if (isLongPressing && isPlayingLivePhoto) {
|
||||
setIsLongPressing(false)
|
||||
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
video.pause()
|
||||
video.currentTime = 0
|
||||
}
|
||||
|
||||
// 开始淡出动画
|
||||
await videoAnimateController.start({
|
||||
opacity: 0,
|
||||
transition: { duration: 0.2, ease: 'easeIn' },
|
||||
})
|
||||
|
||||
setIsPlayingLivePhoto(false)
|
||||
}
|
||||
}, [isLongPressing, isPlayingLivePhoto, videoAnimateController])
|
||||
|
||||
const handleTouchMove = useCallback(() => {
|
||||
// 触摸移动时取消长按
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current)
|
||||
longPressTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Live Photo 视频 */}
|
||||
<m.video
|
||||
ref={videoRef}
|
||||
className={clsxm(
|
||||
'absolute inset-0 z-10 h-full w-full object-contain',
|
||||
className,
|
||||
)}
|
||||
muted
|
||||
playsInline
|
||||
onEnded={handleVideoEnded}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchMove={handleTouchMove}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={videoAnimateController}
|
||||
/>
|
||||
|
||||
{/* Live Photo 标识 */}
|
||||
<div
|
||||
className={clsxm(
|
||||
'absolute z-50 flex items-center space-x-1 rounded-xl bg-black/50 px-1 py-1 text-xs text-white cursor-pointer transition-all duration-200 hover:bg-black/70',
|
||||
import.meta.env.DEV ? 'top-16 right-4' : 'top-12 lg:top-4 left-4',
|
||||
)}
|
||||
onMouseEnter={handleBadgeMouseEnter}
|
||||
onMouseLeave={handleBadgeMouseLeave}
|
||||
title={isMobileDevice ? '长按播放实况照片' : '悬浮播放实况照片'}
|
||||
>
|
||||
{isConvertingVideo ? (
|
||||
<div className="flex items-center gap-1 px-1">
|
||||
<i className="i-mingcute-loading-line animate-spin" />
|
||||
<span>实况视频转换中</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<i className="i-mingcute-live-photo-line size-4" />
|
||||
<span className="mr-1">实况</span>
|
||||
{conversionMethod && (
|
||||
<span className="rounded bg-white/20 px-1 text-xs">
|
||||
{conversionMethod === 'webcodecs' ? 'WebCodecs' : ''}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作提示 */}
|
||||
<div className="pointer-events-none absolute bottom-4 left-1/2 z-20 -translate-x-1/2 rounded bg-black/50 px-2 py-1 text-xs text-white opacity-0 duration-200 group-hover:opacity-50">
|
||||
{isConvertingVideo
|
||||
? `正在使用 ${isWebCodecsSupported() ? 'WebCodecs' : 'FFmpeg'} 转换视频格式...`
|
||||
: isMobileDevice
|
||||
? '长按播放实况照片 / 双击缩放'
|
||||
: '悬浮实况标识播放 / 双击缩放'}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
114
apps/web/src/components/ui/photo-viewer/LoadingIndicator.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { AnimatePresence, m } from 'motion/react'
|
||||
import { useCallback, useImperativeHandle, useState } from 'react'
|
||||
|
||||
import { Spring } from '~/lib/spring'
|
||||
|
||||
interface LoadingState {
|
||||
isVisible: boolean
|
||||
isConverting: boolean
|
||||
isHeicFormat: boolean
|
||||
loadingProgress: number
|
||||
loadedBytes: number
|
||||
totalBytes: number
|
||||
conversionMessage?: string // 视频转换消息
|
||||
codecInfo?: string // 编码器信息
|
||||
}
|
||||
|
||||
interface LoadingIndicatorRef {
|
||||
updateLoadingState: (state: Partial<LoadingState>) => void
|
||||
resetLoadingState: () => void
|
||||
}
|
||||
|
||||
const initialLoadingState: LoadingState = {
|
||||
isVisible: false,
|
||||
isConverting: false,
|
||||
isHeicFormat: false,
|
||||
loadingProgress: 0,
|
||||
loadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
conversionMessage: undefined,
|
||||
codecInfo: undefined,
|
||||
}
|
||||
|
||||
export const LoadingIndicator = ({
|
||||
ref,
|
||||
..._
|
||||
}: {
|
||||
ref?: React.Ref<LoadingIndicatorRef | null>
|
||||
}) => {
|
||||
const [loadingState, setLoadingState] =
|
||||
useState<LoadingState>(initialLoadingState)
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
useCallback(
|
||||
() => ({
|
||||
updateLoadingState: (partialState: Partial<LoadingState>) => {
|
||||
setLoadingState((prev) => ({ ...prev, ...partialState }))
|
||||
},
|
||||
resetLoadingState: () => {
|
||||
setLoadingState(initialLoadingState)
|
||||
},
|
||||
}),
|
||||
[],
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{loadingState.isVisible && (
|
||||
<m.div
|
||||
className="pointer-events-none absolute right-4 bottom-4 z-10 rounded-xl border border-white/10 bg-black/80 px-3 py-2 backdrop-blur-sm"
|
||||
initial={{ opacity: 0, scale: 0.8, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.8, y: 20 }}
|
||||
transition={Spring.presets.snappy}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
<div className="relative">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
{loadingState.isConverting ? (
|
||||
<>
|
||||
<p className="text-xs font-medium text-white tabular-nums">
|
||||
{loadingState.conversionMessage || '转换中...'}
|
||||
</p>
|
||||
{loadingState.codecInfo && (
|
||||
<p className="text-xs text-white/70 tabular-nums">
|
||||
{loadingState.codecInfo}
|
||||
</p>
|
||||
)}
|
||||
<span className="text-xs text-white/60 tabular-nums">
|
||||
{Math.round(loadingState.loadingProgress)}%
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-medium text-white">
|
||||
{loadingState.isHeicFormat ? 'HEIC' : '加载中'}
|
||||
</p>
|
||||
<span className="text-xs text-white/60 tabular-nums">
|
||||
{Math.round(loadingState.loadingProgress)}%
|
||||
</span>
|
||||
</div>
|
||||
{loadingState.totalBytes > 0 && (
|
||||
<p className="text-xs text-white/70 tabular-nums">
|
||||
{(loadingState.loadedBytes / 1024 / 1024).toFixed(1)}MB /{' '}
|
||||
{(loadingState.totalBytes / 1024 / 1024).toFixed(1)}MB
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
LoadingIndicator.displayName = 'LoadingIndicator'
|
||||
|
||||
export type { LoadingIndicatorRef, LoadingState }
|
||||
87
apps/web/src/components/ui/photo-viewer/PhotoViewer.css
Normal file
@@ -0,0 +1,87 @@
|
||||
.contain-intrinsic-size {
|
||||
contain-intrinsic-size: 64px;
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
/* PhotoViewer custom styles */
|
||||
|
||||
/* Hide default Swiper navigation buttons since we use custom ones */
|
||||
.swiper-button-next,
|
||||
.swiper-button-prev {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Ensure Swiper slides don't interfere with zoom/pan gestures */
|
||||
.swiper-slide {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Custom navigation button hover effects */
|
||||
.swiper-button-prev-custom,
|
||||
.swiper-button-next-custom {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Ensure proper touch handling */
|
||||
.swiper {
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
.swiper-slide {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 768px) {
|
||||
/* Improve touch targets on mobile */
|
||||
.swiper-button-prev-custom,
|
||||
.swiper-button-next-custom {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Enable horizontal swiping on mobile */
|
||||
.swiper {
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
.swiper-slide {
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
/* Ensure double-tap zoom works on mobile */
|
||||
.react-transform-wrapper {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.react-transform-component {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Optimize ExifPanel for mobile */
|
||||
.exif-panel-mobile {
|
||||
/* Add safe area padding for devices with notches */
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* Improve thumbnail scrolling on mobile */
|
||||
.gallery-thumbnail-container {
|
||||
/* Add momentum scrolling for iOS */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
/* Hide scrollbar on mobile */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.gallery-thumbnail-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Improve accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.swiper-button-prev-custom,
|
||||
.swiper-button-next-custom {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
401
apps/web/src/components/ui/photo-viewer/PhotoViewer.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import './PhotoViewer.css'
|
||||
// Import Swiper styles
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/navigation'
|
||||
|
||||
import { AnimatePresence, m } from 'motion/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Blurhash } from 'react-blurhash'
|
||||
import type { Swiper as SwiperType } from 'swiper'
|
||||
import { Keyboard, Navigation, Virtual } from 'swiper/modules'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
|
||||
import { PassiveFragment } from '~/components/common/PassiveFragmenet'
|
||||
import { useMobile } from '~/hooks/useMobile'
|
||||
import { Spring } from '~/lib/spring'
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
import { ExifPanel } from './ExifPanel'
|
||||
import { GalleryThumbnail } from './GalleryThumbnail'
|
||||
import { ProgressiveImage } from './ProgressiveImage'
|
||||
import { SharePanel } from './SharePanel'
|
||||
|
||||
interface PhotoViewerProps {
|
||||
photos: PhotoManifest[]
|
||||
currentIndex: number
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onIndexChange: (index: number) => void
|
||||
}
|
||||
|
||||
export const PhotoViewer = ({
|
||||
photos,
|
||||
currentIndex,
|
||||
isOpen,
|
||||
onClose,
|
||||
onIndexChange,
|
||||
}: PhotoViewerProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const swiperRef = useRef<SwiperType | null>(null)
|
||||
const [isImageZoomed, setIsImageZoomed] = useState(false)
|
||||
const [showExifPanel, setShowExifPanel] = useState(false)
|
||||
const [currentBlobSrc, setCurrentBlobSrc] = useState<string | null>(null)
|
||||
const isMobile = useMobile()
|
||||
|
||||
const currentPhoto = photos[currentIndex]
|
||||
|
||||
// 当 PhotoViewer 关闭时重置缩放状态和面板状态
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setIsImageZoomed(false)
|
||||
setShowExifPanel(false)
|
||||
setCurrentBlobSrc(null)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// 计算图片的适配尺寸
|
||||
const getImageDisplaySize = () => {
|
||||
if (!currentPhoto) return { width: 0, height: 0 }
|
||||
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
// 在移动设备上调整最大尺寸
|
||||
const maxWidth = isMobile ? viewportWidth * 0.95 : viewportWidth * 0.9
|
||||
const maxHeight = isMobile ? viewportHeight * 0.8 : viewportHeight * 0.9
|
||||
|
||||
const imageAspectRatio = currentPhoto.width / currentPhoto.height
|
||||
const maxAspectRatio = maxWidth / maxHeight
|
||||
|
||||
let displayWidth: number
|
||||
let displayHeight: number
|
||||
|
||||
if (imageAspectRatio > maxAspectRatio) {
|
||||
// 图片更宽,以宽度为准
|
||||
displayWidth = Math.min(maxWidth, currentPhoto.width)
|
||||
displayHeight = displayWidth / imageAspectRatio
|
||||
} else {
|
||||
// 图片更高,以高度为准
|
||||
displayHeight = Math.min(maxHeight, currentPhoto.height)
|
||||
displayWidth = displayHeight * imageAspectRatio
|
||||
}
|
||||
|
||||
return { width: displayWidth, height: displayHeight }
|
||||
}
|
||||
|
||||
// 预加载相邻图片
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const preloadImage = (src: string) => {
|
||||
const img = new Image()
|
||||
img.src = src
|
||||
}
|
||||
|
||||
// 预加载前一张和后一张
|
||||
if (currentIndex > 0) {
|
||||
preloadImage(photos[currentIndex - 1].originalUrl)
|
||||
}
|
||||
if (currentIndex < photos.length - 1) {
|
||||
preloadImage(photos[currentIndex + 1].originalUrl)
|
||||
}
|
||||
}, [isOpen, currentIndex, photos])
|
||||
|
||||
const handlePrevious = useCallback(() => {
|
||||
if (currentIndex > 0) {
|
||||
onIndexChange(currentIndex - 1)
|
||||
}
|
||||
}, [currentIndex, onIndexChange])
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentIndex < photos.length - 1) {
|
||||
onIndexChange(currentIndex + 1)
|
||||
}
|
||||
}, [currentIndex, photos.length, onIndexChange])
|
||||
|
||||
// 同步 Swiper 的索引
|
||||
useEffect(() => {
|
||||
if (swiperRef.current && swiperRef.current.activeIndex !== currentIndex) {
|
||||
swiperRef.current.slideTo(currentIndex, 300)
|
||||
}
|
||||
// 切换图片时重置缩放状态
|
||||
setIsImageZoomed(false)
|
||||
}, [currentIndex])
|
||||
|
||||
// 当图片缩放状态改变时,控制 Swiper 的触摸行为
|
||||
useEffect(() => {
|
||||
if (swiperRef.current) {
|
||||
if (isImageZoomed) {
|
||||
// 图片被缩放时,禁用 Swiper 的触摸滑动
|
||||
swiperRef.current.allowTouchMove = false
|
||||
} else {
|
||||
// 图片未缩放时,启用 Swiper 的触摸滑动
|
||||
swiperRef.current.allowTouchMove = true
|
||||
}
|
||||
}
|
||||
}, [isImageZoomed])
|
||||
|
||||
// 处理图片缩放状态变化
|
||||
const handleZoomChange = useCallback((isZoomed: boolean) => {
|
||||
setIsImageZoomed(isZoomed)
|
||||
}, [])
|
||||
|
||||
// 处理 blobSrc 变化
|
||||
const handleBlobSrcChange = useCallback((blobSrc: string | null) => {
|
||||
setCurrentBlobSrc(blobSrc)
|
||||
}, [])
|
||||
|
||||
// 键盘导航
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft': {
|
||||
event.preventDefault()
|
||||
handlePrevious()
|
||||
break
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
event.preventDefault()
|
||||
handleNext()
|
||||
break
|
||||
}
|
||||
case 'Escape': {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, handlePrevious, handleNext, onClose, showExifPanel])
|
||||
|
||||
const imageSize = getImageDisplaySize()
|
||||
|
||||
if (!currentPhoto) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 固定背景层防止透出 */}
|
||||
{/* 交叉溶解的 Blurhash 背景 */}
|
||||
<AnimatePresence mode="popLayout">
|
||||
{isOpen && (
|
||||
<PassiveFragment>
|
||||
<m.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="bg-material-opaque fixed inset-0"
|
||||
/>
|
||||
<m.div
|
||||
key={currentPhoto.blurhash}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="fixed inset-0"
|
||||
>
|
||||
<Blurhash
|
||||
hash={currentPhoto.blurhash}
|
||||
width="100%"
|
||||
height="100%"
|
||||
resolutionX={32}
|
||||
resolutionY={32}
|
||||
punch={1}
|
||||
className="size-fill"
|
||||
/>
|
||||
</m.div>
|
||||
</PassiveFragment>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ touchAction: isMobile ? 'manipulation' : 'none' }}
|
||||
>
|
||||
<div
|
||||
className={`flex size-full ${isMobile ? 'flex-col' : 'flex-row'}`}
|
||||
>
|
||||
<div className="z-[1] flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div className="group relative flex min-h-0 min-w-0 flex-1">
|
||||
{/* 顶部工具栏 */}
|
||||
<m.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={`pointer-events-none absolute ${isMobile ? 'top-2 right-2 left-2' : 'top-4 right-4 left-4'} z-30 flex items-center justify-between`}
|
||||
>
|
||||
{/* 左侧工具按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 信息按钮 - 在移动设备上显示 */}
|
||||
{isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
className={`bg-material-ultra-thick pointer-events-auto flex size-8 items-center justify-center rounded-full text-white backdrop-blur-2xl duration-200 hover:bg-black/40 ${showExifPanel ? 'bg-accent' : ''}`}
|
||||
onClick={() => setShowExifPanel(!showExifPanel)}
|
||||
>
|
||||
<i className="i-mingcute-information-line" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧按钮组 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 分享按钮 */}
|
||||
<SharePanel
|
||||
photo={currentPhoto}
|
||||
blobSrc={currentBlobSrc || undefined}
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className="bg-material-ultra-thick pointer-events-auto flex size-8 items-center justify-center rounded-full text-white backdrop-blur-2xl duration-200 hover:bg-black/40"
|
||||
title="分享照片"
|
||||
>
|
||||
<i className="i-mingcute-share-2-line" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className="bg-material-ultra-thick pointer-events-auto flex size-8 items-center justify-center rounded-full text-white backdrop-blur-2xl duration-200 hover:bg-black/40"
|
||||
onClick={onClose}
|
||||
>
|
||||
<i className="i-mingcute-close-line" />
|
||||
</button>
|
||||
</div>
|
||||
</m.div>
|
||||
|
||||
{/* Swiper 容器 */}
|
||||
<Swiper
|
||||
modules={[Navigation, Keyboard, Virtual]}
|
||||
spaceBetween={0}
|
||||
slidesPerView={1}
|
||||
initialSlide={currentIndex}
|
||||
virtual
|
||||
keyboard={{
|
||||
enabled: true,
|
||||
onlyInViewport: true,
|
||||
}}
|
||||
navigation={{
|
||||
prevEl: '.swiper-button-prev-custom',
|
||||
nextEl: '.swiper-button-next-custom',
|
||||
}}
|
||||
onSwiper={(swiper) => {
|
||||
swiperRef.current = swiper
|
||||
// 初始化时确保触摸滑动是启用的
|
||||
swiper.allowTouchMove = !isImageZoomed
|
||||
}}
|
||||
onSlideChange={(swiper) => {
|
||||
onIndexChange(swiper.activeIndex)
|
||||
}}
|
||||
className="h-full w-full"
|
||||
style={{ touchAction: isMobile ? 'pan-x' : 'pan-y' }}
|
||||
>
|
||||
{photos.map((photo, index) => {
|
||||
const isCurrentImage = index === currentIndex
|
||||
return (
|
||||
<SwiperSlide
|
||||
key={photo.id}
|
||||
className="flex items-center justify-center"
|
||||
virtualIndex={index}
|
||||
>
|
||||
<m.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="relative flex h-full w-full items-center justify-center"
|
||||
>
|
||||
<ProgressiveImage
|
||||
isCurrentImage={isCurrentImage}
|
||||
src={photo.originalUrl}
|
||||
thumbnailSrc={photo.thumbnailUrl}
|
||||
alt={photo.title}
|
||||
width={
|
||||
isCurrentImage ? imageSize.width : undefined
|
||||
}
|
||||
height={
|
||||
isCurrentImage ? imageSize.height : undefined
|
||||
}
|
||||
className="h-full w-full object-contain"
|
||||
enablePan={
|
||||
isCurrentImage
|
||||
? !isMobile || isImageZoomed
|
||||
: true
|
||||
}
|
||||
enableZoom={true}
|
||||
onZoomChange={
|
||||
isCurrentImage ? handleZoomChange : undefined
|
||||
}
|
||||
onBlobSrcChange={
|
||||
isCurrentImage ? handleBlobSrcChange : undefined
|
||||
}
|
||||
// Live Photo props
|
||||
isLivePhoto={photo.isLivePhoto}
|
||||
livePhotoVideoUrl={photo.livePhotoVideoUrl}
|
||||
/>
|
||||
</m.div>
|
||||
</SwiperSlide>
|
||||
)
|
||||
})}
|
||||
</Swiper>
|
||||
|
||||
{/* 自定义导航按钮 */}
|
||||
{currentIndex > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={`swiper-button-prev-custom absolute ${isMobile ? 'left-2' : 'left-4'} top-1/2 z-20 flex -translate-y-1/2 items-center justify-center ${isMobile ? 'size-8' : 'size-10'} bg-material-medium rounded-full text-white opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100 hover:bg-black/40`}
|
||||
onClick={handlePrevious}
|
||||
>
|
||||
<i
|
||||
className={`i-mingcute-left-line ${isMobile ? 'text-lg' : 'text-xl'}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentIndex < photos.length - 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className={`swiper-button-next-custom absolute ${isMobile ? 'right-2' : 'right-4'} top-1/2 z-20 flex -translate-y-1/2 items-center justify-center ${isMobile ? 'size-8' : 'size-10'} bg-material-medium rounded-full text-white opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100 hover:bg-black/40`}
|
||||
>
|
||||
<i
|
||||
className={`i-mingcute-right-line ${isMobile ? 'text-lg' : 'text-xl'}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GalleryThumbnail
|
||||
currentIndex={currentIndex}
|
||||
photos={photos}
|
||||
onIndexChange={onIndexChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ExifPanel - 在桌面端始终显示,在移动端根据状态显示 */}
|
||||
|
||||
{(!isMobile || showExifPanel) && (
|
||||
<ExifPanel
|
||||
currentPhoto={currentPhoto}
|
||||
exifData={currentPhoto.exif}
|
||||
onClose={isMobile ? () => setShowExifPanel(false) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
321
apps/web/src/components/ui/photo-viewer/ProgressiveImage.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import type { WebGLImageViewerRef } from '@photo-gallery/webgl-viewer'
|
||||
import { WebGLImageViewer } from '@photo-gallery/webgl-viewer'
|
||||
import { m, useAnimationControls } from 'motion/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
MenuItemSeparator,
|
||||
MenuItemText,
|
||||
useShowContextMenu,
|
||||
} from '~/atoms/context-menu'
|
||||
import { clsxm } from '~/lib/cn'
|
||||
import { canUseWebGL } from '~/lib/feature'
|
||||
import { ImageLoaderManager } from '~/lib/image-loader-manager'
|
||||
import { Spring } from '~/lib/spring'
|
||||
|
||||
import { LivePhoto } from './LivePhoto'
|
||||
import type { LoadingIndicatorRef } from './LoadingIndicator'
|
||||
import { LoadingIndicator } from './LoadingIndicator'
|
||||
|
||||
interface ProgressiveImageProps {
|
||||
src: string
|
||||
thumbnailSrc?: string
|
||||
|
||||
alt: string
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
onError?: () => void
|
||||
onProgress?: (progress: number) => void
|
||||
onZoomChange?: (isZoomed: boolean) => void
|
||||
onBlobSrcChange?: (blobSrc: string | null) => void
|
||||
|
||||
enableZoom?: boolean
|
||||
enablePan?: boolean
|
||||
maxZoom?: number
|
||||
minZoom?: number
|
||||
|
||||
isCurrentImage?: boolean
|
||||
|
||||
// Live Photo 相关 props
|
||||
isLivePhoto?: boolean
|
||||
livePhotoVideoUrl?: string
|
||||
}
|
||||
|
||||
export const ProgressiveImage = ({
|
||||
src,
|
||||
thumbnailSrc,
|
||||
|
||||
alt,
|
||||
className,
|
||||
|
||||
onError,
|
||||
onProgress,
|
||||
onZoomChange,
|
||||
onBlobSrcChange,
|
||||
|
||||
maxZoom = 20,
|
||||
minZoom = 1,
|
||||
isCurrentImage = false,
|
||||
|
||||
// Live Photo props
|
||||
isLivePhoto = false,
|
||||
livePhotoVideoUrl,
|
||||
}: ProgressiveImageProps) => {
|
||||
const [blobSrc, setBlobSrc] = useState<string | null>(null)
|
||||
const [highResLoaded, setHighResLoaded] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
const thumbnailRef = useRef<HTMLImageElement>(null)
|
||||
const transformRef = useRef<WebGLImageViewerRef>(null)
|
||||
const thumbnailAnimateController = useAnimationControls()
|
||||
const loadingIndicatorRef = useRef<LoadingIndicatorRef>(null)
|
||||
const imageLoaderManagerRef = useRef<ImageLoaderManager | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (highResLoaded || error || !isCurrentImage) return
|
||||
|
||||
// Create new image loader manager
|
||||
const imageLoaderManager = new ImageLoaderManager()
|
||||
imageLoaderManagerRef.current = imageLoaderManager
|
||||
|
||||
function cleanup() {
|
||||
setHighResLoaded(false)
|
||||
setBlobSrc(null)
|
||||
setError(false)
|
||||
onBlobSrcChange?.(null)
|
||||
|
||||
// Reset loading indicator
|
||||
loadingIndicatorRef.current?.resetLoadingState()
|
||||
|
||||
// Reset transform when image changes
|
||||
if (transformRef.current) {
|
||||
transformRef.current.resetView()
|
||||
}
|
||||
}
|
||||
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
const result = await imageLoaderManager.loadImage(src, {
|
||||
onProgress,
|
||||
onError,
|
||||
onLoadingStateUpdate: (state) => {
|
||||
loadingIndicatorRef.current?.updateLoadingState(state)
|
||||
},
|
||||
})
|
||||
|
||||
setBlobSrc(result.blobSrc)
|
||||
onBlobSrcChange?.(result.blobSrc)
|
||||
setHighResLoaded(true)
|
||||
} catch (loadError) {
|
||||
console.error('Failed to load image:', loadError)
|
||||
setError(true)
|
||||
}
|
||||
}
|
||||
|
||||
cleanup()
|
||||
loadImage()
|
||||
|
||||
return () => {
|
||||
imageLoaderManager.cleanup()
|
||||
}
|
||||
}, [
|
||||
highResLoaded,
|
||||
error,
|
||||
onProgress,
|
||||
src,
|
||||
onError,
|
||||
isCurrentImage,
|
||||
onBlobSrcChange,
|
||||
])
|
||||
|
||||
const onTransformed = useCallback(
|
||||
(originalScale: number, relativeScale: number) => {
|
||||
const isZoomed = Math.abs(relativeScale - 1) > 0.01
|
||||
|
||||
onZoomChange?.(isZoomed)
|
||||
},
|
||||
[onZoomChange],
|
||||
)
|
||||
|
||||
const handleThumbnailLoad = useCallback(() => {
|
||||
thumbnailAnimateController.start({
|
||||
opacity: 1,
|
||||
})
|
||||
}, [thumbnailAnimateController])
|
||||
|
||||
const showContextMenu = useShowContextMenu()
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={clsxm(
|
||||
'flex items-center justify-center bg-material-opaque',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="text-text-secondary text-center">
|
||||
<i className="i-mingcute-image-line mb-2 text-4xl" />
|
||||
<p className="text-sm">图片加载失败</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsxm('relative overflow-hidden', className)}>
|
||||
{/* 缩略图 */}
|
||||
{thumbnailSrc && (
|
||||
<m.img
|
||||
ref={thumbnailRef}
|
||||
initial={{ opacity: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
src={thumbnailSrc}
|
||||
key={thumbnailSrc}
|
||||
alt={alt}
|
||||
transition={Spring.presets.smooth}
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
animate={thumbnailAnimateController}
|
||||
onLoad={handleThumbnailLoad}
|
||||
/>
|
||||
)}
|
||||
|
||||
{highResLoaded && blobSrc && (
|
||||
<WebGLImageViewer
|
||||
ref={transformRef}
|
||||
src={blobSrc}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
initialScale={1}
|
||||
minScale={minZoom}
|
||||
maxScale={maxZoom}
|
||||
limitToBounds={true}
|
||||
centerOnInit={true}
|
||||
smooth={true}
|
||||
onZoomChange={onTransformed}
|
||||
debug={import.meta.env.DEV}
|
||||
onContextMenu={(e) =>
|
||||
showContextMenu(
|
||||
[
|
||||
new MenuItemText({
|
||||
label: '复制图片',
|
||||
click: async () => {
|
||||
const loadingToast = toast.loading('正在复制图片...')
|
||||
|
||||
try {
|
||||
// Create a canvas to convert the image to PNG
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve
|
||||
img.onerror = reject
|
||||
img.src = blobSrc
|
||||
})
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
|
||||
ctx?.drawImage(img, 0, 0)
|
||||
|
||||
// Convert to PNG blob
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
canvas.toBlob(async (pngBlob) => {
|
||||
try {
|
||||
if (pngBlob) {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'image/png': pngBlob,
|
||||
}),
|
||||
])
|
||||
resolve()
|
||||
} else {
|
||||
reject(
|
||||
new Error('Failed to convert image to PNG'),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}, 'image/png')
|
||||
})
|
||||
|
||||
toast.dismiss(loadingToast)
|
||||
toast.success('图片已复制到剪贴板')
|
||||
} catch (error) {
|
||||
console.error('Failed to copy image:', error)
|
||||
|
||||
// Fallback: try to copy the original blob
|
||||
try {
|
||||
const blob = await fetch(blobSrc).then((res) =>
|
||||
res.blob(),
|
||||
)
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob,
|
||||
}),
|
||||
])
|
||||
toast.dismiss(loadingToast)
|
||||
toast.success('图片已复制到剪贴板')
|
||||
} catch (fallbackError) {
|
||||
console.error(
|
||||
'Fallback copy also failed:',
|
||||
fallbackError,
|
||||
)
|
||||
toast.dismiss(loadingToast)
|
||||
toast.error('复制图片失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
MenuItemSeparator.default,
|
||||
new MenuItemText({
|
||||
label: '下载图片',
|
||||
click: () => {
|
||||
const a = document.createElement('a')
|
||||
a.href = blobSrc
|
||||
a.download = alt
|
||||
a.click()
|
||||
},
|
||||
}),
|
||||
],
|
||||
e,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Live Photo 组件 */}
|
||||
{isLivePhoto && livePhotoVideoUrl && imageLoaderManagerRef.current && (
|
||||
<LivePhoto
|
||||
videoUrl={livePhotoVideoUrl}
|
||||
imageLoaderManager={imageLoaderManagerRef.current}
|
||||
loadingIndicatorRef={loadingIndicatorRef}
|
||||
isCurrentImage={isCurrentImage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 备用图片(当 WebGL 不可用时) */}
|
||||
{!canUseWebGL && highResLoaded && blobSrc && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-black/20">
|
||||
<i className="i-mingcute-warning-line mb-2 text-4xl" />
|
||||
<span className="text-center text-sm text-white">
|
||||
WebGL 不可用,无法渲染图片
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载指示器 */}
|
||||
<LoadingIndicator ref={loadingIndicatorRef} />
|
||||
|
||||
{/* 操作提示 */}
|
||||
{!isLivePhoto && (
|
||||
<div className="pointer-events-none absolute bottom-4 left-1/2 z-20 -translate-x-1/2 rounded bg-black/50 px-2 py-1 text-xs text-white opacity-0 duration-200 group-hover:opacity-50">
|
||||
双击或双指缩放
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
289
apps/web/src/components/ui/photo-viewer/SharePanel.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { AnimatePresence, m } from 'motion/react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { clsxm } from '~/lib/cn'
|
||||
import { Spring } from '~/lib/spring'
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
interface SharePanelProps {
|
||||
photo: PhotoManifest
|
||||
trigger: React.ReactNode
|
||||
blobSrc?: string
|
||||
}
|
||||
|
||||
interface ShareOption {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
action: () => Promise<void> | void
|
||||
color?: string
|
||||
bgColor?: string
|
||||
}
|
||||
|
||||
interface SocialShareOption {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
url: string
|
||||
color: string
|
||||
bgColor: string
|
||||
}
|
||||
|
||||
// 社交媒体分享选项
|
||||
const socialOptions: SocialShareOption[] = [
|
||||
{
|
||||
id: 'twitter',
|
||||
label: 'Twitter',
|
||||
icon: 'i-mingcute-twitter-fill',
|
||||
url: 'https://twitter.com/intent/tweet?text={text}&url={url}',
|
||||
color: 'text-white',
|
||||
bgColor: 'bg-sky-500',
|
||||
},
|
||||
{
|
||||
id: 'facebook',
|
||||
label: 'Facebook',
|
||||
icon: 'i-mingcute-facebook-line',
|
||||
url: 'https://www.facebook.com/sharer/sharer.php?u={url}',
|
||||
color: 'text-white',
|
||||
bgColor: 'bg-[#1877F2]',
|
||||
},
|
||||
{
|
||||
id: 'telegram',
|
||||
label: 'Telegram',
|
||||
icon: 'i-mingcute-telegram-line',
|
||||
url: 'https://t.me/share/url?url={url}&text={text}',
|
||||
color: 'text-white',
|
||||
bgColor: 'bg-[#0088CC]',
|
||||
},
|
||||
{
|
||||
id: 'weibo',
|
||||
label: '微博',
|
||||
icon: 'i-mingcute-weibo-line',
|
||||
url: 'https://service.weibo.com/share/share.php?url={url}&title={text}',
|
||||
color: 'text-white',
|
||||
bgColor: 'bg-[#E6162D]',
|
||||
},
|
||||
]
|
||||
|
||||
export const SharePanel = ({ photo, trigger, blobSrc }: SharePanelProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const handleNativeShare = useCallback(async () => {
|
||||
const shareUrl = window.location.href
|
||||
const shareTitle = photo.title || '照片分享'
|
||||
const shareText = `查看这张精美的照片:${shareTitle}`
|
||||
|
||||
try {
|
||||
// 优先使用 blobSrc(转换后的图片),如果没有则使用 originalUrl
|
||||
const imageUrl = blobSrc || photo.originalUrl
|
||||
const response = await fetch(imageUrl)
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], `${photo.title || 'photo'}.jpg`, {
|
||||
type: blob.type || 'image/jpeg',
|
||||
})
|
||||
|
||||
// 检查是否支持文件分享
|
||||
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({
|
||||
title: shareTitle,
|
||||
text: shareText,
|
||||
url: shareUrl,
|
||||
files: [file],
|
||||
})
|
||||
} else {
|
||||
// 不支持文件分享,只分享链接
|
||||
await navigator.share({
|
||||
title: shareTitle,
|
||||
text: shareText,
|
||||
url: shareUrl,
|
||||
})
|
||||
}
|
||||
setIsOpen(false)
|
||||
} catch {
|
||||
// 如果分享失败,复制链接
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
toast.success('链接已复制到剪贴板')
|
||||
setIsOpen(false)
|
||||
}
|
||||
}, [photo.title, blobSrc, photo.originalUrl])
|
||||
|
||||
const handleCopyLink = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
toast.success('链接已复制到剪贴板')
|
||||
setIsOpen(false)
|
||||
} catch {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSocialShare = useCallback(
|
||||
(url: string) => {
|
||||
const shareUrl = encodeURIComponent(window.location.href)
|
||||
const shareTitle = encodeURIComponent(photo.title || '照片分享')
|
||||
const shareText = encodeURIComponent(
|
||||
`查看这张精美的照片:${photo.title || '照片分享'}`,
|
||||
)
|
||||
|
||||
const finalUrl = url
|
||||
.replace('{url}', shareUrl)
|
||||
.replace('{title}', shareTitle)
|
||||
.replace('{text}', shareText)
|
||||
|
||||
window.open(finalUrl, '_blank', 'width=600,height=400')
|
||||
setIsOpen(false)
|
||||
},
|
||||
[photo.title],
|
||||
)
|
||||
|
||||
// 功能选项
|
||||
const actionOptions: ShareOption[] = [
|
||||
...(typeof navigator !== 'undefined' && 'share' in navigator
|
||||
? [
|
||||
{
|
||||
id: 'native-share',
|
||||
label: '系统分享',
|
||||
icon: 'i-mingcute-share-2-line',
|
||||
action: handleNativeShare,
|
||||
color: 'text-blue-500',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'copy-link',
|
||||
label: '复制链接',
|
||||
icon: 'i-mingcute-link-line',
|
||||
action: handleCopyLink,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuPrimitive.Trigger asChild>
|
||||
{trigger}
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DropdownMenuPrimitive.Portal forceMount>
|
||||
<DropdownMenuPrimitive.Content
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="z-[10000] min-w-[280px] will-change-[opacity,transform]"
|
||||
asChild
|
||||
>
|
||||
<m.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className={clsxm(
|
||||
'rounded-2xl border border-border/10 p-4',
|
||||
'bg-material-ultra-thick backdrop-blur-[70px]',
|
||||
'shadow-2xl shadow-black/20',
|
||||
'dark:shadow-black/50',
|
||||
)}
|
||||
>
|
||||
{/* 标题区域 */}
|
||||
<div className="mb-4 text-center">
|
||||
<h3 className="text-text font-semibold">分享照片</h3>
|
||||
{photo.title && (
|
||||
<p className="text-text-secondary mt-1 line-clamp-1 text-sm">
|
||||
{photo.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 社交媒体分享 - 第一排 */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-text-secondary text-xs font-medium tracking-wide uppercase">
|
||||
社交媒体
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex justify-center gap-4">
|
||||
{socialOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className="group flex flex-col items-center gap-2"
|
||||
onClick={() => handleSocialShare(option.url)}
|
||||
>
|
||||
<div
|
||||
className={clsxm(
|
||||
'flex size-12 items-center justify-center rounded-full transition-all duration-200',
|
||||
option.bgColor,
|
||||
'group-hover:scale-110 group-active:scale-95',
|
||||
'shadow-lg',
|
||||
)}
|
||||
>
|
||||
<i
|
||||
className={clsxm(
|
||||
option.icon,
|
||||
'size-5',
|
||||
option.color,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-text-secondary text-xs font-medium">
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 功能选项 - 第二排 */}
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<h4 className="text-text-secondary text-xs font-medium tracking-wide uppercase">
|
||||
操作
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{actionOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={clsxm(
|
||||
'relative flex cursor-pointer select-none items-center rounded-lg px-2 py-2',
|
||||
'text-sm outline-none transition-all duration-200',
|
||||
'hover:bg-fill-secondary/80 active:bg-fill-secondary',
|
||||
'group',
|
||||
)}
|
||||
onClick={() => option.action()}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={clsxm(
|
||||
'flex size-7 items-center justify-center rounded-full',
|
||||
'bg-fill-tertiary/80 group-hover:bg-fill-tertiary',
|
||||
'transition-colors duration-200',
|
||||
)}
|
||||
>
|
||||
<i
|
||||
className={clsxm(
|
||||
option.icon,
|
||||
'size-3.5',
|
||||
option.color || 'text-text-secondary',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-text text-xs font-medium">
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
)
|
||||
}
|
||||
5
apps/web/src/components/ui/photo-viewer/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './ExifPanel'
|
||||
export * from './GalleryThumbnail'
|
||||
export * from './PhotoViewer'
|
||||
export * from './ProgressiveImage'
|
||||
export * from './SharePanel'
|
||||
16
apps/web/src/components/ui/portal/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { useRootPortal } from './provider'
|
||||
|
||||
export const RootPortal: FC<
|
||||
{
|
||||
to?: HTMLElement
|
||||
} & PropsWithChildren
|
||||
> = (props) => {
|
||||
const to = useRootPortal()
|
||||
|
||||
return createPortal(props.children, props.to || to || document.body)
|
||||
}
|
||||
15
apps/web/src/components/ui/portal/provider.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createContext, use } from 'react'
|
||||
|
||||
export const useRootPortal = () => {
|
||||
const ctx = use(RootPortalContext)
|
||||
|
||||
return ctx.to || document.body
|
||||
}
|
||||
|
||||
const RootPortalContext = createContext<{
|
||||
to?: HTMLElement | undefined
|
||||
}>({
|
||||
to: undefined,
|
||||
})
|
||||
|
||||
export const RootPortalProvider = RootPortalContext
|
||||
180
apps/web/src/components/ui/scroll-areas/ScrollArea.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import * as ScrollAreaBase from '@radix-ui/react-scroll-area'
|
||||
import clsx from 'clsx'
|
||||
import * as React from 'react'
|
||||
|
||||
import { clsxm } from '~/lib/cn'
|
||||
|
||||
import { ScrollElementContext } from './ctx'
|
||||
|
||||
const Corner = ({
|
||||
ref: forwardedRef,
|
||||
className,
|
||||
...rest
|
||||
}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Corner> & {
|
||||
ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Corner> | null>
|
||||
}) => (
|
||||
<ScrollAreaBase.Corner
|
||||
{...rest}
|
||||
ref={forwardedRef}
|
||||
className={clsx('bg-accent', className)}
|
||||
/>
|
||||
)
|
||||
|
||||
Corner.displayName = 'ScrollArea.Corner'
|
||||
|
||||
const Thumb = ({
|
||||
ref: forwardedRef,
|
||||
className,
|
||||
...rest
|
||||
}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Thumb> & {
|
||||
ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Thumb> | null>
|
||||
}) => (
|
||||
<ScrollAreaBase.Thumb
|
||||
{...rest}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
rest.onClick?.(e)
|
||||
}}
|
||||
ref={forwardedRef}
|
||||
className={clsxm(
|
||||
'relative w-full flex-1 rounded-xl transition-colors duration-150',
|
||||
'bg-fill-secondary hover:bg-fill',
|
||||
'active:bg-control-enabled',
|
||||
'before:absolute before:-left-1/2 before:-top-1/2 before:h-full before:min-h-[44]',
|
||||
'before:w-full before:min-w-[44] before:-translate-x-full before:-translate-y-full before:content-[""]',
|
||||
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
Thumb.displayName = 'ScrollArea.Thumb'
|
||||
|
||||
const Scrollbar = ({
|
||||
ref: forwardedRef,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Scrollbar> & {
|
||||
ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Scrollbar> | null>
|
||||
}) => {
|
||||
const { orientation = 'vertical' } = rest
|
||||
return (
|
||||
<ScrollAreaBase.Scrollbar
|
||||
{...rest}
|
||||
ref={forwardedRef}
|
||||
className={clsxm(
|
||||
'flex w-2.5 touch-none select-none p-0.5',
|
||||
orientation === 'horizontal'
|
||||
? `h-2.5 w-full flex-col`
|
||||
: `w-2.5 flex-row`,
|
||||
'animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<Thumb />
|
||||
</ScrollAreaBase.Scrollbar>
|
||||
)
|
||||
}
|
||||
Scrollbar.displayName = 'ScrollArea.Scrollbar'
|
||||
|
||||
const Viewport = ({
|
||||
ref: forwardedRef,
|
||||
className,
|
||||
|
||||
focusable = true,
|
||||
...rest
|
||||
}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Viewport> & {
|
||||
mask?: boolean
|
||||
focusable?: boolean
|
||||
} & {
|
||||
ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Viewport> | null>
|
||||
}) => {
|
||||
const ref = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useImperativeHandle(forwardedRef, () => ref.current as HTMLDivElement)
|
||||
return (
|
||||
<ScrollAreaBase.Viewport
|
||||
{...rest}
|
||||
ref={ref}
|
||||
tabIndex={focusable ? -1 : void 0}
|
||||
className={clsxm(
|
||||
'block size-full',
|
||||
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Viewport.displayName = 'ScrollArea.Viewport'
|
||||
|
||||
const Root = ({
|
||||
ref: forwardedRef,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Root> & {
|
||||
ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Root> | null>
|
||||
}) => (
|
||||
<ScrollAreaBase.Root
|
||||
{...rest}
|
||||
scrollHideDelay={0}
|
||||
ref={forwardedRef}
|
||||
className={clsxm('overflow-hidden', className)}
|
||||
>
|
||||
{children}
|
||||
<Corner />
|
||||
</ScrollAreaBase.Root>
|
||||
)
|
||||
|
||||
Root.displayName = 'ScrollArea.Root'
|
||||
export const ScrollArea = ({
|
||||
ref,
|
||||
flex,
|
||||
children,
|
||||
rootClassName,
|
||||
viewportClassName,
|
||||
scrollbarClassName,
|
||||
mask = false,
|
||||
onScroll,
|
||||
orientation = 'vertical',
|
||||
asChild = false,
|
||||
|
||||
focusable = true,
|
||||
}: React.PropsWithChildren & {
|
||||
rootClassName?: string
|
||||
viewportClassName?: string
|
||||
scrollbarClassName?: string
|
||||
flex?: boolean
|
||||
mask?: boolean
|
||||
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void
|
||||
orientation?: 'vertical' | 'horizontal'
|
||||
asChild?: boolean
|
||||
focusable?: boolean
|
||||
} & { ref?: React.Ref<HTMLDivElement | null> }) => {
|
||||
const [viewportRef, setViewportRef] = React.useState<HTMLDivElement | null>(
|
||||
null,
|
||||
)
|
||||
React.useImperativeHandle(ref, () => viewportRef as HTMLDivElement)
|
||||
|
||||
return (
|
||||
<ScrollElementContext value={viewportRef}>
|
||||
<Root className={rootClassName}>
|
||||
<Viewport
|
||||
ref={setViewportRef}
|
||||
className={clsxm(
|
||||
flex ? '[&>div]:!flex [&>div]:!flex-col' : '',
|
||||
viewportClassName,
|
||||
)}
|
||||
mask={mask}
|
||||
asChild={asChild}
|
||||
onScroll={onScroll}
|
||||
focusable={focusable}
|
||||
>
|
||||
{children}
|
||||
</Viewport>
|
||||
<Scrollbar orientation={orientation} className={scrollbarClassName} />
|
||||
</Root>
|
||||
</ScrollElementContext>
|
||||
)
|
||||
}
|
||||
5
apps/web/src/components/ui/scroll-areas/ctx.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
export const ScrollElementContext = createContext<HTMLElement | null>(
|
||||
document.documentElement,
|
||||
)
|
||||
9
apps/web/src/components/ui/scroll-areas/hooks.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { use } from 'react'
|
||||
|
||||
import { ScrollElementContext } from './ctx'
|
||||
|
||||
/**
|
||||
* Get the scroll area element when in radix scroll area
|
||||
* @returns
|
||||
*/
|
||||
export const useScrollViewElement = () => use(ScrollElementContext)
|
||||
9
apps/web/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Toaster as Sonner } from 'sonner'
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => (
|
||||
<Sonner theme="dark" {...props} />
|
||||
)
|
||||
|
||||
export { Toaster }
|
||||
51
apps/web/src/components/ui/tooltip/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
import { m } from 'motion/react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { clsxm } from '~/lib/cn'
|
||||
import { Spring } from '~/lib/spring'
|
||||
|
||||
import { tooltipStyle } from './styles'
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
const TooltipRoot = TooltipPrimitive.Root
|
||||
|
||||
const Tooltip: typeof TooltipProvider = ({ children, ...props }) => (
|
||||
<TooltipProvider {...props}>
|
||||
<TooltipPrimitive.Tooltip>{children}</TooltipPrimitive.Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = ({
|
||||
ref,
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
|
||||
ref?: React.Ref<React.ElementRef<typeof TooltipPrimitive.Content> | null>
|
||||
}) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
asChild
|
||||
sideOffset={sideOffset}
|
||||
className={clsxm(tooltipStyle.content, className)}
|
||||
{...props}
|
||||
>
|
||||
<m.div
|
||||
initial={{ opacity: 0.82, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={Spring.presets.smooth}
|
||||
>
|
||||
{/* https://github.com/radix-ui/primitives/discussions/868 */}
|
||||
<TooltipPrimitive.Arrow className="z-50 fill-white [clip-path:inset(0_-10px_-10px_-10px)] dark:fill-neutral-950 dark:drop-shadow-[0_0_1px_theme(colors.white/0.5)]" />
|
||||
{props.children}
|
||||
</m.div>
|
||||
</TooltipPrimitive.Content>
|
||||
)
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipRoot, TooltipTrigger }
|
||||
|
||||
export { RootPortal as TooltipPortal } from '../portal'
|
||||
10
apps/web/src/components/ui/tooltip/styles.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const tooltipStyle = {
|
||||
content: [
|
||||
'relative z-[101] bg-white px-2 py-1 text-text dark:bg-neutral-950',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0',
|
||||
'rounded-lg text-sm',
|
||||
'max-w-[75ch] select-text',
|
||||
'drop-shadow data-[side=top]:shadow-tooltip-bottom data-[side=bottom]:shadow-tooltip-top',
|
||||
'dark:drop-shadow-[0_0_1px_theme(colors.white/0.5)]',
|
||||
],
|
||||
}
|
||||
101
apps/web/src/components/ui/typography/EllipsisWithTooltip.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { clsxm } from '~/lib/cn'
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipTrigger,
|
||||
} from '../tooltip'
|
||||
|
||||
const isTextOverflowed = (element: HTMLElement, dir: 'h' | 'v') => {
|
||||
if (dir === 'h') {
|
||||
return element.offsetWidth < element.scrollWidth
|
||||
} else {
|
||||
return element.offsetHeight < element.scrollHeight
|
||||
}
|
||||
}
|
||||
type EllipsisProps = PropsWithChildren<{
|
||||
width?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
dir?: 'h' | 'v'
|
||||
}>
|
||||
|
||||
export const EllipsisTextWithTooltip = (props: EllipsisProps) => {
|
||||
const { children, className, width, disabled, dir = 'v' } = props
|
||||
|
||||
const [textElRef, setTextElRef] = useState<HTMLSpanElement | null>()
|
||||
const [isOverflowed, setIsOverflowed] = useState(false)
|
||||
|
||||
const judgment = () => {
|
||||
if (!textElRef) return
|
||||
|
||||
setIsOverflowed(isTextOverflowed(textElRef, dir))
|
||||
}
|
||||
useEffect(() => {
|
||||
judgment()
|
||||
}, [textElRef, children])
|
||||
|
||||
useEffect(() => {
|
||||
if (!textElRef) return
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
judgment()
|
||||
})
|
||||
resizeObserver.observe(textElRef)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [textElRef])
|
||||
|
||||
const Content = (
|
||||
<span
|
||||
className={className}
|
||||
ref={setTextElRef}
|
||||
style={
|
||||
width
|
||||
? {
|
||||
maxWidth: width,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (!isOverflowed || disabled) return Content
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{Content}</TooltipTrigger>
|
||||
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
<span
|
||||
className="break-all whitespace-pre-line"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show ellipses when horizontal text overflows and full text on hover.
|
||||
*/
|
||||
export const EllipsisHorizontalTextWithTooltip = (props: EllipsisProps) => {
|
||||
const { className, ...rest } = props
|
||||
return (
|
||||
<EllipsisTextWithTooltip
|
||||
className={clsxm('block truncate', className)}
|
||||
{...rest}
|
||||
dir="h"
|
||||
/>
|
||||
)
|
||||
}
|
||||
1
apps/web/src/components/ui/typography/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './EllipsisWithTooltip'
|
||||
153
apps/web/src/core/README.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Photo Gallery Core 架构
|
||||
|
||||
这是照片库构建系统的核心模块,采用模块化设计,将不同功能分离到各自的模块中。
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
src/core/
|
||||
├── types/ # 类型定义
|
||||
│ └── photo.ts # 照片相关类型
|
||||
├── logger/ # 日志系统
|
||||
│ └── index.ts # 统一日志器
|
||||
├── s3/ # S3 存储操作
|
||||
│ ├── client.ts # S3 客户端配置
|
||||
│ └── operations.ts # S3 操作(上传、下载、列表)
|
||||
├── image/ # 图像处理
|
||||
│ ├── processor.ts # 图像预处理和元数据
|
||||
│ ├── blurhash.ts # Blurhash 生成
|
||||
│ ├── thumbnail.ts # 缩略图生成
|
||||
│ └── exif.ts # EXIF 数据提取
|
||||
├── photo/ # 照片处理
|
||||
│ ├── info-extractor.ts # 照片信息提取
|
||||
│ └── processor.ts # 照片处理主逻辑
|
||||
├── manifest/ # Manifest 管理
|
||||
│ └── manager.ts # Manifest 读写和管理
|
||||
├── worker/ # 并发处理
|
||||
│ └── pool.ts # Worker 池管理
|
||||
├── builder/ # 主构建器
|
||||
│ └── index.ts # 构建流程编排
|
||||
└── index.ts # 模块入口
|
||||
```
|
||||
|
||||
## 模块说明
|
||||
|
||||
### 1. 类型定义 (`types/`)
|
||||
- `PhotoInfo`: 照片基本信息
|
||||
- `ImageMetadata`: 图像元数据
|
||||
- `PhotoManifestItem`: Manifest 项目
|
||||
- `ProcessPhotoResult`: 处理结果
|
||||
- `ThumbnailResult`: 缩略图生成结果
|
||||
|
||||
### 2. 日志系统 (`logger/`)
|
||||
- 统一的日志管理
|
||||
- 支持不同模块的标签化日志
|
||||
- Worker 专用日志器
|
||||
|
||||
### 3. S3 存储操作 (`s3/`)
|
||||
- **client.ts**: S3 客户端配置和连接
|
||||
- **operations.ts**: 图片下载、列表获取、URL 生成
|
||||
|
||||
### 4. 图像处理 (`image/`)
|
||||
- **processor.ts**: 图像预处理、HEIC 转换、元数据提取
|
||||
- **blurhash.ts**: Blurhash 生成算法
|
||||
- **thumbnail.ts**: 缩略图生成和管理
|
||||
- **exif.ts**: EXIF 数据提取和清理
|
||||
|
||||
### 5. 照片处理 (`photo/`)
|
||||
- **info-extractor.ts**: 从文件名和 EXIF 提取照片信息
|
||||
- **processor.ts**: 照片处理主流程,整合所有处理步骤
|
||||
|
||||
### 6. Manifest 管理 (`manifest/`)
|
||||
- **manager.ts**: Manifest 文件的读取、保存、更新检测
|
||||
|
||||
### 7. 并发处理 (`worker/`)
|
||||
- **pool.ts**: Worker 池管理,支持并发处理
|
||||
|
||||
### 8. 主构建器 (`builder/`)
|
||||
- **index.ts**: 整个构建流程的编排和协调
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 基本使用
|
||||
```typescript
|
||||
import { buildManifest } from './src/core/index.js'
|
||||
|
||||
await buildManifest({
|
||||
isForceMode: false,
|
||||
isForceManifest: false,
|
||||
isForceThumbnails: false,
|
||||
concurrencyLimit: 10,
|
||||
})
|
||||
```
|
||||
|
||||
### 单独使用模块
|
||||
```typescript
|
||||
import {
|
||||
getImageFromS3,
|
||||
generateThumbnailAndBlurhash,
|
||||
extractExifData
|
||||
} from './src/core/index.js'
|
||||
|
||||
// 下载图片
|
||||
const buffer = await getImageFromS3('path/to/image.jpg')
|
||||
|
||||
// 生成缩略图
|
||||
const result = await generateThumbnailAndBlurhash(buffer, 'photo-id', 1920, 1080)
|
||||
|
||||
// 提取 EXIF
|
||||
const exif = await extractExifData(buffer)
|
||||
```
|
||||
|
||||
## 特性
|
||||
|
||||
### 1. 模块化设计
|
||||
- 每个功能模块独立,便于测试和维护
|
||||
- 清晰的依赖关系
|
||||
- 易于扩展新功能
|
||||
|
||||
### 2. 类型安全
|
||||
- 完整的 TypeScript 类型定义
|
||||
- 编译时错误检查
|
||||
|
||||
### 3. 性能优化
|
||||
- Worker 池并发处理
|
||||
- Sharp 实例复用
|
||||
- 增量更新支持
|
||||
|
||||
### 4. 错误处理
|
||||
- 统一的错误处理机制
|
||||
- 详细的日志记录
|
||||
- 优雅的失败处理
|
||||
|
||||
### 5. 配置灵活
|
||||
- 支持多种运行模式
|
||||
- 可配置的并发数
|
||||
- 环境变量配置
|
||||
|
||||
## 扩展指南
|
||||
|
||||
### 添加新的图像处理功能
|
||||
1. 在 `image/` 目录下创建新模块
|
||||
2. 在 `index.ts` 中导出新功能
|
||||
3. 在 `photo/processor.ts` 中集成
|
||||
|
||||
### 添加新的存储后端
|
||||
1. 在 `s3/` 目录下创建新的操作模块
|
||||
2. 实现相同的接口
|
||||
3. 在配置中切换
|
||||
|
||||
### 自定义日志器
|
||||
```typescript
|
||||
import { logger } from './src/core/index.js'
|
||||
|
||||
const customLogger = logger.worker(1).withTag('CUSTOM')
|
||||
customLogger.info('自定义日志')
|
||||
```
|
||||
|
||||
## 性能考虑
|
||||
|
||||
- 使用 Worker 池避免过度并发
|
||||
- Sharp 实例复用减少内存开销
|
||||
- 增量更新减少不必要的处理
|
||||
- 缩略图和 Blurhash 缓存复用
|
||||
346
apps/web/src/core/builder/builder.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import type { BuilderConfig } from '@builder'
|
||||
import { builderConfig } from '@builder'
|
||||
|
||||
import { logger } from '../logger/index.js'
|
||||
import {
|
||||
handleDeletedPhotos,
|
||||
loadExistingManifest,
|
||||
saveManifest,
|
||||
} from '../manifest/manager.js'
|
||||
import type { PhotoProcessorOptions } from '../photo/processor.js'
|
||||
import { processPhoto } from '../photo/processor.js'
|
||||
import { StorageManager } from '../storage/index.js'
|
||||
import type { PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
|
||||
import { ClusterPool } from '../worker/cluster-pool.js'
|
||||
import { WorkerPool } from '../worker/pool.js'
|
||||
|
||||
export interface BuilderOptions {
|
||||
isForceMode: boolean
|
||||
isForceManifest: boolean
|
||||
isForceThumbnails: boolean
|
||||
concurrencyLimit?: number // 可选,如果未提供则使用配置文件中的默认值
|
||||
}
|
||||
|
||||
export class PhotoGalleryBuilder {
|
||||
private storageManager: StorageManager
|
||||
private config: BuilderConfig
|
||||
|
||||
constructor(config?: Partial<BuilderConfig>) {
|
||||
// 合并用户配置和默认配置
|
||||
this.config = this.mergeConfig(builderConfig, config)
|
||||
|
||||
// 创建存储管理器
|
||||
this.storageManager = new StorageManager(this.config.storage)
|
||||
|
||||
// 配置日志级别
|
||||
this.configureLogging()
|
||||
}
|
||||
|
||||
private mergeConfig(
|
||||
baseConfig: BuilderConfig,
|
||||
userConfig?: Partial<BuilderConfig>,
|
||||
): BuilderConfig {
|
||||
if (!userConfig) return baseConfig
|
||||
|
||||
return {
|
||||
repo: { ...baseConfig.repo, ...userConfig.repo },
|
||||
storage: { ...baseConfig.storage, ...userConfig.storage },
|
||||
options: { ...baseConfig.options, ...userConfig.options },
|
||||
logging: { ...baseConfig.logging, ...userConfig.logging },
|
||||
performance: {
|
||||
...baseConfig.performance,
|
||||
...userConfig.performance,
|
||||
worker: {
|
||||
...baseConfig.performance.worker,
|
||||
...userConfig.performance?.worker,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private configureLogging(): void {
|
||||
// 这里可以根据配置调整日志设置
|
||||
// 目前日志配置在 logger 模块中处理
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建照片清单
|
||||
* @param options 构建选项
|
||||
*/
|
||||
async buildManifest(options: BuilderOptions): Promise<void> {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
this.logBuildStart()
|
||||
|
||||
// 读取现有的 manifest(如果存在)
|
||||
const existingManifest = await this.loadExistingManifest(options)
|
||||
const existingManifestMap = new Map(
|
||||
existingManifest.map((item) => [item.s3Key, item]),
|
||||
)
|
||||
|
||||
logger.main.info(`现有 manifest 包含 ${existingManifest.length} 张照片`)
|
||||
|
||||
// 列出存储中的所有文件
|
||||
const allObjects = await this.storageManager.listAllFiles()
|
||||
logger.main.info(`存储中找到 ${allObjects.length} 个文件`)
|
||||
|
||||
// 检测 Live Photo 配对(如果启用)
|
||||
const livePhotoMap = await this.detectLivePhotos(allObjects)
|
||||
if (this.config.options.enableLivePhotoDetection) {
|
||||
logger.main.info(`检测到 ${livePhotoMap.size} 个 Live Photo`)
|
||||
}
|
||||
|
||||
// 列出存储中的所有图片文件
|
||||
const imageObjects = await this.storageManager.listImages()
|
||||
logger.main.info(`存储中找到 ${imageObjects.length} 张照片`)
|
||||
|
||||
// 检查照片数量限制
|
||||
if (imageObjects.length > this.config.options.maxPhotos) {
|
||||
logger.main.warn(
|
||||
`⚠️ 照片数量 (${imageObjects.length}) 超过配置限制 (${this.config.options.maxPhotos})`,
|
||||
)
|
||||
}
|
||||
|
||||
// 创建存储中存在的图片 key 集合,用于检测已删除的图片
|
||||
const s3ImageKeys = new Set(imageObjects.map((obj) => obj.key))
|
||||
|
||||
const manifest: PhotoManifestItem[] = []
|
||||
let processedCount = 0
|
||||
let skippedCount = 0
|
||||
let newCount = 0
|
||||
let deletedCount = 0
|
||||
|
||||
if (imageObjects.length > 0) {
|
||||
// 获取并发限制
|
||||
const concurrency =
|
||||
options.concurrencyLimit ?? this.config.options.defaultConcurrency
|
||||
|
||||
// 根据配置选择处理模式
|
||||
const { useClusterMode } = this.config.performance.worker
|
||||
|
||||
logger.main.info(
|
||||
`开始${useClusterMode ? '多进程' : '并发'}处理任务,${useClusterMode ? '进程' : 'Worker'}数:${concurrency}${useClusterMode ? `,每进程并发:${this.config.performance.worker.workerConcurrency}` : ''}`,
|
||||
)
|
||||
|
||||
const processorOptions: PhotoProcessorOptions = {
|
||||
isForceMode: options.isForceMode,
|
||||
isForceManifest: options.isForceManifest,
|
||||
isForceThumbnails: options.isForceThumbnails,
|
||||
}
|
||||
|
||||
let results: ProcessPhotoResult[]
|
||||
|
||||
if (useClusterMode) {
|
||||
// 创建 Cluster 池(多进程模式)
|
||||
const clusterPool = new ClusterPool<ProcessPhotoResult>(
|
||||
{
|
||||
concurrency,
|
||||
totalTasks: imageObjects.length,
|
||||
workerConcurrency:
|
||||
this.config.performance.worker.workerConcurrency,
|
||||
workerEnv: {
|
||||
FORCE_MODE: processorOptions.isForceMode.toString(),
|
||||
FORCE_MANIFEST: processorOptions.isForceManifest.toString(),
|
||||
FORCE_THUMBNAILS: processorOptions.isForceThumbnails.toString(),
|
||||
},
|
||||
},
|
||||
logger,
|
||||
)
|
||||
|
||||
// 执行多进程并发处理
|
||||
results = await clusterPool.execute()
|
||||
} else {
|
||||
// 创建传统 Worker 池(主线程并发模式)
|
||||
const workerPool = new WorkerPool<ProcessPhotoResult>(
|
||||
{
|
||||
concurrency,
|
||||
totalTasks: imageObjects.length,
|
||||
},
|
||||
logger,
|
||||
)
|
||||
|
||||
// 执行并发处理
|
||||
results = await workerPool.execute(async (taskIndex, workerId) => {
|
||||
const obj = imageObjects[taskIndex]
|
||||
|
||||
// 转换 StorageObject 到旧的 _Object 格式以兼容现有的 processPhoto 函数
|
||||
const legacyObj = {
|
||||
Key: obj.key,
|
||||
Size: obj.size,
|
||||
LastModified: obj.lastModified,
|
||||
ETag: obj.etag,
|
||||
}
|
||||
|
||||
// 转换 Live Photo Map
|
||||
const legacyLivePhotoMap = new Map()
|
||||
for (const [key, value] of livePhotoMap) {
|
||||
legacyLivePhotoMap.set(key, {
|
||||
Key: value.key,
|
||||
Size: value.size,
|
||||
LastModified: value.lastModified,
|
||||
ETag: value.etag,
|
||||
})
|
||||
}
|
||||
|
||||
return await processPhoto(
|
||||
legacyObj,
|
||||
taskIndex,
|
||||
workerId,
|
||||
imageObjects.length,
|
||||
existingManifestMap,
|
||||
legacyLivePhotoMap,
|
||||
processorOptions,
|
||||
logger,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 统计结果并添加到 manifest
|
||||
for (const result of results) {
|
||||
if (result.item) {
|
||||
manifest.push(result.item)
|
||||
|
||||
switch (result.type) {
|
||||
case 'new': {
|
||||
newCount++
|
||||
processedCount++
|
||||
break
|
||||
}
|
||||
case 'processed': {
|
||||
processedCount++
|
||||
break
|
||||
}
|
||||
case 'skipped': {
|
||||
skippedCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测并处理已删除的图片
|
||||
if (
|
||||
!options.isForceMode &&
|
||||
!options.isForceManifest &&
|
||||
existingManifest.length > 0
|
||||
) {
|
||||
deletedCount = await handleDeletedPhotos(
|
||||
existingManifest,
|
||||
s3ImageKeys,
|
||||
logger.main,
|
||||
logger.fs,
|
||||
)
|
||||
}
|
||||
|
||||
// 保存 manifest
|
||||
await saveManifest(manifest, logger.fs)
|
||||
|
||||
// 显示构建结果
|
||||
if (this.config.options.showDetailedStats) {
|
||||
this.logBuildResults(
|
||||
manifest,
|
||||
{
|
||||
newCount,
|
||||
processedCount,
|
||||
skippedCount,
|
||||
deletedCount,
|
||||
},
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.main.error('❌ 构建 manifest 失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async loadExistingManifest(
|
||||
options: BuilderOptions,
|
||||
): Promise<PhotoManifestItem[]> {
|
||||
return options.isForceMode || options.isForceManifest
|
||||
? []
|
||||
: await loadExistingManifest()
|
||||
}
|
||||
|
||||
private async detectLivePhotos(
|
||||
allObjects: Awaited<ReturnType<StorageManager['listAllFiles']>>,
|
||||
): Promise<Map<string, (typeof allObjects)[0]>> {
|
||||
if (!this.config.options.enableLivePhotoDetection) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
return await this.storageManager.detectLivePhotos(allObjects)
|
||||
}
|
||||
|
||||
private logBuildStart(): void {
|
||||
switch (this.config.storage.provider) {
|
||||
case 's3': {
|
||||
const endpoint = this.config.storage.endpoint || '默认 AWS S3'
|
||||
const customDomain = this.config.storage.customDomain || '未设置'
|
||||
const { bucket } = this.config.storage
|
||||
const prefix = this.config.storage.prefix || '无前缀'
|
||||
|
||||
logger.main.info('🚀 开始从存储获取照片列表...')
|
||||
logger.main.info(`🔗 使用端点:${endpoint}`)
|
||||
logger.main.info(`🌐 自定义域名:${customDomain}`)
|
||||
logger.main.info(`🪣 存储桶:${bucket}`)
|
||||
logger.main.info(`📂 前缀:${prefix}`)
|
||||
break
|
||||
}
|
||||
case 'github': {
|
||||
const { owner, repo, branch, path } = this.config.storage
|
||||
logger.main.info('🚀 开始从存储获取照片列表...')
|
||||
logger.main.info(`👤 仓库所有者:${owner}`)
|
||||
logger.main.info(`🏷️ 仓库名称:${repo}`)
|
||||
logger.main.info(`🌲 分支:${branch}`)
|
||||
logger.main.info(`📂 路径:${path}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private logBuildResults(
|
||||
manifest: PhotoManifestItem[],
|
||||
stats: {
|
||||
newCount: number
|
||||
processedCount: number
|
||||
skippedCount: number
|
||||
deletedCount: number
|
||||
},
|
||||
totalDuration: number,
|
||||
): void {
|
||||
const durationSeconds = Math.round(totalDuration / 1000)
|
||||
const durationMinutes = Math.floor(durationSeconds / 60)
|
||||
const remainingSeconds = durationSeconds % 60
|
||||
|
||||
logger.main.success(`🎉 Manifest 构建完成!`)
|
||||
logger.main.info(`📊 处理统计:`)
|
||||
logger.main.info(` 📸 总照片数:${manifest.length}`)
|
||||
logger.main.info(` 🆕 新增照片:${stats.newCount}`)
|
||||
logger.main.info(` 🔄 处理照片:${stats.processedCount}`)
|
||||
logger.main.info(` ⏭️ 跳过照片:${stats.skippedCount}`)
|
||||
logger.main.info(` 🗑️ 删除照片:${stats.deletedCount}`)
|
||||
logger.main.info(
|
||||
` ⏱️ 总耗时:${durationMinutes > 0 ? `${durationMinutes}分${remainingSeconds}秒` : `${durationSeconds}秒`}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前使用的存储管理器
|
||||
*/
|
||||
getStorageManager(): StorageManager {
|
||||
return this.storageManager
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置
|
||||
*/
|
||||
getConfig(): BuilderConfig {
|
||||
return { ...this.config }
|
||||
}
|
||||
}
|
||||
|
||||
// 导出默认的构建器实例
|
||||
export const defaultBuilder = new PhotoGalleryBuilder()
|
||||
5
apps/web/src/core/builder/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// 保持向后兼容的函数
|
||||
|
||||
// 重新导出新的 Builder 类和相关类型
|
||||
export type { BuilderOptions } from './builder.js'
|
||||
export { defaultBuilder, PhotoGalleryBuilder } from './builder.js'
|
||||
180
apps/web/src/core/cli.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import cluster from 'node:cluster'
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { builderConfig } from '@builder'
|
||||
import { $ } from 'execa'
|
||||
|
||||
import { defaultBuilder } from './builder/index.js'
|
||||
import { logger } from './logger/index.js'
|
||||
import { runAsWorker } from './runAsWorker.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
async function main() {
|
||||
// 检查是否作为 cluster worker 运行
|
||||
if (
|
||||
process.env.CLUSTER_WORKER === 'true' ||
|
||||
process.argv.includes('--cluster-worker') ||
|
||||
cluster.isWorker
|
||||
) {
|
||||
await runAsWorker()
|
||||
return
|
||||
}
|
||||
|
||||
// 如果配置了远程仓库,则使用远程仓库
|
||||
if (builderConfig.repo.enable) {
|
||||
// 拉取远程仓库
|
||||
const workdir = path.resolve(__dirname, '..', '..')
|
||||
const hasExist = existsSync(path.resolve(workdir, 'assets-git'))
|
||||
if (!hasExist) {
|
||||
await $({
|
||||
cwd: workdir,
|
||||
stdio: 'inherit',
|
||||
})`git clone ${builderConfig.repo.url} assets-git`
|
||||
} else {
|
||||
await $({
|
||||
cwd: path.resolve(workdir, 'assets-git'),
|
||||
stdio: 'inherit',
|
||||
})`git pull --rebase`
|
||||
}
|
||||
|
||||
// 删除 public/thumbnails 目录,并建立软连接到 assets-git/thumbnails
|
||||
const thumbnailsDir = path.resolve(workdir, 'public', 'thumbnails')
|
||||
if (existsSync(thumbnailsDir)) {
|
||||
await $({ cwd: workdir, stdio: 'inherit' })`rm -rf ${thumbnailsDir}`
|
||||
}
|
||||
await $({
|
||||
cwd: workdir,
|
||||
stdio: 'inherit',
|
||||
})`ln -s ${path.resolve(workdir, 'assets-git', 'thumbnails')} ${thumbnailsDir}`
|
||||
// 删除src/data/photos-manifest.json,并建立软连接到 assets-git/photos-manifest.json
|
||||
const photosManifestPath = path.resolve(
|
||||
workdir,
|
||||
'src',
|
||||
'data',
|
||||
'photos-manifest.json',
|
||||
)
|
||||
if (existsSync(photosManifestPath)) {
|
||||
await $({ cwd: workdir, stdio: 'inherit' })`rm -rf ${photosManifestPath}`
|
||||
}
|
||||
await $({ cwd: workdir, stdio: 'inherit' })`ln -s ${path.resolve(
|
||||
workdir,
|
||||
'assets-git',
|
||||
'photos-manifest.json',
|
||||
)} ${photosManifestPath}`
|
||||
}
|
||||
|
||||
process.title = 'photo-gallery-builder-main'
|
||||
|
||||
// 解析命令行参数
|
||||
const args = new Set(process.argv.slice(2))
|
||||
const isForceMode = args.has('--force')
|
||||
const isForceManifest = args.has('--force-manifest')
|
||||
const isForceThumbnails = args.has('--force-thumbnails')
|
||||
|
||||
// 显示帮助信息
|
||||
if (args.has('--help') || args.has('-h')) {
|
||||
logger.main.info(`
|
||||
照片库构建工具 (新版本 - 使用适配器模式)
|
||||
|
||||
用法:tsx src/core/cli.ts [选项]
|
||||
|
||||
选项:
|
||||
--force 强制重新处理所有照片
|
||||
--force-manifest 强制重新生成 manifest
|
||||
--force-thumbnails 强制重新生成缩略图
|
||||
--config 显示当前配置信息
|
||||
--help, -h 显示帮助信息
|
||||
|
||||
示例:
|
||||
tsx src/core/cli.ts # 增量更新
|
||||
tsx src/core/cli.ts --force # 全量更新
|
||||
tsx src/core/cli.ts --force-thumbnails # 强制重新生成缩略图
|
||||
tsx src/core/cli.ts --config # 显示配置信息
|
||||
|
||||
配置:
|
||||
在 builder.config.ts 中设置 performance.worker.useClusterMode = true
|
||||
可启用多进程集群模式,发挥多核心优势。
|
||||
`)
|
||||
return
|
||||
}
|
||||
|
||||
// 显示配置信息
|
||||
if (args.has('--config')) {
|
||||
const config = defaultBuilder.getConfig()
|
||||
logger.main.info('🔧 当前配置:')
|
||||
logger.main.info(` 存储提供商:${config.storage.provider}`)
|
||||
|
||||
switch (config.storage.provider) {
|
||||
case 's3': {
|
||||
logger.main.info(` 存储桶:${config.storage.bucket}`)
|
||||
logger.main.info(` 区域:${config.storage.region || '未设置'}`)
|
||||
logger.main.info(` 端点:${config.storage.endpoint || '默认'}`)
|
||||
logger.main.info(
|
||||
` 自定义域名:${config.storage.customDomain || '未设置'}`,
|
||||
)
|
||||
logger.main.info(` 前缀:${config.storage.prefix || '无'}`)
|
||||
break
|
||||
}
|
||||
case 'github': {
|
||||
logger.main.info(` 仓库所有者:${config.storage.owner}`)
|
||||
logger.main.info(` 仓库名称:${config.storage.repo}`)
|
||||
logger.main.info(` 分支:${config.storage.branch || 'main'}`)
|
||||
logger.main.info(` 路径:${config.storage.path || '无'}`)
|
||||
logger.main.info(` 使用原始 URL:${config.storage.useRawUrl || '否'}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
logger.main.info(` 默认并发数:${config.options.defaultConcurrency}`)
|
||||
logger.main.info(` 最大照片数:${config.options.maxPhotos}`)
|
||||
logger.main.info(
|
||||
` Live Photo 检测:${config.options.enableLivePhotoDetection ? '启用' : '禁用'}`,
|
||||
)
|
||||
logger.main.info(` Worker 数:${config.performance.worker.workerCount}`)
|
||||
logger.main.info(` Worker 超时:${config.performance.worker.timeout}ms`)
|
||||
logger.main.info(
|
||||
` 集群模式:${config.performance.worker.useClusterMode ? '启用' : '禁用'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 确定运行模式
|
||||
let runMode = '增量更新'
|
||||
if (isForceMode) {
|
||||
runMode = '全量更新'
|
||||
} else if (isForceManifest && isForceThumbnails) {
|
||||
runMode = '强制刷新 manifest 和缩略图'
|
||||
} else if (isForceManifest) {
|
||||
runMode = '强制刷新 manifest'
|
||||
} else if (isForceThumbnails) {
|
||||
runMode = '强制刷新缩略图'
|
||||
}
|
||||
|
||||
const config = defaultBuilder.getConfig()
|
||||
const concurrencyLimit = config.performance.worker.workerCount
|
||||
const finalConcurrency = concurrencyLimit ?? config.options.defaultConcurrency
|
||||
const processingMode = config.performance.worker.useClusterMode
|
||||
? '多进程集群'
|
||||
: '并发线程池'
|
||||
|
||||
logger.main.info(`🚀 运行模式:${runMode}`)
|
||||
logger.main.info(`⚡ 最大并发数:${finalConcurrency}`)
|
||||
logger.main.info(`🔧 处理模式:${processingMode}`)
|
||||
logger.main.info(`🏗️ 使用构建器:PhotoGalleryBuilder (适配器模式)`)
|
||||
|
||||
// 启动构建过程
|
||||
await defaultBuilder.buildManifest({
|
||||
isForceMode,
|
||||
isForceManifest,
|
||||
isForceThumbnails,
|
||||
concurrencyLimit,
|
||||
})
|
||||
}
|
||||
|
||||
// 运行主函数
|
||||
main().catch((error) => {
|
||||
logger.main.error('构建失败:', error)
|
||||
throw error
|
||||
})
|
||||
15
apps/web/src/core/constants/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// 支持的图片格式
|
||||
export const SUPPORTED_FORMATS = new Set([
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.tiff',
|
||||
'.heic',
|
||||
'.heif',
|
||||
'.hif',
|
||||
])
|
||||
|
||||
// HEIC/HEIF 格式
|
||||
export const HEIC_FORMATS = new Set(['.heic', '.heif', '.hif'])
|
||||
76
apps/web/src/core/image/blurhash.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { encode } from 'blurhash'
|
||||
import sharp from 'sharp'
|
||||
|
||||
import type { Logger } from '../logger/index.js'
|
||||
|
||||
// 生成 blurhash(基于缩略图数据,保持长宽比)
|
||||
export async function generateBlurhash(
|
||||
thumbnailBuffer: Buffer,
|
||||
originalWidth: number,
|
||||
originalHeight: number,
|
||||
blurhashLogger?: Logger['blurhash'],
|
||||
): Promise<string | null> {
|
||||
const log = blurhashLogger
|
||||
|
||||
try {
|
||||
// 计算原始图像的长宽比
|
||||
const aspectRatio = originalWidth / originalHeight
|
||||
|
||||
// 根据长宽比计算合适的 blurhash 尺寸
|
||||
// 目标是在保持长宽比的同时,获得合适的细节级别
|
||||
let targetWidth: number
|
||||
let targetHeight: number
|
||||
|
||||
// 基础尺寸,可以根据需要调整
|
||||
const baseSize = 64
|
||||
|
||||
if (aspectRatio >= 1) {
|
||||
// 横向图片
|
||||
targetWidth = baseSize
|
||||
targetHeight = Math.round(baseSize / aspectRatio)
|
||||
} else {
|
||||
// 纵向图片
|
||||
targetHeight = baseSize
|
||||
targetWidth = Math.round(baseSize * aspectRatio)
|
||||
}
|
||||
|
||||
// 确保最小尺寸,避免过小的尺寸
|
||||
targetWidth = Math.max(targetWidth, 16)
|
||||
targetHeight = Math.max(targetHeight, 16)
|
||||
|
||||
// 计算 blurhash 的组件数量
|
||||
// 根据图像尺寸动态调整,但限制在合理范围内
|
||||
const xComponents = Math.min(Math.max(Math.round(targetWidth / 16), 3), 9)
|
||||
const yComponents = Math.min(Math.max(Math.round(targetHeight / 16), 3), 9)
|
||||
|
||||
log?.debug(
|
||||
`生成参数:原始 ${originalWidth}x${originalHeight}, 目标 ${targetWidth}x${targetHeight}, 组件 ${xComponents}x${yComponents}`,
|
||||
)
|
||||
|
||||
// 复用缩略图的 Sharp 实例来生成 blurhash
|
||||
const { data, info } = await sharp(thumbnailBuffer)
|
||||
.rotate() // 自动根据 EXIF 旋转
|
||||
.resize(targetWidth, targetHeight, {
|
||||
fit: 'fill', // 填充整个目标尺寸,保持长宽比
|
||||
background: { r: 255, g: 255, b: 255, alpha: 0 }, // 透明背景
|
||||
})
|
||||
.raw()
|
||||
.ensureAlpha()
|
||||
.toBuffer({ resolveWithObject: true })
|
||||
|
||||
// 生成 blurhash
|
||||
const blurhash = encode(
|
||||
new Uint8ClampedArray(data),
|
||||
info.width,
|
||||
info.height,
|
||||
xComponents,
|
||||
yComponents,
|
||||
)
|
||||
|
||||
log?.success(`生成成功:${blurhash}`)
|
||||
return blurhash
|
||||
} catch (error) {
|
||||
log?.error('生成失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
144
apps/web/src/core/image/exif.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { Exif } from 'exif-reader'
|
||||
import exifReader from 'exif-reader'
|
||||
import getRecipe from 'fuji-recipes'
|
||||
import sharp from 'sharp'
|
||||
|
||||
import type { Logger } from '../logger/index.js'
|
||||
|
||||
// 清理 EXIF 数据中的空字符和无用信息
|
||||
function cleanExifData(exifData: any): any {
|
||||
if (!exifData || typeof exifData !== 'object') {
|
||||
return exifData
|
||||
}
|
||||
|
||||
if (Array.isArray(exifData)) {
|
||||
return exifData.map((item) => cleanExifData(item))
|
||||
}
|
||||
|
||||
// 如果是 Date 对象,直接返回
|
||||
if (exifData instanceof Date) {
|
||||
return exifData
|
||||
}
|
||||
|
||||
const cleaned: any = {}
|
||||
|
||||
// 重要的日期字段,不应该被过度清理
|
||||
const importantDateFields = new Set([
|
||||
'DateTimeOriginal',
|
||||
'DateTime',
|
||||
'DateTimeDigitized',
|
||||
'CreateDate',
|
||||
'ModifyDate',
|
||||
])
|
||||
|
||||
for (const [key, value] of Object.entries(exifData)) {
|
||||
if (value === null || value === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// 对于重要的日期字段,只移除空字符,不进行过度清理
|
||||
if (importantDateFields.has(key)) {
|
||||
const cleanedString = value.replaceAll('\0', '')
|
||||
if (cleanedString.length > 0) {
|
||||
cleaned[key] = cleanedString
|
||||
}
|
||||
} else {
|
||||
// 对于其他字符串字段,移除空字符并清理空白字符
|
||||
const cleanedString = value.replaceAll('\0', '').trim()
|
||||
if (cleanedString.length > 0) {
|
||||
cleaned[key] = cleanedString
|
||||
}
|
||||
}
|
||||
} else if (value instanceof Date) {
|
||||
// Date 对象直接保留
|
||||
cleaned[key] = value
|
||||
} else if (typeof value === 'object') {
|
||||
// 递归清理嵌套对象
|
||||
const cleanedNested = cleanExifData(value)
|
||||
if (cleanedNested && Object.keys(cleanedNested).length > 0) {
|
||||
cleaned[key] = cleanedNested
|
||||
}
|
||||
} else {
|
||||
// 其他类型直接保留
|
||||
cleaned[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// 提取 EXIF 数据
|
||||
export async function extractExifData(
|
||||
imageBuffer: Buffer,
|
||||
originalBuffer?: Buffer,
|
||||
exifLogger?: Logger['exif'],
|
||||
): Promise<Exif | null> {
|
||||
const log = exifLogger
|
||||
|
||||
try {
|
||||
log?.info('开始提取 EXIF 数据')
|
||||
|
||||
// 首先尝试从处理后的图片中提取 EXIF
|
||||
let metadata = await sharp(imageBuffer).metadata()
|
||||
|
||||
// 如果处理后的图片没有 EXIF 数据,且提供了原始 buffer,尝试从原始图片提取
|
||||
if (!metadata.exif && originalBuffer) {
|
||||
log?.info('处理后的图片缺少 EXIF 数据,尝试从原始图片提取')
|
||||
try {
|
||||
metadata = await sharp(originalBuffer).metadata()
|
||||
} catch (error) {
|
||||
log?.warn('从原始图片提取 EXIF 失败,可能是不支持的格式:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!metadata.exif) {
|
||||
log?.warn('未找到 EXIF 数据')
|
||||
return null
|
||||
}
|
||||
|
||||
let startIndex = 0
|
||||
for (let i = 0; i < metadata.exif.length; i++) {
|
||||
if (
|
||||
metadata.exif.toString('ascii', i, i + 2) === 'II' ||
|
||||
metadata.exif.toString('ascii', i, i + 2) === 'MM'
|
||||
) {
|
||||
startIndex = i
|
||||
break
|
||||
}
|
||||
if (metadata.exif.toString('ascii', i, i + 4) === 'Exif') {
|
||||
startIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
const exifBuffer = metadata.exif.subarray(startIndex)
|
||||
|
||||
// 使用 exif-reader 解析 EXIF 数据
|
||||
const exifData = exifReader(exifBuffer)
|
||||
|
||||
if (exifData.Photo?.MakerNote) {
|
||||
const recipe = getRecipe(exifData.Photo.MakerNote)
|
||||
;(exifData as any).FujiRecipe = recipe
|
||||
log?.info('检测到富士胶片配方信息')
|
||||
}
|
||||
|
||||
delete exifData.Photo?.MakerNote
|
||||
delete exifData.Photo?.UserComment
|
||||
delete exifData.Photo?.PrintImageMatching
|
||||
delete exifData.Image?.PrintImageMatching
|
||||
|
||||
if (!exifData) {
|
||||
log?.warn('EXIF 数据解析失败')
|
||||
return null
|
||||
}
|
||||
|
||||
// 清理 EXIF 数据中的空字符和无用数据
|
||||
const cleanedExifData = cleanExifData(exifData)
|
||||
|
||||
log?.success('EXIF 数据提取完成')
|
||||
return cleanedExifData
|
||||
} catch (error) {
|
||||
log?.error('提取 EXIF 数据失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
101
apps/web/src/core/image/processor.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import heicConvert from 'heic-convert'
|
||||
import type sharp from 'sharp'
|
||||
|
||||
import { HEIC_FORMATS } from '../constants/index.js'
|
||||
import type { Logger } from '../logger/index.js'
|
||||
import type { ImageMetadata } from '../types/photo.js'
|
||||
|
||||
// 获取图片元数据(复用 Sharp 实例)
|
||||
export async function getImageMetadataWithSharp(
|
||||
sharpInstance: sharp.Sharp,
|
||||
imageLogger?: Logger['image'],
|
||||
): Promise<ImageMetadata | null> {
|
||||
const log = imageLogger
|
||||
|
||||
try {
|
||||
const metadata = await sharpInstance.metadata()
|
||||
|
||||
if (!metadata.width || !metadata.height || !metadata.format) {
|
||||
log?.error('图片元数据不完整')
|
||||
return null
|
||||
}
|
||||
|
||||
let { width } = metadata
|
||||
let { height } = metadata
|
||||
|
||||
// 根据 EXIF Orientation 信息调整宽高
|
||||
const { orientation } = metadata
|
||||
if (
|
||||
orientation === 5 ||
|
||||
orientation === 6 ||
|
||||
orientation === 7 ||
|
||||
orientation === 8
|
||||
) {
|
||||
// 对于需要旋转 90°的图片,需要交换宽高
|
||||
;[width, height] = [height, width]
|
||||
log?.info(
|
||||
`检测到需要旋转 90°的图片 (orientation: ${orientation}),交换宽高:${width}x${height}`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
format: metadata.format,
|
||||
}
|
||||
} catch (error) {
|
||||
log?.error('获取图片元数据失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 转换 HEIC/HEIF 格式到 JPEG
|
||||
export async function convertHeicToJpeg(
|
||||
heicBuffer: Buffer,
|
||||
imageLogger?: Logger['image'],
|
||||
): Promise<Buffer> {
|
||||
const log = imageLogger
|
||||
|
||||
try {
|
||||
log?.info(
|
||||
`开始 HEIC/HEIF → JPEG 转换 (${Math.round(heicBuffer.length / 1024)}KB)`,
|
||||
)
|
||||
const startTime = Date.now()
|
||||
|
||||
const jpegBuffer = await heicConvert({
|
||||
buffer: heicBuffer,
|
||||
format: 'JPEG',
|
||||
quality: 0.95, // 高质量转换
|
||||
})
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
const outputSizeKB = Math.round(jpegBuffer.byteLength / 1024)
|
||||
log?.success(`HEIC/HEIF 转换完成 (${outputSizeKB}KB, ${duration}ms)`)
|
||||
|
||||
return Buffer.from(jpegBuffer)
|
||||
} catch (error) {
|
||||
log?.error('HEIC/HEIF 转换失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 预处理图片 Buffer(处理 HEIC/HEIF 格式)
|
||||
export async function preprocessImageBuffer(
|
||||
buffer: Buffer,
|
||||
key: string,
|
||||
imageLogger?: Logger['image'],
|
||||
): Promise<Buffer> {
|
||||
const log = imageLogger
|
||||
const ext = path.extname(key).toLowerCase()
|
||||
|
||||
// 如果是 HEIC/HEIF 格式,先转换为 JPEG
|
||||
if (HEIC_FORMATS.has(ext)) {
|
||||
log?.info(`检测到 HEIC/HEIF 格式:${key}`)
|
||||
return await convertHeicToJpeg(buffer, log)
|
||||
}
|
||||
|
||||
// 其他格式直接返回原始 buffer
|
||||
return buffer
|
||||
}
|
||||
121
apps/web/src/core/image/thumbnail.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import sharp from 'sharp'
|
||||
|
||||
import type { Logger } from '../logger/index.js'
|
||||
import type { ThumbnailResult } from '../types/photo.js'
|
||||
import { generateBlurhash } from './blurhash.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
// 检查缩略图是否存在
|
||||
export async function thumbnailExists(photoId: string): Promise<boolean> {
|
||||
try {
|
||||
const thumbnailPath = path.join(
|
||||
__dirname,
|
||||
'../../../public/thumbnails',
|
||||
`${photoId}.webp`,
|
||||
)
|
||||
await fs.access(thumbnailPath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 生成缩略图和 blurhash(复用 Sharp 实例)
|
||||
export async function generateThumbnailAndBlurhash(
|
||||
imageBuffer: Buffer,
|
||||
photoId: string,
|
||||
originalWidth: number,
|
||||
originalHeight: number,
|
||||
forceRegenerate = false,
|
||||
workerLogger?: {
|
||||
thumbnail: Logger['thumbnail']
|
||||
blurhash: Logger['blurhash']
|
||||
},
|
||||
): Promise<ThumbnailResult> {
|
||||
const thumbnailLog = workerLogger?.thumbnail
|
||||
const blurhashLog = workerLogger?.blurhash
|
||||
|
||||
try {
|
||||
const thumbnailDir = path.join(__dirname, '../../../public/thumbnails')
|
||||
await fs.mkdir(thumbnailDir, { recursive: true })
|
||||
|
||||
const thumbnailPath = path.join(thumbnailDir, `${photoId}.webp`)
|
||||
const thumbnailUrl = `/thumbnails/${photoId}.webp`
|
||||
|
||||
// 如果不是强制模式且缩略图已存在,读取现有文件
|
||||
if (!forceRegenerate && (await thumbnailExists(photoId))) {
|
||||
thumbnailLog?.info(`复用现有缩略图:${photoId}`)
|
||||
try {
|
||||
const existingBuffer = await fs.readFile(thumbnailPath)
|
||||
|
||||
// 基于现有缩略图生成 blurhash
|
||||
const blurhash = await generateBlurhash(
|
||||
existingBuffer,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
blurhashLog,
|
||||
)
|
||||
|
||||
return {
|
||||
thumbnailUrl,
|
||||
thumbnailBuffer: existingBuffer,
|
||||
blurhash,
|
||||
}
|
||||
} catch (error) {
|
||||
thumbnailLog?.warn(`读取现有缩略图失败,重新生成:${photoId}`, error)
|
||||
// 继续执行生成逻辑
|
||||
}
|
||||
}
|
||||
|
||||
thumbnailLog?.info(`生成缩略图:${photoId}`)
|
||||
const startTime = Date.now()
|
||||
|
||||
// 创建 Sharp 实例,复用于缩略图和 blurhash 生成
|
||||
const sharpInstance = sharp(imageBuffer).rotate() // 自动根据 EXIF 旋转
|
||||
|
||||
// 生成缩略图
|
||||
const thumbnailBuffer = await sharpInstance
|
||||
.clone() // 克隆实例用于缩略图生成
|
||||
.resize(600, null, {
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.webp({
|
||||
quality: 100,
|
||||
})
|
||||
.toBuffer()
|
||||
|
||||
// 保存到文件
|
||||
await fs.writeFile(thumbnailPath, thumbnailBuffer)
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
const sizeKB = Math.round(thumbnailBuffer.length / 1024)
|
||||
thumbnailLog?.success(`生成完成:${photoId} (${sizeKB}KB, ${duration}ms)`)
|
||||
|
||||
// 基于生成的缩略图生成 blurhash
|
||||
const blurhash = await generateBlurhash(
|
||||
thumbnailBuffer,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
blurhashLog,
|
||||
)
|
||||
|
||||
return {
|
||||
thumbnailUrl,
|
||||
thumbnailBuffer,
|
||||
blurhash,
|
||||
}
|
||||
} catch (error) {
|
||||
thumbnailLog?.error(`生成失败:${photoId}`, error)
|
||||
return {
|
||||
thumbnailUrl: null,
|
||||
thumbnailBuffer: null,
|
||||
blurhash: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
61
apps/web/src/core/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// 主要构建器
|
||||
export {
|
||||
type BuilderOptions,
|
||||
defaultBuilder,
|
||||
PhotoGalleryBuilder,
|
||||
} from './builder/index.js'
|
||||
|
||||
// 日志系统
|
||||
export { type Logger, logger, type WorkerLogger } from './logger/index.js'
|
||||
|
||||
// 类型定义
|
||||
export type {
|
||||
ImageMetadata,
|
||||
PhotoInfo,
|
||||
PhotoManifestItem,
|
||||
ProcessPhotoResult,
|
||||
ThumbnailResult,
|
||||
} from './types/photo.js'
|
||||
|
||||
// S3 操作
|
||||
export { s3Client } from './s3/client.js'
|
||||
export {
|
||||
generateS3Url,
|
||||
getImageFromS3,
|
||||
listImagesFromS3,
|
||||
} from './s3/operations.js'
|
||||
|
||||
// 图像处理
|
||||
export { generateBlurhash } from './image/blurhash.js'
|
||||
export { extractExifData } from './image/exif.js'
|
||||
export {
|
||||
getImageMetadataWithSharp,
|
||||
preprocessImageBuffer,
|
||||
} from './image/processor.js'
|
||||
export {
|
||||
generateThumbnailAndBlurhash,
|
||||
thumbnailExists,
|
||||
} from './image/thumbnail.js'
|
||||
|
||||
// 照片处理
|
||||
export { extractPhotoInfo } from './photo/info-extractor.js'
|
||||
export {
|
||||
type PhotoProcessorOptions,
|
||||
processPhoto,
|
||||
type WorkerLoggers,
|
||||
} from './photo/processor.js'
|
||||
|
||||
// Manifest 管理
|
||||
export {
|
||||
handleDeletedPhotos,
|
||||
loadExistingManifest,
|
||||
needsUpdate,
|
||||
saveManifest,
|
||||
} from './manifest/manager.js'
|
||||
|
||||
// Worker 池
|
||||
export {
|
||||
type TaskFunction,
|
||||
WorkerPool,
|
||||
type WorkerPoolOptions,
|
||||
} from './worker/pool.js'
|
||||
24
apps/web/src/core/logger/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import consola from 'consola'
|
||||
|
||||
// 创建系统化的日志器
|
||||
export const logger = {
|
||||
// 主进程日志
|
||||
main: consola.withTag('MAIN'),
|
||||
// S3 操作日志
|
||||
s3: consola.withTag('S3'),
|
||||
// 图片处理日志
|
||||
image: consola.withTag('IMAGE'),
|
||||
// 缩略图处理日志
|
||||
thumbnail: consola.withTag('THUMBNAIL'),
|
||||
// Blurhash 处理日志
|
||||
blurhash: consola.withTag('BLURHASH'),
|
||||
// EXIF 处理日志
|
||||
exif: consola.withTag('EXIF'),
|
||||
// 文件系统操作日志
|
||||
fs: consola.withTag('FS'),
|
||||
// Worker 日志(动态创建)
|
||||
worker: (id: number) => consola.withTag(`WORKER-${id}`),
|
||||
}
|
||||
|
||||
export type Logger = typeof logger
|
||||
export type WorkerLogger = ReturnType<typeof logger.worker>
|
||||
99
apps/web/src/core/manifest/manager.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import type { _Object } from '@aws-sdk/client-s3'
|
||||
|
||||
import type { Logger } from '../logger/index.js'
|
||||
import type { PhotoManifestItem } from '../types/photo.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
// 读取现有的 manifest
|
||||
export async function loadExistingManifest(): Promise<PhotoManifestItem[]> {
|
||||
try {
|
||||
const manifestPath = path.join(
|
||||
__dirname,
|
||||
'../../../src/data/photos-manifest.json',
|
||||
)
|
||||
const manifestContent = await fs.readFile(manifestPath, 'utf-8')
|
||||
return JSON.parse(manifestContent) as PhotoManifestItem[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 检查照片是否需要更新(基于最后修改时间)
|
||||
export function needsUpdate(
|
||||
existingItem: PhotoManifestItem | undefined,
|
||||
s3Object: _Object,
|
||||
): boolean {
|
||||
if (!existingItem) return true
|
||||
if (!s3Object.LastModified) return true
|
||||
|
||||
const existingModified = new Date(existingItem.lastModified)
|
||||
const s3Modified = s3Object.LastModified
|
||||
|
||||
return s3Modified > existingModified
|
||||
}
|
||||
|
||||
// 保存 manifest
|
||||
export async function saveManifest(
|
||||
manifest: PhotoManifestItem[],
|
||||
fsLogger?: Logger['fs'],
|
||||
): Promise<void> {
|
||||
const manifestPath = path.join(
|
||||
__dirname,
|
||||
'../../../src/data/photos-manifest.json',
|
||||
)
|
||||
|
||||
// 按日期排序(最新的在前)
|
||||
const sortedManifest = [...manifest].sort(
|
||||
(a, b) => new Date(b.dateTaken).getTime() - new Date(a.dateTaken).getTime(),
|
||||
)
|
||||
|
||||
await fs.mkdir(path.dirname(manifestPath), { recursive: true })
|
||||
await fs.writeFile(manifestPath, JSON.stringify(sortedManifest, null, 2))
|
||||
|
||||
fsLogger?.info(`📁 Manifest 保存至:${manifestPath}`)
|
||||
}
|
||||
|
||||
// 检测并处理已删除的图片
|
||||
export async function handleDeletedPhotos(
|
||||
existingManifest: PhotoManifestItem[],
|
||||
s3ImageKeys: Set<string>,
|
||||
mainLogger?: Logger['main'],
|
||||
fsLogger?: Logger['fs'],
|
||||
): Promise<number> {
|
||||
if (existingManifest.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
mainLogger?.info('🔍 检查已删除的图片...')
|
||||
let deletedCount = 0
|
||||
|
||||
for (const existingItem of existingManifest) {
|
||||
// 如果现有 manifest 中的图片在 S3 中不存在了
|
||||
if (!s3ImageKeys.has(existingItem.s3Key)) {
|
||||
mainLogger?.info(`🗑️ 检测到已删除的图片:${existingItem.s3Key}`)
|
||||
deletedCount++
|
||||
|
||||
// 删除对应的缩略图文件
|
||||
try {
|
||||
const thumbnailPath = path.join(
|
||||
__dirname,
|
||||
'../../../public/thumbnails',
|
||||
`${existingItem.id}.webp`,
|
||||
)
|
||||
await fs.unlink(thumbnailPath)
|
||||
fsLogger?.info(`🗑️ 已删除缩略图:${existingItem.id}.webp`)
|
||||
} catch (error) {
|
||||
// 缩略图可能已经不存在,忽略错误
|
||||
fsLogger?.warn(`删除缩略图失败:${existingItem.id}.webp`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount
|
||||
}
|
||||
117
apps/web/src/core/photo/info-extractor.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { env } from '@env'
|
||||
import type { Exif } from 'exif-reader'
|
||||
|
||||
import type { Logger } from '../logger/index.js'
|
||||
import type { PhotoInfo } from '../types/photo.js'
|
||||
|
||||
// 从文件名提取照片信息
|
||||
export function extractPhotoInfo(
|
||||
key: string,
|
||||
exifData?: Exif | null,
|
||||
imageLogger?: Logger['image'],
|
||||
): PhotoInfo {
|
||||
const log = imageLogger
|
||||
|
||||
log?.debug(`提取照片信息: ${key}`)
|
||||
|
||||
const fileName = path.basename(key, path.extname(key))
|
||||
|
||||
// 尝试从文件名解析信息,格式示例: "2024-01-15_城市夜景_1250views"
|
||||
let title = fileName
|
||||
let dateTaken = new Date().toISOString()
|
||||
let views = 0
|
||||
let tags: string[] = []
|
||||
|
||||
// 从目录路径中提取 tags
|
||||
const dirPath = path.dirname(key)
|
||||
if (dirPath && dirPath !== '.' && dirPath !== '/') {
|
||||
// 移除前缀(如果有的话)
|
||||
let relativePath = dirPath
|
||||
if (env.S3_PREFIX && dirPath.startsWith(env.S3_PREFIX)) {
|
||||
relativePath = dirPath.slice(env.S3_PREFIX.length)
|
||||
}
|
||||
|
||||
// 清理路径分隔符
|
||||
relativePath = relativePath.replaceAll(/^\/+|\/+$/g, '')
|
||||
|
||||
if (relativePath) {
|
||||
// 分割路径并过滤空字符串
|
||||
const pathParts = relativePath
|
||||
.split('/')
|
||||
.filter((part) => part.trim() !== '')
|
||||
tags = pathParts.map((part) => part.trim())
|
||||
|
||||
log?.debug(`从路径提取标签:[${tags.join(', ')}]`)
|
||||
}
|
||||
}
|
||||
|
||||
// 优先使用 EXIF 中的 DateTimeOriginal
|
||||
if (exifData?.Photo?.DateTimeOriginal) {
|
||||
try {
|
||||
const dateTimeOriginal = exifData.Photo.DateTimeOriginal as any
|
||||
|
||||
// 如果是 Date 对象,直接使用
|
||||
if (dateTimeOriginal instanceof Date) {
|
||||
dateTaken = dateTimeOriginal.toISOString()
|
||||
log?.debug('使用 EXIF Date 对象作为拍摄时间')
|
||||
} else if (typeof dateTimeOriginal === 'string') {
|
||||
// 如果是字符串,按原来的方式处理
|
||||
// EXIF 日期格式通常是 "YYYY:MM:DD HH:MM:SS"
|
||||
const formattedDateStr = dateTimeOriginal.replace(
|
||||
/^(\d{4}):(\d{2}):(\d{2})/,
|
||||
'$1-$2-$3',
|
||||
)
|
||||
dateTaken = new Date(formattedDateStr).toISOString()
|
||||
log?.debug(`使用 EXIF 字符串作为拍摄时间:${dateTimeOriginal}`)
|
||||
} else {
|
||||
log?.warn(
|
||||
`未知的 DateTimeOriginal 类型:${typeof dateTimeOriginal}`,
|
||||
dateTimeOriginal,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
log?.warn(
|
||||
`解析 EXIF DateTimeOriginal 失败:${exifData.Photo.DateTimeOriginal}`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 如果 EXIF 中没有日期,尝试从文件名解析
|
||||
const dateMatch = fileName.match(/(\d{4}-\d{2}-\d{2})/)
|
||||
if (dateMatch) {
|
||||
dateTaken = new Date(dateMatch[1]).toISOString()
|
||||
log?.debug(`从文件名提取拍摄时间:${dateMatch[1]}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果文件名包含浏览次数
|
||||
const viewsMatch = fileName.match(/(\d+)views?/i)
|
||||
if (viewsMatch) {
|
||||
views = Number.parseInt(viewsMatch[1])
|
||||
log?.debug(`从文件名提取浏览次数:${views}`)
|
||||
}
|
||||
|
||||
// 从文件名中提取标题(移除日期和浏览次数)
|
||||
title = fileName
|
||||
.replaceAll(/\d{4}-\d{2}-\d{2}[_-]?/g, '')
|
||||
.replaceAll(/[_-]?\d+views?/gi, '')
|
||||
.replaceAll(/[_-]+/g, ' ')
|
||||
.trim()
|
||||
|
||||
// 如果标题为空,使用文件名
|
||||
if (!title) {
|
||||
title = path.basename(key, path.extname(key))
|
||||
}
|
||||
|
||||
log?.debug(`照片信息提取完成:"${title}"`)
|
||||
|
||||
return {
|
||||
title,
|
||||
dateTaken,
|
||||
views,
|
||||
tags,
|
||||
description: '', // 可以从 EXIF 或其他元数据中获取
|
||||
}
|
||||
}
|
||||
248
apps/web/src/core/photo/processor.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import type { _Object } from '@aws-sdk/client-s3'
|
||||
import type { Exif } from 'exif-reader'
|
||||
import sharp from 'sharp'
|
||||
|
||||
import { HEIC_FORMATS } from '../constants/index.js'
|
||||
import { extractExifData } from '../image/exif.js'
|
||||
import {
|
||||
getImageMetadataWithSharp,
|
||||
preprocessImageBuffer,
|
||||
} from '../image/processor.js'
|
||||
import {
|
||||
generateThumbnailAndBlurhash,
|
||||
thumbnailExists,
|
||||
} from '../image/thumbnail.js'
|
||||
import type { Logger } from '../logger/index.js'
|
||||
import { needsUpdate } from '../manifest/manager.js'
|
||||
import { generateS3Url, getImageFromS3 } from '../s3/operations.js'
|
||||
import type { PhotoManifestItem, ProcessPhotoResult } from '../types/photo.js'
|
||||
import { extractPhotoInfo } from './info-extractor.js'
|
||||
|
||||
export interface PhotoProcessorOptions {
|
||||
isForceMode: boolean
|
||||
isForceManifest: boolean
|
||||
isForceThumbnails: boolean
|
||||
}
|
||||
|
||||
export interface WorkerLoggers {
|
||||
image: Logger['image']
|
||||
s3: Logger['s3']
|
||||
thumbnail: Logger['thumbnail']
|
||||
blurhash: Logger['blurhash']
|
||||
exif: Logger['exif']
|
||||
}
|
||||
|
||||
// 处理单张照片
|
||||
export async function processPhoto(
|
||||
obj: _Object,
|
||||
index: number,
|
||||
workerId: number,
|
||||
totalImages: number,
|
||||
existingManifestMap: Map<string, PhotoManifestItem>,
|
||||
livePhotoMap: Map<string, _Object>,
|
||||
options: PhotoProcessorOptions,
|
||||
logger: Logger,
|
||||
): Promise<ProcessPhotoResult> {
|
||||
const key = obj.Key
|
||||
if (!key) {
|
||||
logger.image.warn(`跳过没有 Key 的对象`)
|
||||
return { item: null, type: 'failed' }
|
||||
}
|
||||
|
||||
const photoId = path.basename(key, path.extname(key))
|
||||
const existingItem = existingManifestMap.get(key)
|
||||
|
||||
// 创建 worker 专用的 logger
|
||||
const workerLoggers: WorkerLoggers = {
|
||||
image: logger.worker(workerId).withTag('IMAGE'),
|
||||
s3: logger.worker(workerId).withTag('S3'),
|
||||
thumbnail: logger.worker(workerId).withTag('THUMBNAIL'),
|
||||
blurhash: logger.worker(workerId).withTag('BLURHASH'),
|
||||
exif: logger.worker(workerId).withTag('EXIF'),
|
||||
}
|
||||
|
||||
workerLoggers.image.info(`📸 [${index + 1}/${totalImages}] ${key}`)
|
||||
|
||||
// 检查是否需要更新
|
||||
if (
|
||||
!options.isForceMode &&
|
||||
!options.isForceManifest &&
|
||||
existingItem &&
|
||||
!needsUpdate(existingItem, obj)
|
||||
) {
|
||||
// 检查缩略图是否存在,如果不存在或强制刷新缩略图则需要重新处理
|
||||
const hasThumbnail = await thumbnailExists(photoId)
|
||||
if (hasThumbnail && !options.isForceThumbnails) {
|
||||
workerLoggers.image.info(`⏭️ 跳过处理 (未更新且缩略图存在): ${key}`)
|
||||
return { item: existingItem, type: 'skipped' }
|
||||
} else {
|
||||
if (options.isForceThumbnails) {
|
||||
workerLoggers.image.info(`🔄 强制重新生成缩略图:${key}`)
|
||||
} else {
|
||||
workerLoggers.image.info(
|
||||
`🔄 重新生成缩略图 (文件未更新但缩略图缺失): ${key}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 需要处理的照片(新照片、更新的照片或缺失缩略图的照片)
|
||||
const isNewPhoto = !existingItem
|
||||
if (isNewPhoto) {
|
||||
workerLoggers.image.info(`🆕 新照片:${key}`)
|
||||
} else {
|
||||
workerLoggers.image.info(`🔄 更新照片:${key}`)
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取图片数据
|
||||
const rawImageBuffer = await getImageFromS3(key, workerLoggers.s3)
|
||||
if (!rawImageBuffer) return { item: null, type: 'failed' }
|
||||
|
||||
// 预处理图片(处理 HEIC/HEIF 格式)
|
||||
let imageBuffer: Buffer
|
||||
try {
|
||||
imageBuffer = await preprocessImageBuffer(
|
||||
rawImageBuffer,
|
||||
key,
|
||||
workerLoggers.image,
|
||||
)
|
||||
} catch (error) {
|
||||
workerLoggers.image.error(`预处理图片失败:${key}`, error)
|
||||
return { item: null, type: 'failed' }
|
||||
}
|
||||
|
||||
// 创建 Sharp 实例,复用于多个操作
|
||||
const sharpInstance = sharp(imageBuffer)
|
||||
|
||||
// 获取图片元数据(复用 Sharp 实例)
|
||||
const metadata = await getImageMetadataWithSharp(
|
||||
sharpInstance,
|
||||
workerLoggers.image,
|
||||
)
|
||||
if (!metadata) return { item: null, type: 'failed' }
|
||||
|
||||
// 如果是增量更新且已有 blurhash,可以复用
|
||||
let thumbnailUrl: string | null = null
|
||||
let thumbnailBuffer: Buffer | null = null
|
||||
let blurhash: string | null = null
|
||||
|
||||
if (
|
||||
!options.isForceMode &&
|
||||
!options.isForceThumbnails &&
|
||||
existingItem?.blurhash &&
|
||||
(await thumbnailExists(photoId))
|
||||
) {
|
||||
// 复用现有的缩略图和 blurhash
|
||||
blurhash = existingItem.blurhash
|
||||
workerLoggers.blurhash.info(`复用现有 blurhash: ${photoId}`)
|
||||
|
||||
try {
|
||||
const fs = await import('node:fs/promises')
|
||||
const thumbnailPath = path.join(
|
||||
process.cwd(),
|
||||
'public/thumbnails',
|
||||
`${photoId}.webp`,
|
||||
)
|
||||
thumbnailBuffer = await fs.readFile(thumbnailPath)
|
||||
thumbnailUrl = `/thumbnails/${photoId}.webp`
|
||||
workerLoggers.thumbnail.info(`复用现有缩略图:${photoId}`)
|
||||
} catch (error) {
|
||||
workerLoggers.thumbnail.warn(
|
||||
`读取现有缩略图失败,重新生成:${photoId}`,
|
||||
error,
|
||||
)
|
||||
// 继续执行生成逻辑
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有复用成功,则生成缩略图和 blurhash
|
||||
if (!thumbnailUrl || !thumbnailBuffer || !blurhash) {
|
||||
const result = await generateThumbnailAndBlurhash(
|
||||
imageBuffer,
|
||||
photoId,
|
||||
metadata.width,
|
||||
metadata.height,
|
||||
options.isForceMode || options.isForceThumbnails,
|
||||
{
|
||||
thumbnail: workerLoggers.thumbnail,
|
||||
blurhash: workerLoggers.blurhash,
|
||||
},
|
||||
)
|
||||
|
||||
thumbnailUrl = result.thumbnailUrl
|
||||
thumbnailBuffer = result.thumbnailBuffer
|
||||
blurhash = result.blurhash
|
||||
}
|
||||
|
||||
// 如果是增量更新且已有 EXIF 数据,可以复用
|
||||
let exifData: Exif | null = null
|
||||
if (
|
||||
!options.isForceMode &&
|
||||
!options.isForceManifest &&
|
||||
existingItem?.exif
|
||||
) {
|
||||
exifData = existingItem.exif
|
||||
workerLoggers.exif.info(`复用现有 EXIF 数据:${photoId}`)
|
||||
} else {
|
||||
// 传入原始 buffer 以便在转换后的图片缺少 EXIF 时回退
|
||||
const ext = path.extname(key).toLowerCase()
|
||||
const originalBuffer = HEIC_FORMATS.has(ext) ? rawImageBuffer : undefined
|
||||
exifData = await extractExifData(
|
||||
imageBuffer,
|
||||
originalBuffer,
|
||||
workerLoggers.exif,
|
||||
)
|
||||
}
|
||||
|
||||
// 提取照片信息(在获取 EXIF 数据之后,以便使用 DateTimeOriginal)
|
||||
const photoInfo = extractPhotoInfo(key, exifData, workerLoggers.image)
|
||||
|
||||
const aspectRatio = metadata.width / metadata.height
|
||||
|
||||
// 检查是否为 live photo
|
||||
const livePhotoVideo = livePhotoMap.get(key)
|
||||
const isLivePhoto = !!livePhotoVideo
|
||||
let livePhotoVideoUrl: string | undefined
|
||||
let livePhotoVideoS3Key: string | undefined
|
||||
|
||||
if (isLivePhoto && livePhotoVideo?.Key) {
|
||||
livePhotoVideoS3Key = livePhotoVideo.Key
|
||||
livePhotoVideoUrl = generateS3Url(livePhotoVideo.Key)
|
||||
workerLoggers.image.info(
|
||||
`📱 检测到 Live Photo:${key} -> ${livePhotoVideo.Key}`,
|
||||
)
|
||||
}
|
||||
|
||||
const photoItem: PhotoManifestItem = {
|
||||
id: photoId,
|
||||
title: photoInfo.title,
|
||||
description: photoInfo.description,
|
||||
dateTaken: photoInfo.dateTaken,
|
||||
views: photoInfo.views,
|
||||
tags: photoInfo.tags,
|
||||
originalUrl: generateS3Url(key),
|
||||
thumbnailUrl,
|
||||
blurhash,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
aspectRatio,
|
||||
s3Key: key,
|
||||
lastModified: obj.LastModified?.toISOString() || new Date().toISOString(),
|
||||
size: obj.Size || 0,
|
||||
exif: exifData,
|
||||
// Live Photo 相关字段
|
||||
isLivePhoto,
|
||||
livePhotoVideoUrl,
|
||||
livePhotoVideoS3Key,
|
||||
}
|
||||
|
||||
workerLoggers.image.success(`✅ 处理完成:${key}`)
|
||||
return { item: photoItem, type: isNewPhoto ? 'new' : 'processed' }
|
||||
} catch (error) {
|
||||
workerLoggers.image.error(`❌ 处理失败:${key}`, error)
|
||||
return { item: null, type: 'failed' }
|
||||
}
|
||||
}
|
||||
291
apps/web/src/core/runAsWorker.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import process from 'node:process'
|
||||
|
||||
import { logger } from './logger'
|
||||
import type { PhotoManifestItem } from './types/photo'
|
||||
import type {
|
||||
BatchTaskMessage,
|
||||
BatchTaskResult,
|
||||
TaskMessage,
|
||||
TaskResult,
|
||||
} from './worker/cluster-pool'
|
||||
|
||||
// Worker 进程处理逻辑
|
||||
export async function runAsWorker() {
|
||||
process.title = 'photo-gallery-builder-worker'
|
||||
const workerId = Number.parseInt(process.env.WORKER_ID || '0')
|
||||
|
||||
// 立即注册消息监听器,避免被异步初始化阻塞
|
||||
let isInitialized = false
|
||||
let storageManager: any
|
||||
let imageObjects: any[]
|
||||
let existingManifestMap: Map<string, PhotoManifestItem>
|
||||
let livePhotoMap: Map<string, any>
|
||||
|
||||
// 初始化函数,只在第一次收到任务时执行
|
||||
const initializeWorker = async () => {
|
||||
if (isInitialized) return
|
||||
|
||||
// 动态导入所需模块
|
||||
const [{ StorageManager }, { builderConfig }, { loadExistingManifest }] =
|
||||
await Promise.all([
|
||||
import('./storage/index.js'),
|
||||
import('@builder'),
|
||||
import('./manifest/manager.js'),
|
||||
])
|
||||
|
||||
// 在 worker 中初始化存储管理器和数据
|
||||
storageManager = new StorageManager(builderConfig.storage)
|
||||
|
||||
// 获取图片列表(worker 需要知道要处理什么)
|
||||
imageObjects = await storageManager.listImages()
|
||||
|
||||
// 获取现有 manifest 和 live photo 信息
|
||||
const existingManifest = await loadExistingManifest()
|
||||
existingManifestMap = new Map(
|
||||
existingManifest.map((item: PhotoManifestItem) => [item.s3Key, item]),
|
||||
)
|
||||
|
||||
// 检测 Live Photos
|
||||
const allObjects = await storageManager.listAllFiles()
|
||||
livePhotoMap = builderConfig.options.enableLivePhotoDetection
|
||||
? await storageManager.detectLivePhotos(allObjects)
|
||||
: new Map()
|
||||
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
const handleTask = async (message: TaskMessage): Promise<void> => {
|
||||
try {
|
||||
// 确保 worker 已初始化
|
||||
await initializeWorker()
|
||||
|
||||
// 动态导入 processPhoto(放在这里以避免阻塞消息监听)
|
||||
const { processPhoto } = await import('./photo/processor.js')
|
||||
const { logger: workerLogger } = await import('./logger/index.js')
|
||||
|
||||
const { taskIndex } = message
|
||||
|
||||
// 根据 taskIndex 获取对应的图片对象
|
||||
const obj = imageObjects[taskIndex]
|
||||
if (!obj) {
|
||||
throw new Error(`Invalid taskIndex: ${taskIndex}`)
|
||||
}
|
||||
|
||||
// 转换 StorageObject 到旧的 _Object 格式以兼容现有的 processPhoto 函数
|
||||
const legacyObj = {
|
||||
Key: obj.key,
|
||||
Size: obj.size,
|
||||
LastModified: obj.lastModified,
|
||||
ETag: obj.etag,
|
||||
}
|
||||
|
||||
// 转换 Live Photo Map
|
||||
const legacyLivePhotoMap = new Map()
|
||||
for (const [key, value] of livePhotoMap) {
|
||||
legacyLivePhotoMap.set(key, {
|
||||
Key: value.key,
|
||||
Size: value.size,
|
||||
LastModified: value.lastModified,
|
||||
ETag: value.etag,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理器选项(这些可以作为环境变量传递或使用默认值)
|
||||
const processorOptions = {
|
||||
isForceMode: process.env.FORCE_MODE === 'true',
|
||||
isForceManifest: process.env.FORCE_MANIFEST === 'true',
|
||||
isForceThumbnails: process.env.FORCE_THUMBNAILS === 'true',
|
||||
}
|
||||
|
||||
// 处理照片
|
||||
const result = await processPhoto(
|
||||
legacyObj,
|
||||
taskIndex,
|
||||
workerId,
|
||||
imageObjects.length,
|
||||
existingManifestMap,
|
||||
legacyLivePhotoMap,
|
||||
processorOptions,
|
||||
workerLogger,
|
||||
)
|
||||
|
||||
// 发送结果回主进程
|
||||
const response: TaskResult = {
|
||||
type: 'result',
|
||||
taskId: message.taskId,
|
||||
result,
|
||||
}
|
||||
|
||||
if (process.send) {
|
||||
process.send(response)
|
||||
}
|
||||
} catch (error) {
|
||||
// 发送错误回主进程
|
||||
const response: TaskResult = {
|
||||
type: 'error',
|
||||
taskId: message.taskId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
|
||||
if (process.send) {
|
||||
process.send(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量任务处理函数
|
||||
const handleBatchTask = async (message: BatchTaskMessage): Promise<void> => {
|
||||
try {
|
||||
// 确保已初始化
|
||||
await initializeWorker()
|
||||
|
||||
const results: TaskResult[] = []
|
||||
const taskPromises: Promise<void>[] = []
|
||||
|
||||
// 创建所有任务的并发执行 Promise
|
||||
for (const task of message.tasks) {
|
||||
const taskPromise = (async () => {
|
||||
try {
|
||||
const obj = imageObjects[task.taskIndex]
|
||||
|
||||
// 转换 StorageObject 到旧的 _Object 格式以兼容现有的 processPhoto 函数
|
||||
const legacyObj = {
|
||||
Key: obj.key,
|
||||
Size: obj.size,
|
||||
LastModified: obj.lastModified,
|
||||
ETag: obj.etag,
|
||||
}
|
||||
|
||||
// 转换 Live Photo Map
|
||||
const legacyLivePhotoMap = new Map()
|
||||
for (const [key, value] of livePhotoMap) {
|
||||
legacyLivePhotoMap.set(key, {
|
||||
Key: value.key,
|
||||
Size: value.size,
|
||||
LastModified: value.lastModified,
|
||||
ETag: value.etag,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理照片
|
||||
const { processPhoto } = await import('./photo/processor.js')
|
||||
const result = await processPhoto(
|
||||
legacyObj,
|
||||
task.taskIndex,
|
||||
workerId,
|
||||
imageObjects.length,
|
||||
existingManifestMap,
|
||||
legacyLivePhotoMap,
|
||||
{
|
||||
isForceMode: process.env.FORCE_MODE === 'true',
|
||||
isForceManifest: process.env.FORCE_MANIFEST === 'true',
|
||||
isForceThumbnails: process.env.FORCE_THUMBNAILS === 'true',
|
||||
},
|
||||
logger,
|
||||
)
|
||||
|
||||
// 添加成功结果
|
||||
results.push({
|
||||
type: 'result',
|
||||
taskId: task.taskId,
|
||||
result,
|
||||
})
|
||||
} catch (error) {
|
||||
// 添加错误结果
|
||||
results.push({
|
||||
type: 'error',
|
||||
taskId: task.taskId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
taskPromises.push(taskPromise)
|
||||
}
|
||||
|
||||
// 等待所有任务完成
|
||||
await Promise.all(taskPromises)
|
||||
|
||||
// 发送批量结果回主进程
|
||||
const response: BatchTaskResult = {
|
||||
type: 'batch-result',
|
||||
results,
|
||||
}
|
||||
|
||||
if (process.send) {
|
||||
process.send(response)
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果批量处理失败,为每个任务发送错误结果
|
||||
const results: TaskResult[] = message.tasks.map((task) => ({
|
||||
type: 'error',
|
||||
taskId: task.taskId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}))
|
||||
|
||||
const response: BatchTaskResult = {
|
||||
type: 'batch-result',
|
||||
results,
|
||||
}
|
||||
|
||||
if (process.send) {
|
||||
process.send(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 立即注册消息监听器
|
||||
process.on(
|
||||
'message',
|
||||
async (
|
||||
message:
|
||||
| TaskMessage
|
||||
| BatchTaskMessage
|
||||
| { type: 'shutdown' }
|
||||
| { type: 'ping' },
|
||||
) => {
|
||||
if (message.type === 'shutdown') {
|
||||
process.removeAllListeners('message')
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'ping') {
|
||||
// 响应主进程的 ping,表示 worker 已准备好
|
||||
if (process.send) {
|
||||
process.send({ type: 'pong', workerId })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'batch-task') {
|
||||
await handleBatchTask(message)
|
||||
} else if (message.type === 'task') {
|
||||
await handleTask(message)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 错误处理
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Worker uncaught exception:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('Worker unhandled rejection:', reason)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
// 告知主进程 worker 已准备好
|
||||
if (process.send) {
|
||||
process.send({ type: 'ready', workerId })
|
||||
}
|
||||
}
|
||||
27
apps/web/src/core/s3/client.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { S3ClientConfig } from '@aws-sdk/client-s3'
|
||||
import { S3Client } from '@aws-sdk/client-s3'
|
||||
import { env } from '@env'
|
||||
|
||||
// 创建 S3 客户端
|
||||
function createS3Client(): S3Client {
|
||||
if (!env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY) {
|
||||
throw new Error('S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY are required')
|
||||
}
|
||||
|
||||
const s3ClientConfig: S3ClientConfig = {
|
||||
region: env.S3_REGION,
|
||||
credentials: {
|
||||
accessKeyId: env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
||||
},
|
||||
}
|
||||
|
||||
// 如果提供了自定义端点,则使用它
|
||||
if (env.S3_ENDPOINT) {
|
||||
s3ClientConfig.endpoint = env.S3_ENDPOINT
|
||||
}
|
||||
|
||||
return new S3Client(s3ClientConfig)
|
||||
}
|
||||
|
||||
export const s3Client = createS3Client()
|
||||
10
apps/web/src/core/s3/operations.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// 重新导出适配器中的函数以保持 API 兼容性
|
||||
// 推荐使用新的 StorageManager API,这些函数将在未来版本中被弃用
|
||||
|
||||
export {
|
||||
detectLivePhotos,
|
||||
generateS3Url,
|
||||
getImageFromS3,
|
||||
listAllFilesFromS3,
|
||||
listImagesFromS3,
|
||||
} from '../storage/adapters.js'
|
||||
79
apps/web/src/core/storage/adapters.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { _Object } from '@aws-sdk/client-s3'
|
||||
|
||||
import type { Logger } from '../logger/index.js'
|
||||
import type { StorageObject } from './interfaces.js'
|
||||
import { defaultStorageManager } from './manager.js'
|
||||
|
||||
// 将 StorageObject 转换为 _Object 以保持兼容性
|
||||
function convertStorageObjectToS3Object(storageObject: StorageObject): _Object {
|
||||
return {
|
||||
Key: storageObject.key,
|
||||
Size: storageObject.size,
|
||||
LastModified: storageObject.lastModified,
|
||||
ETag: storageObject.etag,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 S3 获取图片(兼容性函数)
|
||||
* @deprecated 推荐使用 defaultStorageManager.getFile()
|
||||
*/
|
||||
export async function getImageFromS3(
|
||||
key: string,
|
||||
s3Logger?: Logger['s3'],
|
||||
): Promise<Buffer | null> {
|
||||
return defaultStorageManager.getFile(key, s3Logger)
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出 S3 中的所有图片文件(兼容性函数)
|
||||
* @deprecated 推荐使用 defaultStorageManager.listImages()
|
||||
*/
|
||||
export async function listImagesFromS3(): Promise<_Object[]> {
|
||||
const storageObjects = await defaultStorageManager.listImages()
|
||||
return storageObjects.map((obj) => convertStorageObjectToS3Object(obj))
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出 S3 中的所有文件(兼容性函数)
|
||||
* @deprecated 推荐使用 defaultStorageManager.listAllFiles()
|
||||
*/
|
||||
export async function listAllFilesFromS3(): Promise<_Object[]> {
|
||||
const storageObjects = await defaultStorageManager.listAllFiles()
|
||||
return storageObjects.map((obj) => convertStorageObjectToS3Object(obj))
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 live photo 配对(兼容性函数)
|
||||
* @deprecated 推荐使用 defaultStorageManager.detectLivePhotos()
|
||||
*/
|
||||
export function detectLivePhotos(allObjects: _Object[]): Map<string, _Object> {
|
||||
// 转换 _Object 数组为 StorageObject 数组
|
||||
const storageObjects: StorageObject[] = allObjects.map((obj) => ({
|
||||
key: obj.Key || '',
|
||||
size: obj.Size,
|
||||
lastModified: obj.LastModified,
|
||||
etag: obj.ETag,
|
||||
}))
|
||||
|
||||
// 使用存储管理器检测 Live Photos
|
||||
const livePhotoMap = defaultStorageManager
|
||||
.getProvider()
|
||||
.detectLivePhotos(storageObjects)
|
||||
|
||||
// 转换回 _Object 格式
|
||||
const result = new Map<string, _Object>()
|
||||
for (const [key, storageObject] of livePhotoMap) {
|
||||
result.set(key, convertStorageObjectToS3Object(storageObject))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 S3 公共 URL(兼容性函数)
|
||||
* @deprecated 推荐使用 defaultStorageManager.generatePublicUrl()
|
||||
*/
|
||||
export function generateS3Url(key: string): string {
|
||||
return defaultStorageManager.generatePublicUrl(key)
|
||||
}
|
||||
42
apps/web/src/core/storage/factory.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { env } from '@env'
|
||||
|
||||
import type { StorageConfig, StorageProvider } from './interfaces'
|
||||
import { GitHubStorageProvider } from './providers/github-provider.js'
|
||||
import { S3StorageProvider } from './providers/s3-provider.js'
|
||||
|
||||
export class StorageFactory {
|
||||
/**
|
||||
* 根据配置创建存储提供商实例
|
||||
* @param config 存储配置
|
||||
* @returns 存储提供商实例
|
||||
*/
|
||||
static createProvider(config: StorageConfig): StorageProvider {
|
||||
switch (config.provider) {
|
||||
case 's3': {
|
||||
return new S3StorageProvider(config)
|
||||
}
|
||||
case 'github': {
|
||||
return new GitHubStorageProvider(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于环境变量创建默认的存储提供商
|
||||
* @returns 默认存储提供商实例
|
||||
*/
|
||||
static createDefaultProvider(): StorageProvider {
|
||||
const config: StorageConfig = {
|
||||
provider: 's3',
|
||||
bucket: env.S3_BUCKET_NAME,
|
||||
region: env.S3_REGION,
|
||||
endpoint: env.S3_ENDPOINT,
|
||||
accessKeyId: env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
||||
prefix: env.S3_PREFIX,
|
||||
customDomain: env.S3_CUSTOM_DOMAIN,
|
||||
}
|
||||
|
||||
return this.createProvider(config)
|
||||
}
|
||||
}
|
||||
16
apps/web/src/core/storage/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// 导出接口
|
||||
export type {
|
||||
StorageConfig,
|
||||
StorageObject,
|
||||
StorageProvider,
|
||||
} from './interfaces.js'
|
||||
|
||||
// 导出工厂类
|
||||
export { StorageFactory } from './factory.js'
|
||||
|
||||
// 导出管理器
|
||||
export { defaultStorageManager, StorageManager } from './manager.js'
|
||||
|
||||
// 导出具体提供商(如果需要直接使用)
|
||||
export { GitHubStorageProvider } from './providers/github-provider.js'
|
||||
export { S3StorageProvider } from './providers/s3-provider.js'
|
||||
69
apps/web/src/core/storage/interfaces.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
|
||||
import type { Logger } from '../logger/index.js'
|
||||
|
||||
// 存储对象的通用接口
|
||||
export interface StorageObject {
|
||||
key: string
|
||||
size?: number
|
||||
lastModified?: Date
|
||||
etag?: string
|
||||
}
|
||||
|
||||
// 存储提供商的通用接口
|
||||
export interface StorageProvider {
|
||||
/**
|
||||
* 从存储中获取文件
|
||||
* @param key 文件的键值/路径
|
||||
* @param logger 可选的日志记录器
|
||||
* @returns 文件的 Buffer 数据,如果不存在则返回 null
|
||||
*/
|
||||
getFile: (key: string, logger?: Logger['s3']) => Promise<Buffer | null>
|
||||
|
||||
/**
|
||||
* 列出存储中的所有图片文件
|
||||
* @returns 图片文件对象数组
|
||||
*/
|
||||
listImages: () => Promise<StorageObject[]>
|
||||
|
||||
/**
|
||||
* 列出存储中的所有文件
|
||||
* @returns 所有文件对象数组
|
||||
*/
|
||||
listAllFiles: () => Promise<StorageObject[]>
|
||||
|
||||
/**
|
||||
* 生成文件的公共访问 URL
|
||||
* @param key 文件的键值/路径
|
||||
* @returns 公共访问 URL
|
||||
*/
|
||||
generatePublicUrl: (key: string) => string
|
||||
|
||||
/**
|
||||
* 检测 Live Photos 配对
|
||||
* @param allObjects 所有文件对象
|
||||
* @returns Live Photo 配对映射 (图片 key -> 视频对象)
|
||||
*/
|
||||
detectLivePhotos: (allObjects: StorageObject[]) => Map<string, StorageObject>
|
||||
}
|
||||
|
||||
export type S3Config = {
|
||||
provider: 's3'
|
||||
bucket?: string
|
||||
region?: string
|
||||
endpoint?: string
|
||||
accessKeyId?: string
|
||||
secretAccessKey?: string
|
||||
prefix?: string
|
||||
customDomain?: string
|
||||
}
|
||||
|
||||
export type GitHubConfig = {
|
||||
provider: 'github'
|
||||
owner: string
|
||||
repo: string
|
||||
branch?: string
|
||||
token?: string
|
||||
path?: string
|
||||
useRawUrl?: boolean
|
||||
}
|
||||
export type StorageConfig = S3Config | GitHubConfig
|
||||
83
apps/web/src/core/storage/manager.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { Logger } from '../logger/index.js'
|
||||
import { StorageFactory } from './factory.js'
|
||||
import type {
|
||||
StorageConfig,
|
||||
StorageObject,
|
||||
StorageProvider,
|
||||
} from './interfaces.js'
|
||||
|
||||
export class StorageManager {
|
||||
private provider: StorageProvider
|
||||
|
||||
constructor(config?: StorageConfig) {
|
||||
this.provider = config
|
||||
? StorageFactory.createProvider(config)
|
||||
: StorageFactory.createDefaultProvider()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从存储中获取文件
|
||||
* @param key 文件的键值/路径
|
||||
* @param logger 可选的日志记录器
|
||||
* @returns 文件的 Buffer 数据,如果不存在则返回 null
|
||||
*/
|
||||
async getFile(key: string, logger?: Logger['s3']): Promise<Buffer | null> {
|
||||
return this.provider.getFile(key, logger)
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出存储中的所有图片文件
|
||||
* @returns 图片文件对象数组
|
||||
*/
|
||||
async listImages(): Promise<StorageObject[]> {
|
||||
return this.provider.listImages()
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出存储中的所有文件
|
||||
* @returns 所有文件对象数组
|
||||
*/
|
||||
async listAllFiles(): Promise<StorageObject[]> {
|
||||
return this.provider.listAllFiles()
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件的公共访问 URL
|
||||
* @param key 文件的键值/路径
|
||||
* @returns 公共访问 URL
|
||||
*/
|
||||
generatePublicUrl(key: string): string {
|
||||
return this.provider.generatePublicUrl(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 Live Photos 配对
|
||||
* @param allObjects 所有文件对象(可选,如果不提供则自动获取)
|
||||
* @returns Live Photo 配对映射 (图片 key -> 视频对象)
|
||||
*/
|
||||
async detectLivePhotos(
|
||||
allObjects?: StorageObject[],
|
||||
): Promise<Map<string, StorageObject>> {
|
||||
const objects = allObjects || (await this.listAllFiles())
|
||||
return this.provider.detectLivePhotos(objects)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前使用的存储提供商
|
||||
* @returns 存储提供商实例
|
||||
*/
|
||||
getProvider(): StorageProvider {
|
||||
return this.provider
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换存储提供商
|
||||
* @param config 新的存储配置
|
||||
*/
|
||||
switchProvider(config: StorageConfig): void {
|
||||
this.provider = StorageFactory.createProvider(config)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出默认的存储管理器实例
|
||||
export const defaultStorageManager = new StorageManager()
|
||||
157
apps/web/src/core/storage/providers/README.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# 存储提供商
|
||||
|
||||
本目录包含各种存储服务的具体实现。
|
||||
|
||||
## S3 存储提供商
|
||||
|
||||
支持 AWS S3 和兼容 S3 API 的存储服务(如 MinIO、阿里云 OSS 等)。
|
||||
|
||||
### 配置示例
|
||||
|
||||
```typescript
|
||||
const s3Config: StorageConfig = {
|
||||
provider: 's3',
|
||||
bucket: 'my-bucket',
|
||||
region: 'us-east-1',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
accessKeyId: 'your-access-key',
|
||||
secretAccessKey: 'your-secret-key',
|
||||
prefix: 'photos/',
|
||||
customDomain: 'https://cdn.example.com',
|
||||
}
|
||||
```
|
||||
|
||||
## GitHub 存储提供商
|
||||
|
||||
将照片存储在 GitHub 仓库中,利用 GitHub 的免费存储空间和全球 CDN。
|
||||
|
||||
### 特点
|
||||
|
||||
- ✅ 免费存储空间(GitHub 仓库限制为 1GB)
|
||||
- ✅ 全球 CDN 支持
|
||||
- ✅ 版本控制
|
||||
- ✅ 公开访问(通过 raw.githubusercontent.com)
|
||||
- ✅ 支持私有仓库(需要访问令牌)
|
||||
- ⚠️ GitHub API 有请求频率限制
|
||||
- ⚠️ 不适合大量文件或频繁更新
|
||||
|
||||
### 配置示例
|
||||
|
||||
```typescript
|
||||
const githubConfig: StorageConfig = {
|
||||
provider: 'github',
|
||||
github: {
|
||||
owner: 'your-username', // GitHub 用户名或组织名
|
||||
repo: 'photo-gallery', // 仓库名称
|
||||
branch: 'main', // 分支名称(可选,默认 'main')
|
||||
token: 'ghp_xxxxxxxxxxxx', // GitHub 访问令牌(可选)
|
||||
path: 'photos', // 照片存储路径(可选)
|
||||
useRawUrl: true, // 使用 raw.githubusercontent.com(默认 true)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 设置步骤
|
||||
|
||||
1. **创建 GitHub 仓库**
|
||||
```bash
|
||||
# 创建新仓库(或使用现有仓库)
|
||||
git clone https://github.com/your-username/photo-gallery.git
|
||||
cd photo-gallery
|
||||
mkdir photos
|
||||
```
|
||||
|
||||
2. **获取 GitHub 访问令牌**(可选,但推荐)
|
||||
- 访问 GitHub Settings > Developer settings > Personal access tokens
|
||||
- 创建新的 Fine-grained personal access token
|
||||
- 选择你的仓库
|
||||
- 赋予 "Contents" 权限(读写)
|
||||
|
||||
3. **配置环境变量**
|
||||
```bash
|
||||
export GITHUB_TOKEN="ghp_xxxxxxxxxxxx"
|
||||
```
|
||||
|
||||
4. **更新配置文件**
|
||||
```typescript
|
||||
// builder.config.ts
|
||||
export const builderConfig: BuilderConfig = {
|
||||
...defaultBuilderConfig,
|
||||
storage: {
|
||||
provider: 'github',
|
||||
github: {
|
||||
owner: 'your-username',
|
||||
repo: 'photo-gallery',
|
||||
branch: 'main',
|
||||
token: process.env.GITHUB_TOKEN,
|
||||
path: 'photos',
|
||||
useRawUrl: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```typescript
|
||||
import { GitHubStorageProvider } from '@/core/storage'
|
||||
|
||||
const githubProvider = new GitHubStorageProvider({
|
||||
provider: 'github',
|
||||
github: {
|
||||
owner: 'octocat',
|
||||
repo: 'Hello-World',
|
||||
branch: 'main',
|
||||
token: 'your-token',
|
||||
path: 'images',
|
||||
},
|
||||
})
|
||||
|
||||
// 获取文件
|
||||
const buffer = await githubProvider.getFile('sunset.jpg')
|
||||
|
||||
// 列出所有图片
|
||||
const images = await githubProvider.listImages()
|
||||
|
||||
// 生成公共 URL
|
||||
const url = githubProvider.generatePublicUrl('sunset.jpg')
|
||||
// 结果:https://raw.githubusercontent.com/octocat/Hello-World/main/images/sunset.jpg
|
||||
```
|
||||
|
||||
### API 限制
|
||||
|
||||
GitHub API 有以下限制:
|
||||
|
||||
- **未认证请求**: 60 requests/hour/IP
|
||||
- **认证请求**: 5,000 requests/hour/token
|
||||
- **文件大小**: 最大 100MB(通过 API)
|
||||
- **仓库大小**: 建议不超过 1GB
|
||||
|
||||
### 最佳实践
|
||||
|
||||
1. **使用访问令牌**: 提高 API 请求限制
|
||||
2. **合理组织目录结构**: 便于管理和访问
|
||||
3. **定期清理**: 删除不需要的文件以节省空间
|
||||
4. **监控 API 使用**: 避免超出请求限制
|
||||
5. **考虑文件大小**: 对于大文件,考虑使用其他存储服务
|
||||
|
||||
### 错误处理
|
||||
|
||||
GitHub 存储提供商会处理以下错误:
|
||||
|
||||
- **404 Not Found**: 文件或仓库不存在
|
||||
- **403 Forbidden**: 权限不足或 API 限制
|
||||
- **422 Unprocessable Entity**: 请求格式错误
|
||||
- **500+ Server Error**: GitHub 服务器错误
|
||||
|
||||
### 与其他提供商的对比
|
||||
|
||||
| 特性 | S3 | GitHub |
|
||||
|------|----|----|
|
||||
| 存储空间 | 按需付费 | 1GB 免费 |
|
||||
| CDN | 额外付费 | 免费全球 CDN |
|
||||
| API 限制 | 很高 | 有限制 |
|
||||
| 适用场景 | 生产环境 | 小型项目、演示 |
|
||||
| 设置复杂度 | 中等 | 简单 |
|
||||
|
||||
选择存储提供商时,请根据你的具体需求和预算进行选择。
|
||||
278
apps/web/src/core/storage/providers/github-provider.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { SUPPORTED_FORMATS } from '../../constants/index.js'
|
||||
import type { Logger } from '../../logger/index.js'
|
||||
import type {
|
||||
GitHubConfig,
|
||||
StorageObject,
|
||||
StorageProvider,
|
||||
} from '../interfaces.js'
|
||||
|
||||
// GitHub API 响应类型
|
||||
interface GitHubFileContent {
|
||||
name: string
|
||||
path: string
|
||||
sha: string
|
||||
size: number
|
||||
url: string
|
||||
html_url: string
|
||||
git_url: string
|
||||
download_url: string | null
|
||||
type: 'file' | 'dir'
|
||||
content?: string
|
||||
encoding?: string
|
||||
}
|
||||
|
||||
interface GitHubDirectoryContent extends GitHubFileContent {
|
||||
type: 'dir'
|
||||
}
|
||||
|
||||
type GitHubContent = GitHubFileContent | GitHubDirectoryContent
|
||||
|
||||
export class GitHubStorageProvider implements StorageProvider {
|
||||
private config: GitHubConfig
|
||||
private githubConfig: NonNullable<GitHubConfig>
|
||||
private baseApiUrl: string
|
||||
|
||||
constructor(config: GitHubConfig) {
|
||||
this.config = config
|
||||
|
||||
if (config.provider !== 'github') {
|
||||
throw new Error('GitHub 配置不能为空')
|
||||
}
|
||||
|
||||
this.githubConfig = {
|
||||
branch: 'main',
|
||||
path: '',
|
||||
useRawUrl: true,
|
||||
...config,
|
||||
}
|
||||
|
||||
this.baseApiUrl = `https://api.github.com/repos/${this.githubConfig.owner}/${this.githubConfig.repo}`
|
||||
|
||||
if (!this.githubConfig.owner || !this.githubConfig.repo) {
|
||||
throw new Error('GitHub owner 和 repo 配置不能为空')
|
||||
}
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'PhotoGallery/1.0',
|
||||
}
|
||||
|
||||
if (this.githubConfig.token) {
|
||||
headers['Authorization'] = `Bearer ${this.githubConfig.token}`
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
private normalizeKey(key: string): string {
|
||||
// 移除开头的斜杠,确保路径格式正确
|
||||
return key.replace(/^\/+/, '')
|
||||
}
|
||||
|
||||
private getFullPath(key: string): string {
|
||||
const normalizedKey = this.normalizeKey(key)
|
||||
if (this.githubConfig.path) {
|
||||
return `${this.githubConfig.path}/${normalizedKey}`.replaceAll(
|
||||
/\/+/g,
|
||||
'/',
|
||||
)
|
||||
}
|
||||
return normalizedKey
|
||||
}
|
||||
|
||||
async getFile(key: string, logger?: Logger['s3']): Promise<Buffer | null> {
|
||||
const log = logger
|
||||
|
||||
try {
|
||||
log?.info(`下载文件:${key}`)
|
||||
const startTime = Date.now()
|
||||
|
||||
const fullPath = this.getFullPath(key)
|
||||
const url = `${this.baseApiUrl}/contents/${fullPath}?ref=${this.githubConfig.branch}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: this.getAuthHeaders(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
log?.warn(`文件不存在:${key}`)
|
||||
return null
|
||||
}
|
||||
throw new Error(
|
||||
`GitHub API 请求失败:${response.status} ${response.statusText}`,
|
||||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GitHubFileContent
|
||||
|
||||
if (data.type !== 'file') {
|
||||
log?.error(`路径不是文件:${key}`)
|
||||
return null
|
||||
}
|
||||
|
||||
let fileBuffer: Buffer
|
||||
|
||||
if (data.download_url) {
|
||||
// 使用 download_url 获取文件内容(推荐方式)
|
||||
const fileResponse = await fetch(data.download_url)
|
||||
if (!fileResponse.ok) {
|
||||
throw new Error(
|
||||
`下载文件失败:${fileResponse.status} ${fileResponse.statusText}`,
|
||||
)
|
||||
}
|
||||
const arrayBuffer = await fileResponse.arrayBuffer()
|
||||
fileBuffer = Buffer.from(arrayBuffer)
|
||||
} else if (data.content && data.encoding === 'base64') {
|
||||
// 从 API 响应中解码 base64 内容
|
||||
fileBuffer = Buffer.from(data.content, 'base64')
|
||||
} else {
|
||||
throw new Error('无法获取文件内容')
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
const sizeKB = Math.round(fileBuffer.length / 1024)
|
||||
log?.success(`下载完成:${key} (${sizeKB}KB, ${duration}ms)`)
|
||||
|
||||
return fileBuffer
|
||||
} catch (error) {
|
||||
log?.error(`下载失败:${key}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async listImages(): Promise<StorageObject[]> {
|
||||
const allFiles = await this.listAllFiles()
|
||||
|
||||
// 过滤出图片文件
|
||||
return allFiles.filter((file) => {
|
||||
const ext = path.extname(file.key).toLowerCase()
|
||||
return SUPPORTED_FORMATS.has(ext)
|
||||
})
|
||||
}
|
||||
|
||||
async listAllFiles(): Promise<StorageObject[]> {
|
||||
const files: StorageObject[] = []
|
||||
const basePath = this.githubConfig.path || ''
|
||||
|
||||
await this.listFilesRecursive(basePath, files)
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
private async listFilesRecursive(
|
||||
dirPath: string,
|
||||
files: StorageObject[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const url = `${this.baseApiUrl}/contents/${dirPath}?ref=${this.githubConfig.branch}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: this.getAuthHeaders(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
// 目录不存在,返回空数组
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
`GitHub API 请求失败:${response.status} ${response.statusText}`,
|
||||
)
|
||||
}
|
||||
|
||||
const contents = (await response.json()) as GitHubContent[]
|
||||
|
||||
for (const item of contents) {
|
||||
if (item.type === 'file') {
|
||||
// 计算相对于配置路径的 key
|
||||
let key = item.path
|
||||
if (this.githubConfig.path) {
|
||||
key = item.path.replace(
|
||||
new RegExp(`^${this.githubConfig.path}/`),
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
files.push({
|
||||
key,
|
||||
size: item.size,
|
||||
// GitHub API 不直接提供最后修改时间,使用当前时间或从其他 API 获取
|
||||
lastModified: new Date(),
|
||||
etag: item.sha,
|
||||
})
|
||||
} else if (item.type === 'dir') {
|
||||
// 递归处理子目录
|
||||
await this.listFilesRecursive(item.path, files)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`列出目录 ${dirPath} 失败:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
generatePublicUrl(key: string): string {
|
||||
const fullPath = this.getFullPath(key)
|
||||
|
||||
if (this.githubConfig.useRawUrl) {
|
||||
// 使用 raw.githubusercontent.com 获取文件
|
||||
return `https://raw.githubusercontent.com/${this.githubConfig.owner}/${this.githubConfig.repo}/${this.githubConfig.branch}/${fullPath}`
|
||||
} else {
|
||||
// 使用 GitHub 的 blob URL
|
||||
return `https://github.com/${this.githubConfig.owner}/${this.githubConfig.repo}/blob/${this.githubConfig.branch}/${fullPath}`
|
||||
}
|
||||
}
|
||||
|
||||
detectLivePhotos(allObjects: StorageObject[]): Map<string, StorageObject> {
|
||||
const livePhotoMap = new Map<string, StorageObject>()
|
||||
|
||||
// 按目录和基础文件名分组所有文件
|
||||
const fileGroups = new Map<string, StorageObject[]>()
|
||||
|
||||
for (const obj of allObjects) {
|
||||
if (!obj.key) continue
|
||||
|
||||
const dir = path.dirname(obj.key)
|
||||
const basename = path.basename(obj.key, path.extname(obj.key))
|
||||
const groupKey = `${dir}/${basename}`
|
||||
|
||||
if (!fileGroups.has(groupKey)) {
|
||||
fileGroups.set(groupKey, [])
|
||||
}
|
||||
fileGroups.get(groupKey)!.push(obj)
|
||||
}
|
||||
|
||||
// 在每个分组中寻找图片 + 视频配对
|
||||
for (const files of fileGroups.values()) {
|
||||
let imageFile: StorageObject | null = null
|
||||
let videoFile: StorageObject | null = null
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.key) continue
|
||||
|
||||
const ext = path.extname(file.key).toLowerCase()
|
||||
|
||||
// 检查是否为支持的图片格式
|
||||
if (SUPPORTED_FORMATS.has(ext)) {
|
||||
imageFile = file
|
||||
}
|
||||
// 检查是否为 .mov 视频文件
|
||||
else if (ext === '.mov') {
|
||||
videoFile = file
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到配对,记录为 live photo
|
||||
if (imageFile && videoFile && imageFile.key) {
|
||||
livePhotoMap.set(imageFile.key, videoFile)
|
||||
}
|
||||
}
|
||||
|
||||
return livePhotoMap
|
||||
}
|
||||
}
|
||||
194
apps/web/src/core/storage/providers/s3-provider.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import type { _Object } from '@aws-sdk/client-s3'
|
||||
import { GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
|
||||
|
||||
import { SUPPORTED_FORMATS } from '../../constants/index.js'
|
||||
import type { Logger } from '../../logger/index.js'
|
||||
import { s3Client } from '../../s3/client.js'
|
||||
import type {
|
||||
S3Config,
|
||||
StorageObject,
|
||||
StorageProvider,
|
||||
} from '../interfaces'
|
||||
|
||||
// 将 AWS S3 对象转换为通用存储对象
|
||||
function convertS3ObjectToStorageObject(s3Object: _Object): StorageObject {
|
||||
return {
|
||||
key: s3Object.Key || '',
|
||||
size: s3Object.Size,
|
||||
lastModified: s3Object.LastModified,
|
||||
etag: s3Object.ETag,
|
||||
}
|
||||
}
|
||||
|
||||
export class S3StorageProvider implements StorageProvider {
|
||||
private config: S3Config
|
||||
|
||||
constructor(config: S3Config) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
async getFile(key: string, logger?: Logger['s3']): Promise<Buffer | null> {
|
||||
const log = logger
|
||||
|
||||
try {
|
||||
log?.info(`下载文件:${key}`)
|
||||
const startTime = Date.now()
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.config.bucket,
|
||||
Key: key,
|
||||
})
|
||||
|
||||
const response = await s3Client.send(command)
|
||||
|
||||
if (!response.Body) {
|
||||
log?.error(`S3 响应中没有 Body: ${key}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// 处理不同类型的 Body
|
||||
if (response.Body instanceof Buffer) {
|
||||
const duration = Date.now() - startTime
|
||||
const sizeKB = Math.round(response.Body.length / 1024)
|
||||
log?.success(`下载完成:${key} (${sizeKB}KB, ${duration}ms)`)
|
||||
return response.Body
|
||||
}
|
||||
|
||||
// 如果是 Readable stream
|
||||
const chunks: Uint8Array[] = []
|
||||
const stream = response.Body as NodeJS.ReadableStream
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', (chunk: Uint8Array) => {
|
||||
chunks.push(chunk)
|
||||
})
|
||||
|
||||
stream.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks)
|
||||
const duration = Date.now() - startTime
|
||||
const sizeKB = Math.round(buffer.length / 1024)
|
||||
log?.success(`下载完成:${key} (${sizeKB}KB, ${duration}ms)`)
|
||||
resolve(buffer)
|
||||
})
|
||||
|
||||
stream.on('error', (error) => {
|
||||
log?.error(`下载失败:${key}`, error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
log?.error(`下载失败:${key}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async listImages(): Promise<StorageObject[]> {
|
||||
const listCommand = new ListObjectsV2Command({
|
||||
Bucket: this.config.bucket,
|
||||
Prefix: this.config.prefix,
|
||||
MaxKeys: 1000, // 最多获取 1000 张照片
|
||||
})
|
||||
|
||||
const listResponse = await s3Client.send(listCommand)
|
||||
const objects = listResponse.Contents || []
|
||||
|
||||
// 过滤出图片文件并转换为通用格式
|
||||
const imageObjects = objects
|
||||
.filter((obj: _Object) => {
|
||||
if (!obj.Key) return false
|
||||
const ext = path.extname(obj.Key).toLowerCase()
|
||||
return SUPPORTED_FORMATS.has(ext)
|
||||
})
|
||||
.map((obj) => convertS3ObjectToStorageObject(obj))
|
||||
|
||||
return imageObjects
|
||||
}
|
||||
|
||||
async listAllFiles(): Promise<StorageObject[]> {
|
||||
const listCommand = new ListObjectsV2Command({
|
||||
Bucket: this.config.bucket,
|
||||
Prefix: this.config.prefix,
|
||||
MaxKeys: 1000,
|
||||
})
|
||||
|
||||
const listResponse = await s3Client.send(listCommand)
|
||||
const objects = listResponse.Contents || []
|
||||
|
||||
return objects.map((obj) => convertS3ObjectToStorageObject(obj))
|
||||
}
|
||||
|
||||
generatePublicUrl(key: string): string {
|
||||
// 如果设置了自定义域名,直接使用自定义域名
|
||||
if (this.config.customDomain) {
|
||||
const customDomain = this.config.customDomain.replace(/\/$/, '') // 移除末尾的斜杠
|
||||
return `${customDomain}/${this.config.bucket}/${key}`
|
||||
}
|
||||
|
||||
// 如果使用自定义端点,构建相应的 URL
|
||||
const { endpoint } = this.config
|
||||
|
||||
if (!endpoint) {
|
||||
// 默认 AWS S3 端点
|
||||
return `https://${this.config.bucket}.s3.${this.config.region}.amazonaws.com/${key}`
|
||||
}
|
||||
|
||||
// 检查是否是标准 AWS S3 端点
|
||||
if (endpoint.includes('amazonaws.com')) {
|
||||
return `https://${this.config.bucket}.s3.${this.config.region}.amazonaws.com/${key}`
|
||||
}
|
||||
|
||||
// 对于自定义端点(如 MinIO 等)
|
||||
const baseUrl = endpoint.replace(/\/$/, '') // 移除末尾的斜杠
|
||||
return `${baseUrl}/${this.config.bucket}/${key}`
|
||||
}
|
||||
|
||||
detectLivePhotos(allObjects: StorageObject[]): Map<string, StorageObject> {
|
||||
const livePhotoMap = new Map<string, StorageObject>() // image key -> video object
|
||||
|
||||
// 按目录和基础文件名分组所有文件
|
||||
const fileGroups = new Map<string, StorageObject[]>()
|
||||
|
||||
for (const obj of allObjects) {
|
||||
if (!obj.key) continue
|
||||
|
||||
const dir = path.dirname(obj.key)
|
||||
const basename = path.basename(obj.key, path.extname(obj.key))
|
||||
const groupKey = `${dir}/${basename}`
|
||||
|
||||
if (!fileGroups.has(groupKey)) {
|
||||
fileGroups.set(groupKey, [])
|
||||
}
|
||||
fileGroups.get(groupKey)!.push(obj)
|
||||
}
|
||||
|
||||
// 在每个分组中寻找图片 + 视频配对
|
||||
for (const files of fileGroups.values()) {
|
||||
let imageFile: StorageObject | null = null
|
||||
let videoFile: StorageObject | null = null
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.key) continue
|
||||
|
||||
const ext = path.extname(file.key).toLowerCase()
|
||||
|
||||
// 检查是否为支持的图片格式
|
||||
if (SUPPORTED_FORMATS.has(ext)) {
|
||||
imageFile = file
|
||||
}
|
||||
// 检查是否为 .mov 视频文件
|
||||
else if (ext === '.mov') {
|
||||
videoFile = file
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到配对,记录为 live photo
|
||||
if (imageFile && videoFile && imageFile.key) {
|
||||
livePhotoMap.set(imageFile.key, videoFile)
|
||||
}
|
||||
}
|
||||
|
||||
return livePhotoMap
|
||||
}
|
||||
}
|
||||
48
apps/web/src/core/types/photo.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Exif } from 'exif-reader'
|
||||
|
||||
export interface PhotoInfo {
|
||||
title: string
|
||||
dateTaken: string
|
||||
views: number
|
||||
tags: string[]
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ImageMetadata {
|
||||
width: number
|
||||
height: number
|
||||
format: string
|
||||
}
|
||||
|
||||
export interface PhotoManifestItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
dateTaken: string
|
||||
views: number
|
||||
tags: string[]
|
||||
originalUrl: string
|
||||
thumbnailUrl: string | null
|
||||
blurhash: string | null
|
||||
width: number
|
||||
height: number
|
||||
aspectRatio: number
|
||||
s3Key: string
|
||||
lastModified: string
|
||||
size: number
|
||||
exif: Exif | null
|
||||
isLivePhoto?: boolean
|
||||
livePhotoVideoUrl?: string
|
||||
livePhotoVideoS3Key?: string
|
||||
}
|
||||
|
||||
export interface ProcessPhotoResult {
|
||||
item: PhotoManifestItem | null
|
||||
type: 'processed' | 'skipped' | 'new' | 'failed'
|
||||
}
|
||||
|
||||
export interface ThumbnailResult {
|
||||
thumbnailUrl: string | null
|
||||
thumbnailBuffer: Buffer | null
|
||||
blurhash: string | null
|
||||
}
|
||||
462
apps/web/src/core/worker/cluster-pool.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
/* eslint-disable unicorn/prefer-event-target */
|
||||
import type { Worker } from 'node:cluster'
|
||||
import cluster from 'node:cluster'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import process from 'node:process'
|
||||
|
||||
import type { Logger } from '../logger/index.js'
|
||||
|
||||
export interface ClusterPoolOptions {
|
||||
concurrency: number
|
||||
totalTasks: number
|
||||
workerEnv?: Record<string, string> // 传递给 worker 的环境变量
|
||||
workerConcurrency?: number // 每个 worker 内部的并发数
|
||||
}
|
||||
|
||||
export interface WorkerReadyMessage {
|
||||
type: 'ready' | 'pong'
|
||||
workerId: number
|
||||
}
|
||||
|
||||
export interface TaskMessage {
|
||||
type: 'task'
|
||||
taskId: string
|
||||
taskIndex: number
|
||||
workerId: number
|
||||
}
|
||||
|
||||
export interface BatchTaskMessage {
|
||||
type: 'batch-task'
|
||||
tasks: Array<{
|
||||
taskId: string
|
||||
taskIndex: number
|
||||
}>
|
||||
workerId: number
|
||||
}
|
||||
|
||||
export interface TaskResult {
|
||||
type: 'result' | 'error'
|
||||
taskId: string
|
||||
result?: any
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BatchTaskResult {
|
||||
type: 'batch-result'
|
||||
results: TaskResult[]
|
||||
}
|
||||
|
||||
export interface WorkerStats {
|
||||
workerId: number
|
||||
processedTasks: number
|
||||
isIdle: boolean
|
||||
isReady: boolean
|
||||
}
|
||||
|
||||
// 基于 Node.js cluster 的 Worker 池管理器
|
||||
export class ClusterPool<T> extends EventEmitter {
|
||||
private concurrency: number
|
||||
private totalTasks: number
|
||||
private workerEnv: Record<string, string>
|
||||
private workerConcurrency: number
|
||||
private logger: Logger
|
||||
|
||||
private taskQueue: Array<{ taskIndex: number }> = []
|
||||
private workers = new Map<number, Worker>()
|
||||
private workerStats = new Map<number, WorkerStats>()
|
||||
private pendingTasks = new Map<
|
||||
string,
|
||||
{ resolve: (value: T) => void; reject: (error: Error) => void }
|
||||
>()
|
||||
private results: T[] = []
|
||||
private completedTasks = 0
|
||||
private isShuttingDown = false
|
||||
private readyWorkers = new Set<number>()
|
||||
private workerTaskCounts = new Map<number, number>() // 追踪每个 worker 当前正在处理的任务数
|
||||
|
||||
constructor(options: ClusterPoolOptions, logger: Logger) {
|
||||
super()
|
||||
this.concurrency = options.concurrency
|
||||
this.totalTasks = options.totalTasks
|
||||
this.workerEnv = options.workerEnv || {}
|
||||
this.workerConcurrency = options.workerConcurrency || 5 // 默认每个 worker 同时处理 5 个任务
|
||||
this.logger = logger
|
||||
|
||||
this.results = Array.from({ length: this.totalTasks })
|
||||
}
|
||||
|
||||
async execute(): Promise<T[]> {
|
||||
this.logger.main.info(
|
||||
`开始集群模式处理任务,进程数:${this.concurrency},总任务数:${this.totalTasks}`,
|
||||
)
|
||||
|
||||
// 准备任务队列 - 只包含 taskIndex
|
||||
for (let i = 0; i < this.totalTasks; i++) {
|
||||
this.taskQueue.push({
|
||||
taskIndex: i,
|
||||
})
|
||||
}
|
||||
|
||||
// 启动 worker 进程
|
||||
await this.startWorkers()
|
||||
|
||||
// 等待所有 worker 准备好
|
||||
await this.waitForWorkersReady()
|
||||
|
||||
// 等待所有任务完成
|
||||
return new Promise((resolve, reject) => {
|
||||
this.on('allTasksCompleted', () => {
|
||||
this.logger.main.success(`所有任务完成,开始关闭进程池`)
|
||||
this.shutdown()
|
||||
.then(() => {
|
||||
resolve(this.results)
|
||||
})
|
||||
.catch(reject)
|
||||
})
|
||||
|
||||
this.on('error', reject)
|
||||
|
||||
// 开始分发任务
|
||||
this.distributeInitialTasks()
|
||||
})
|
||||
}
|
||||
|
||||
private async startWorkers(): Promise<void> {
|
||||
// 设置 cluster 环境变量以启用 worker 模式
|
||||
cluster.setupPrimary({
|
||||
exec: process.argv[1], // 使用当前脚本 (CLI) 作为 worker
|
||||
args: ['--cluster-worker'], // 传递 worker 标识参数
|
||||
silent: false,
|
||||
})
|
||||
|
||||
// 根据任务数量和每个 worker 的并发能力决定启动多少个 worker
|
||||
// 需要的 worker 数 = Math.ceil(总任务数 / 每个 worker 并发数)
|
||||
// 但不能超过 concurrency 限制
|
||||
const requiredWorkers = Math.ceil(this.totalTasks / this.workerConcurrency)
|
||||
const workersToStart = Math.min(this.concurrency, requiredWorkers)
|
||||
|
||||
this.logger.main.info(
|
||||
`计算 worker 数量:总任务 ${this.totalTasks},每个 worker 并发 ${this.workerConcurrency},需要 ${requiredWorkers} 个,实际启动 ${workersToStart} 个`,
|
||||
)
|
||||
|
||||
for (let i = 1; i <= workersToStart; i++) {
|
||||
await this.createWorker(i)
|
||||
}
|
||||
}
|
||||
|
||||
private async createWorker(workerId: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = cluster.fork({
|
||||
WORKER_ID: workerId.toString(),
|
||||
CLUSTER_WORKER: 'true',
|
||||
WORKER_CONCURRENCY: this.workerConcurrency.toString(),
|
||||
...this.workerEnv, // 传递自定义环境变量
|
||||
})
|
||||
|
||||
this.workers.set(workerId, worker)
|
||||
this.workerStats.set(workerId, {
|
||||
workerId,
|
||||
processedTasks: 0,
|
||||
isIdle: true,
|
||||
isReady: false,
|
||||
})
|
||||
this.workerTaskCounts.set(workerId, 0) // 初始化任务计数
|
||||
|
||||
const workerLogger = this.logger.worker(workerId)
|
||||
|
||||
worker.on('online', () => {
|
||||
workerLogger.start(
|
||||
`Worker ${workerId} 进程启动 (PID: ${worker.process?.pid})`,
|
||||
)
|
||||
resolve()
|
||||
})
|
||||
|
||||
worker.on(
|
||||
'message',
|
||||
(message: TaskResult | BatchTaskResult | WorkerReadyMessage) => {
|
||||
if (message.type === 'ready' || message.type === 'pong') {
|
||||
this.handleWorkerReady(workerId, message as WorkerReadyMessage)
|
||||
} else if (message.type === 'batch-result') {
|
||||
this.handleWorkerBatchResult(workerId, message as BatchTaskResult)
|
||||
} else {
|
||||
this.handleWorkerMessage(workerId, message as TaskResult)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
worker.on('error', (error) => {
|
||||
workerLogger.error(`Worker ${workerId} 进程错误:`, error)
|
||||
this.handleWorkerError(workerId, error)
|
||||
})
|
||||
|
||||
worker.on('exit', (code, signal) => {
|
||||
if (!this.isShuttingDown) {
|
||||
workerLogger.error(
|
||||
`Worker ${workerId} 意外退出 (code: ${code}, signal: ${signal})`,
|
||||
)
|
||||
// 重启 worker
|
||||
setTimeout(() => this.createWorker(workerId), 1000)
|
||||
} else {
|
||||
workerLogger.info(`Worker ${workerId} 正常退出`)
|
||||
}
|
||||
})
|
||||
|
||||
// 设置超时
|
||||
setTimeout(() => {
|
||||
if (!worker.isDead()) {
|
||||
reject(new Error(`Worker ${workerId} 启动超时`))
|
||||
}
|
||||
}, 10000)
|
||||
})
|
||||
}
|
||||
|
||||
private handleWorkerReady(
|
||||
workerId: number,
|
||||
_message: WorkerReadyMessage,
|
||||
): void {
|
||||
const stats = this.workerStats.get(workerId)
|
||||
const workerLogger = this.logger.worker(workerId)
|
||||
|
||||
if (stats) {
|
||||
stats.isReady = true
|
||||
this.readyWorkers.add(workerId)
|
||||
workerLogger.info(`Worker ${workerId} 已准备就绪`)
|
||||
this.emit('workerReady', workerId)
|
||||
}
|
||||
}
|
||||
|
||||
private distributeInitialTasks(): void {
|
||||
// 为每个 worker 分配初始任务批次
|
||||
for (const [workerId] of this.workers) {
|
||||
this.assignBatchTasksToWorker(workerId)
|
||||
}
|
||||
}
|
||||
|
||||
private assignBatchTasksToWorker(workerId: number): void {
|
||||
if (this.taskQueue.length === 0) return
|
||||
|
||||
const worker = this.workers.get(workerId)
|
||||
const stats = this.workerStats.get(workerId)
|
||||
const currentTaskCount = this.workerTaskCounts.get(workerId) || 0
|
||||
|
||||
if (!worker || !stats || !stats.isReady) return
|
||||
|
||||
// 如果当前 worker 的任务数已达到并发限制,则不分配新任务
|
||||
if (currentTaskCount >= this.workerConcurrency) return
|
||||
|
||||
// 计算可以分配的任务数量
|
||||
const availableSlots = this.workerConcurrency - currentTaskCount
|
||||
const tasksToAssign = Math.min(availableSlots, this.taskQueue.length)
|
||||
|
||||
if (tasksToAssign === 0) return
|
||||
|
||||
// 分配一批任务
|
||||
const tasks: Array<{ taskId: string; taskIndex: number }> = []
|
||||
for (let i = 0; i < tasksToAssign; i++) {
|
||||
const task = this.taskQueue.shift()
|
||||
if (!task) break
|
||||
|
||||
const taskId = `${workerId}-${task.taskIndex}-${Date.now()}-${i}`
|
||||
tasks.push({
|
||||
taskId,
|
||||
taskIndex: task.taskIndex,
|
||||
})
|
||||
|
||||
// 设置待处理任务的 Promise
|
||||
this.pendingTasks.set(taskId, {
|
||||
resolve: (_value: T) => {
|
||||
// Promise resolve callback
|
||||
},
|
||||
reject: (_error: Error) => {
|
||||
// Promise reject callback
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 更新 worker 状态
|
||||
this.workerTaskCounts.set(workerId, currentTaskCount + tasks.length)
|
||||
stats.isIdle = tasks.length === 0
|
||||
|
||||
// 发送批量任务
|
||||
const message: BatchTaskMessage = {
|
||||
type: 'batch-task',
|
||||
tasks,
|
||||
workerId,
|
||||
}
|
||||
|
||||
worker.send(message)
|
||||
|
||||
const workerLogger = this.logger.worker(workerId)
|
||||
workerLogger.info(
|
||||
`分配 ${tasks.length} 个任务 (当前处理中:${currentTaskCount + tasks.length}/${this.workerConcurrency})`,
|
||||
)
|
||||
}
|
||||
|
||||
private handleWorkerBatchResult(
|
||||
workerId: number,
|
||||
message: BatchTaskResult,
|
||||
): void {
|
||||
const stats = this.workerStats.get(workerId)
|
||||
const workerLogger = this.logger.worker(workerId)
|
||||
const currentTaskCount = this.workerTaskCounts.get(workerId) || 0
|
||||
|
||||
if (!stats) return
|
||||
|
||||
let completedInBatch = 0
|
||||
let successfulInBatch = 0
|
||||
|
||||
// 处理批量结果中的每个任务
|
||||
for (const taskResult of message.results) {
|
||||
const pendingTask = this.pendingTasks.get(taskResult.taskId)
|
||||
if (!pendingTask) {
|
||||
workerLogger.warn(`收到未知任务结果:${taskResult.taskId}`)
|
||||
continue
|
||||
}
|
||||
|
||||
this.pendingTasks.delete(taskResult.taskId)
|
||||
completedInBatch++
|
||||
|
||||
if (taskResult.type === 'result' && taskResult.result !== undefined) {
|
||||
// 从 taskId 中提取 taskIndex
|
||||
const taskIndex = Number.parseInt(taskResult.taskId.split('-')[1])
|
||||
this.results[taskIndex] = taskResult.result
|
||||
successfulInBatch++
|
||||
|
||||
this.completedTasks++
|
||||
} else if (taskResult.type === 'error') {
|
||||
workerLogger.error(
|
||||
`任务执行失败:${taskResult.taskId}`,
|
||||
taskResult.error,
|
||||
)
|
||||
pendingTask.reject(new Error(taskResult.error))
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 worker 状态
|
||||
const newTaskCount = Math.max(0, currentTaskCount - completedInBatch)
|
||||
this.workerTaskCounts.set(workerId, newTaskCount)
|
||||
stats.processedTasks += successfulInBatch
|
||||
stats.isIdle = newTaskCount === 0
|
||||
|
||||
workerLogger.info(
|
||||
`完成批量任务:${successfulInBatch}/${completedInBatch} 成功 (总完成:${this.completedTasks}/${this.totalTasks},当前处理中:${newTaskCount})`,
|
||||
)
|
||||
|
||||
// 检查是否所有任务都已完成
|
||||
if (this.completedTasks >= this.totalTasks) {
|
||||
this.emit('allTasksCompleted')
|
||||
return
|
||||
}
|
||||
|
||||
// 为该 worker 分配下一批任务
|
||||
this.assignBatchTasksToWorker(workerId)
|
||||
}
|
||||
|
||||
private handleWorkerMessage(workerId: number, message: TaskResult): void {
|
||||
const stats = this.workerStats.get(workerId)
|
||||
const workerLogger = this.logger.worker(workerId)
|
||||
const currentTaskCount = this.workerTaskCounts.get(workerId) || 0
|
||||
|
||||
if (!stats) return
|
||||
|
||||
const pendingTask = this.pendingTasks.get(message.taskId)
|
||||
if (!pendingTask) {
|
||||
workerLogger.warn(`收到未知任务结果:${message.taskId}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingTasks.delete(message.taskId)
|
||||
|
||||
// 更新任务计数
|
||||
const newTaskCount = Math.max(0, currentTaskCount - 1)
|
||||
this.workerTaskCounts.set(workerId, newTaskCount)
|
||||
stats.isIdle = newTaskCount === 0
|
||||
|
||||
if (message.type === 'result' && message.result !== undefined) {
|
||||
// 从 taskId 中提取 taskIndex
|
||||
const taskIndex = Number.parseInt(message.taskId.split('-')[1])
|
||||
this.results[taskIndex] = message.result
|
||||
stats.processedTasks++
|
||||
|
||||
this.completedTasks++
|
||||
workerLogger.info(
|
||||
`完成任务 ${taskIndex + 1}/${this.totalTasks} (已完成:${this.completedTasks},当前处理中:${newTaskCount})`,
|
||||
)
|
||||
|
||||
// 检查是否所有任务都已完成
|
||||
if (this.completedTasks >= this.totalTasks) {
|
||||
this.emit('allTasksCompleted')
|
||||
return
|
||||
}
|
||||
} else if (message.type === 'error') {
|
||||
workerLogger.error(`任务执行失败:${message.taskId}`, message.error)
|
||||
pendingTask.reject(new Error(message.error))
|
||||
}
|
||||
|
||||
// 为该 worker 分配下一批任务
|
||||
this.assignBatchTasksToWorker(workerId)
|
||||
}
|
||||
|
||||
private handleWorkerError(workerId: number, error: Error): void {
|
||||
const stats = this.workerStats.get(workerId)
|
||||
if (stats) {
|
||||
stats.isIdle = true
|
||||
}
|
||||
|
||||
this.emit('error', error)
|
||||
}
|
||||
|
||||
private async shutdown(): Promise<void> {
|
||||
this.isShuttingDown = true
|
||||
const shutdownPromises: Promise<void>[] = []
|
||||
|
||||
for (const [, worker] of this.workers) {
|
||||
shutdownPromises.push(
|
||||
new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
worker.kill('SIGKILL')
|
||||
resolve()
|
||||
}, 0)
|
||||
|
||||
worker.on('exit', () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
})
|
||||
|
||||
// 发送关闭信号
|
||||
worker.send({ type: 'shutdown' })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(shutdownPromises)
|
||||
this.workers.clear()
|
||||
this.workerStats.clear()
|
||||
}
|
||||
|
||||
private async waitForWorkersReady(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const requiredWorkers = Math.ceil(
|
||||
this.totalTasks / this.workerConcurrency,
|
||||
)
|
||||
const expectedWorkers = Math.min(this.concurrency, requiredWorkers)
|
||||
|
||||
const checkReady = () => {
|
||||
if (this.readyWorkers.size >= expectedWorkers) {
|
||||
this.logger.main.info(
|
||||
`所有 ${expectedWorkers} 个 worker 进程已准备就绪`,
|
||||
)
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
this.on('workerReady', checkReady)
|
||||
checkReady() // 立即检查一次
|
||||
})
|
||||
}
|
||||
|
||||
// 获取 worker 统计信息
|
||||
getWorkerStats(): WorkerStats[] {
|
||||
return Array.from(this.workerStats.values())
|
||||
}
|
||||
}
|
||||
71
apps/web/src/core/worker/pool.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Logger } from '../logger/index.js'
|
||||
|
||||
export interface WorkerPoolOptions {
|
||||
concurrency: number
|
||||
totalTasks: number
|
||||
}
|
||||
|
||||
export type TaskFunction<T> = (
|
||||
taskIndex: number,
|
||||
workerId: number,
|
||||
) => Promise<T>
|
||||
|
||||
// Worker 池管理器
|
||||
export class WorkerPool<T> {
|
||||
private concurrency: number
|
||||
private totalTasks: number
|
||||
private taskIndex = 0
|
||||
private logger: Logger
|
||||
|
||||
constructor(options: WorkerPoolOptions, logger: Logger) {
|
||||
this.concurrency = options.concurrency
|
||||
this.totalTasks = options.totalTasks
|
||||
this.logger = logger
|
||||
}
|
||||
|
||||
async execute(taskFunction: TaskFunction<T>): Promise<T[]> {
|
||||
const results: T[] = Array.from({ length: this.totalTasks })
|
||||
|
||||
this.logger.main.info(
|
||||
`开始并发处理任务,工作池模式,并发数:${this.concurrency}`,
|
||||
)
|
||||
|
||||
// Worker 函数
|
||||
const worker = async (workerId: number): Promise<void> => {
|
||||
const workerLogger = this.logger.worker(workerId)
|
||||
workerLogger.start(`Worker ${workerId} 启动`)
|
||||
|
||||
let processedByWorker = 0
|
||||
|
||||
while (this.taskIndex < this.totalTasks) {
|
||||
const currentIndex = this.taskIndex++
|
||||
if (currentIndex >= this.totalTasks) break
|
||||
|
||||
workerLogger.info(`开始处理任务 ${currentIndex + 1}/${this.totalTasks}`)
|
||||
|
||||
const startTime = Date.now()
|
||||
const result = await taskFunction(currentIndex, workerId)
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
results[currentIndex] = result
|
||||
processedByWorker++
|
||||
|
||||
workerLogger.info(
|
||||
`完成任务 ${currentIndex + 1}/${this.totalTasks} - ${duration}ms`,
|
||||
)
|
||||
}
|
||||
|
||||
workerLogger.success(
|
||||
`Worker ${workerId} 完成,处理了 ${processedByWorker} 个任务`,
|
||||
)
|
||||
}
|
||||
|
||||
// 启动工作池
|
||||
const workers = Array.from({ length: this.concurrency }, (_, i) =>
|
||||
worker(i + 1),
|
||||
)
|
||||
await Promise.all(workers)
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
37
apps/web/src/data/photos.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
import PhotosManifest from './photos-manifest.json'
|
||||
|
||||
class PhotoLoader {
|
||||
private photos: PhotoManifest[] = []
|
||||
private photoMap: Record<string, PhotoManifest> = {}
|
||||
|
||||
constructor() {
|
||||
this.getAllTags = this.getAllTags.bind(this)
|
||||
this.getPhotos = this.getPhotos.bind(this)
|
||||
this.getPhoto = this.getPhoto.bind(this)
|
||||
|
||||
this.photos = PhotosManifest as unknown as PhotoManifest[]
|
||||
|
||||
this.photos.forEach((photo) => {
|
||||
this.photoMap[photo.id] = photo
|
||||
})
|
||||
}
|
||||
|
||||
getPhotos() {
|
||||
return this.photos
|
||||
}
|
||||
|
||||
getPhoto(id: string) {
|
||||
return this.photoMap[id]
|
||||
}
|
||||
|
||||
getAllTags() {
|
||||
const tagSet = new Set<string>()
|
||||
this.photos.forEach((photo) => {
|
||||
photo.tags.forEach((tag) => tagSet.add(tag))
|
||||
})
|
||||
return Array.from(tagSet).sort()
|
||||
}
|
||||
}
|
||||
export const photoLoader = new PhotoLoader()
|
||||
1
apps/web/src/framer-lazy-feature.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { domMax as default } from 'motion/react'
|
||||
55
apps/web/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
|
||||
declare global {
|
||||
export type Nullable<T> = T | null | undefined
|
||||
|
||||
type IsLiteralString<T> = T extends string
|
||||
? string extends T
|
||||
? never
|
||||
: T
|
||||
: never
|
||||
|
||||
type OmitStringType<T> = T extends any[]
|
||||
? OmitStringType<T[number]>
|
||||
: IsLiteralString<T>
|
||||
type NonUndefined<T> = T extends undefined
|
||||
? never
|
||||
: T extends object
|
||||
? { [K in keyof T]: NonUndefined<T[K]> }
|
||||
: T
|
||||
|
||||
type NilValue = null | undefined | false | ''
|
||||
type Prettify<T> = {
|
||||
[K in keyof T]: T[K]
|
||||
} & {}
|
||||
|
||||
const APP_NAME: string
|
||||
const BUILT_DATE: string
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
export type Component<P = object> = FC<Prettify<ComponentType & P>>
|
||||
|
||||
export type ComponentWithRef<P = object, Ref = object> = FC<
|
||||
ComponentWithRefType<P, Ref>
|
||||
>
|
||||
export type ComponentWithRefType<P = object, Ref = object> = Prettify<
|
||||
ComponentType<P> & {
|
||||
ref?: React.Ref<Ref>
|
||||
}
|
||||
>
|
||||
|
||||
export type ComponentType<P = object> = {
|
||||
className?: string
|
||||
} & PropsWithChildren &
|
||||
P
|
||||
}
|
||||
|
||||
declare module 'react' {
|
||||
export interface AriaAttributes {
|
||||
'data-testid'?: string
|
||||
'data-hide-in-print'?: boolean
|
||||
}
|
||||
}
|
||||
4
apps/web/src/hooks/common/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './useDark'
|
||||
export * from './usePrevious'
|
||||
export * from './useRefValue'
|
||||
export * from './useTitle'
|
||||
73
apps/web/src/hooks/common/useDark.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { atomWithStorage } from 'jotai/utils'
|
||||
import { useCallback, useLayoutEffect } from 'react'
|
||||
import { useMediaQuery } from 'usehooks-ts'
|
||||
|
||||
import { nextFrame } from '~/lib/dom'
|
||||
import { jotaiStore } from '~/lib/jotai'
|
||||
|
||||
const useDarkQuery = () => useMediaQuery('(prefers-color-scheme: dark)')
|
||||
type ColorMode = 'light' | 'dark' | 'system'
|
||||
const themeAtom = atomWithStorage(
|
||||
'color-mode',
|
||||
'system' as ColorMode,
|
||||
undefined,
|
||||
{
|
||||
getOnInit: true,
|
||||
},
|
||||
)
|
||||
|
||||
function useDarkWebApp() {
|
||||
const systemIsDark = useDarkQuery()
|
||||
const mode = useAtomValue(themeAtom)
|
||||
return mode === 'dark' || (mode === 'system' && systemIsDark)
|
||||
}
|
||||
export const useIsDark = useDarkWebApp
|
||||
|
||||
export const useThemeAtomValue = () => useAtomValue(themeAtom)
|
||||
|
||||
const useSyncThemeWebApp = () => {
|
||||
const colorMode = useAtomValue(themeAtom)
|
||||
const systemIsDark = useDarkQuery()
|
||||
useLayoutEffect(() => {
|
||||
const realColorMode: Exclude<ColorMode, 'system'> =
|
||||
colorMode === 'system' ? (systemIsDark ? 'dark' : 'light') : colorMode
|
||||
document.documentElement.dataset.theme = realColorMode
|
||||
disableTransition(['[role=switch]>*'])()
|
||||
}, [colorMode, systemIsDark])
|
||||
}
|
||||
|
||||
export const useSyncThemeark = useSyncThemeWebApp
|
||||
|
||||
export const useSetTheme = () =>
|
||||
useCallback((colorMode: ColorMode) => {
|
||||
jotaiStore.set(themeAtom, colorMode)
|
||||
}, [])
|
||||
|
||||
function disableTransition(disableTransitionExclude: string[] = []) {
|
||||
const css = document.createElement('style')
|
||||
css.append(
|
||||
document.createTextNode(
|
||||
`
|
||||
*${disableTransitionExclude.map((s) => `:not(${s})`).join('')} {
|
||||
-webkit-transition: none !important;
|
||||
-moz-transition: none !important;
|
||||
-o-transition: none !important;
|
||||
-ms-transition: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
`,
|
||||
),
|
||||
)
|
||||
document.head.append(css)
|
||||
|
||||
return () => {
|
||||
// Force restyle
|
||||
;(() => window.getComputedStyle(document.body))()
|
||||
|
||||
// Wait for next tick before removing
|
||||
nextFrame(() => {
|
||||
css.remove()
|
||||
})
|
||||
}
|
||||
}
|
||||
98
apps/web/src/hooks/common/useInputComposition.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { CompositionEventHandler } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
type InputElementAttributes = React.DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
>
|
||||
type TextareaElementAttributes = React.DetailedHTMLProps<
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
HTMLTextAreaElement
|
||||
>
|
||||
export const useInputComposition = <E = HTMLInputElement>(
|
||||
props: Pick<
|
||||
E extends HTMLInputElement
|
||||
? InputElementAttributes
|
||||
: E extends HTMLTextAreaElement
|
||||
? TextareaElementAttributes
|
||||
: never,
|
||||
'onKeyDown' | 'onCompositionEnd' | 'onCompositionStart' | 'onKeyDownCapture'
|
||||
>,
|
||||
) => {
|
||||
const { onKeyDown, onCompositionStart, onCompositionEnd } = props
|
||||
|
||||
const isCompositionRef = useRef(false)
|
||||
|
||||
const currentInputTargetRef = useRef<E | null>(null)
|
||||
|
||||
const handleCompositionStart: CompositionEventHandler<E> = useCallback(
|
||||
(e) => {
|
||||
currentInputTargetRef.current = e.target as E
|
||||
|
||||
isCompositionRef.current = true
|
||||
onCompositionStart?.(e as any)
|
||||
},
|
||||
[onCompositionStart],
|
||||
)
|
||||
|
||||
const handleCompositionEnd: CompositionEventHandler<E> = useCallback(
|
||||
(e) => {
|
||||
currentInputTargetRef.current = null
|
||||
isCompositionRef.current = false
|
||||
onCompositionEnd?.(e as any)
|
||||
},
|
||||
[onCompositionEnd],
|
||||
)
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<E> = useCallback(
|
||||
(e: any) => {
|
||||
// The keydown event stop emit when the composition is being entered
|
||||
if (isCompositionRef.current) {
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
onKeyDown?.(e)
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (!isCompositionRef.current) {
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}
|
||||
},
|
||||
[onKeyDown],
|
||||
)
|
||||
|
||||
// Register a global capture keydown listener to prevent the radix `useEscapeKeydown` from working
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && currentInputTargetRef.current) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleGlobalKeyDown, { capture: true })
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleGlobalKeyDown, {
|
||||
capture: true,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const ret = {
|
||||
onCompositionEnd: handleCompositionEnd,
|
||||
onCompositionStart: handleCompositionStart,
|
||||
onKeyDown: handleKeyDown,
|
||||
}
|
||||
Object.defineProperty(ret, 'isCompositionRef', {
|
||||
value: isCompositionRef,
|
||||
enumerable: false,
|
||||
})
|
||||
return ret as typeof ret & {
|
||||
isCompositionRef: typeof isCompositionRef
|
||||
}
|
||||
}
|
||||
20
apps/web/src/hooks/common/useIsOnline.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const useIsOnline = () => {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true)
|
||||
const handleOffline = () => setIsOnline(false)
|
||||
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return isOnline
|
||||
}
|
||||
9
apps/web/src/hooks/common/usePrevious.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export const usePrevious = <T>(value: T): T | undefined => {
|
||||
const ref = useRef<T>(void 0)
|
||||
useEffect(() => {
|
||||
ref.current = value
|
||||
})
|
||||
return ref.current
|
||||
}
|
||||
14
apps/web/src/hooks/common/useRefValue.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
|
||||
export const useRefValue = <S>(
|
||||
value: S,
|
||||
): Readonly<{
|
||||
current: Readonly<S>
|
||||
}> => {
|
||||
const ref = useRef<S>(value)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
ref.current = value
|
||||
})
|
||||
return ref
|
||||
}
|
||||
14
apps/web/src/hooks/common/useTitle.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
const titleTemplate = `%s | ${APP_NAME}`
|
||||
export const useTitle = (title?: Nullable<string>) => {
|
||||
const currentTitleRef = useRef(document.title)
|
||||
useEffect(() => {
|
||||
if (!title) return
|
||||
|
||||
document.title = titleTemplate.replace('%s', title)
|
||||
return () => {
|
||||
document.title = currentTitleRef.current
|
||||
}
|
||||
}, [title])
|
||||
}
|
||||
10
apps/web/src/hooks/useMobile.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useViewport } from './useViewport'
|
||||
|
||||
export const useMobile = () => {
|
||||
return useViewport((v) => v.w < 1024 && v.w !== 0)
|
||||
}
|
||||
|
||||
export const isMobile = () => {
|
||||
const w = window.innerWidth
|
||||
return w < 1024 && w !== 0
|
||||
}
|
||||