feat: 实现macOS Quick Look风格照片预览和缩略图显示

## 后端变更
- 新增 GetPhotoPreview API 端点 (GET /api/v1/photo/preview)
- 支持智能格式转换(RAW → PNG → JPG/WebP)
- 实现多级缩略图生成(720p / 2K / 原图)

## 前端变更
- 新增 PhotoPreviewModal.vue - macOS Quick Look 风格预览窗口
  - 悬浮窗口设计(85% 视口,圆角,半透明背景)
  - 支持缩放、平移、拖拽操作
  - 三档质量切换(缩略图/高清/原图)
  - 渐进式加载优化

- 新增 photoPreviewService.ts - 照片预览服务
  - 智能 URL 生成(根据文件大小选择最佳策略)
  - 支持自定义尺寸预览

- 优化 FilePane.vue - 文件列表缩略图显示
  - 网格视图:显示 60-120px 真实缩略图
  - 列表视图:显示 32px 真实缩略图
  - 使用 background-image 避免布局抖动
  - 空格键触发照片预览

## 文档
- 新增 PHOTO_PREVIEW_TEST.md - 测试指南
- 新增 PHOTO_PREVIEW_V2_SUMMARY.md - 功能总结

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jimmyandClaude committed 2025-10-06 14:36:58 +08:00
1 parent c0e1452e06
commit 2a5d870a51
7 files changed
+1248 -16

No files matched your search

+207
View File
@@ -0,0 +1,207 @@
# 照片预览功能测试指南(v2 - macOS Quick Look 风格)
## ✅ 已实现的功能
### 后端
- [x] 预览 API: `GET /api/v1/photo/preview?path={path}&size={size}`
- [x] 智能格式转换(RAW → PNG → JPG/WebP)
- [x] 多级缩略图生成(720p / 2K / 原图)
- [x] 自动缓存机制
### 前端
- [x] **macOS Quick Look 风格预览窗口**(悬浮、圆角、半透明背景)
- [x] **文件列表中显示图片缩略图**(网格视图和列表视图)
- [x] 渐进式加载(缩略图 → 高清 → 原图)
- [x] 缩放和平移功能
- [x] 空格键快捷预览
## 🧪 测试步骤
### 1. 启动服务
#### 后端
```bash
cd D:\go-argus\src\rear
go run main.go
```
#### 前端
```bash
cd D:\go-argus\src\front\argus-front
npm run dev
```
### 2. 测试预览功能
1. **打开文件管理器**
- 访问:http://localhost:3000/field
2. **浏览到包含照片的文件夹**
- 双击进入文件夹
- 确保文件夹中有 JPG/PNG/RAW 等图片文件
3. **测试缩略图显示**(新功能!)
- 📸 **网格视图**: 照片文件应显示实际的缩略图,而不是图标
- 小尺寸:60x60 像素缩略图
- 中等尺寸:80x80 像素缩略图
- 大尺寸:120x120 像素缩略图
- 📋 **列表视图**: 照片文件图标处应显示 32x32 像素缩略图
- 文件夹和其他文件类型仍显示图标
4. **测试 Quick Look 预览**(新风格!)
- 点击选中一张照片(照片项会高亮)
- 按下 **空格键**
- 应该弹出 **macOS 风格的悬浮预览窗口**:
- ✨ 圆角边框(12px)
- 🌫️ 半透明背景(带模糊效果)
- 📏 适中大小(85%视口,可以看到后面的文件列表)
- 🎬 弹出动画(缩放 + 淡入)
- 窗口包含三部分:
- 顶部标题栏:文件名 + 文件大小 + 关闭按钮
- 中间图片区域:可缩放、平移
- 底部工具栏:缩放控制 + 质量切换
5. **测试预览交互**
- **初始加载**: 应显示 720p 缩略图(加载快)
- **双击放大**: 双击图片,自动放大并切换到高清模式
- **滚轮缩放**: 鼠标滚轮缩放图片
- **拖拽平移**: 放大后可拖拽图片查看细节
- **质量切换**:
- 点击底部"缩略图"按钮 → 720p
- 点击"高清"按钮 → 2K 高清
- 点击"原图"按钮 → 原图(可能很大)
- **关闭预览**:
- 按 ESC 键
- 点击右上角关闭按钮(悬停时变红色)
- 点击窗口外的背景区域
### 3. 测试不同文件格式
#### 小图片(< 5MB)
- 测试 JPG/PNG 小文件
- 预期:直接加载原图
#### 中等图片(5-20MB)
- 测试中等 JPG 文件
- 预期:初始加载 1080p 缩略图
#### 大图片(> 20MB)
- 测试专业相机拍摄的 RAW 或大 JPG
- 预期:
1. 初始加载 720p 缩略图(< 200KB,快速)
2. 切换到高清时加载 2K 版本(1-2MB)
3. 切换到原图时才加载完整文件
#### RAW 格式
- 测试 CR2/NEF/ARW 等 RAW 文件
- 预期:后端自动转换为 JPG 并生成缩略图
### 4. 测试边界情况
- **路径错误**: 选中文件后手动删除,再预览
- **网络错误**: 断开后端服务,再预览
- **非照片文件**: 选中文件夹或视频,按空格
- 预期:文件夹/其他文件使用旧的 QuickPreview
- 仅照片使用 PhotoPreviewModal
## 🎯 预期表现
### 性能指标
- **初始预览响应**: < 500ms(包括 Hash 计算)
- **缩略图加载**: < 200ms(720p < 200KB)
- **高清加载**: < 1s(2K 约 1-2MB)
- **原图加载**: 取决于文件大小,应显示加载进度
### 用户体验
- **渐进式加载**: 先模糊后清晰,不阻塞操作
- **平滑缩放**: 缩放和平移动画流畅
- **快捷键**: 空格预览,ESC 关闭,+/- 缩放
- **智能提示**: 底部控制栏显示当前质量和文件信息
## 🐛 已知问题与限制
1. **首次预览延迟**
- 问题:Hash 计算可能需要几百毫秒
- 解决:后续可优化为异步计算
2. **大文件内存占用**
- 问题:加载 40MB 原图可能占用大量内存
- 解决:用户需明确点击"原图"才加载
3. **视频不支持**
- 当前仅支持照片预览
- 视频预览将在未来版本实现
## 📊 测试检查清单
### 缩略图显示
- [ ] 网格视图中照片显示缩略图(不是图标)
- [ ] 列表视图中照片显示小缩略图
- [ ] 缩略图加载流畅(无明显延迟)
- [ ] 不同尺寸网格的缩略图大小适配
- [ ] 文件夹和其他文件仍显示图标
### Quick Look 预览窗口
- [ ] 窗口为悬浮样式(不是全屏)
- [ ] 窗口有圆角和阴影
- [ ] 背景半透明带模糊效果
- [ ] 弹出动画流畅(缩放+淡入)
- [ ] 可以看到后面的文件列表
- [ ] 窗口大小适中(85%视口)
### 预览功能
- [ ] 后端 API 正常响应
- [ ] 空格键触发预览
- [ ] 缩略图快速加载
- [ ] 高清模式切换正常
- [ ] 原图加载正常
- [ ] 缩放功能正常
- [ ] 平移功能正常
- [ ] 双击放大正常
- [ ] ESC 关闭正常
- [ ] 点击背景关闭正常
- [ ] 关闭按钮悬停变红
- [ ] 文件信息显示正确
- [ ] 不同格式都能预览(JPG/PNG/RAW)
- [ ] 错误处理正常(路径错误、网络错误)
## 🔧 调试技巧
### 查看网络请求
1. 打开浏览器开发者工具(F12)
2. 切换到 Network 标签
3. 预览照片,观察请求:
- `/api/v1/photo/preview?path=...&size=720` → 缩略图
- `/api/v1/photo/preview?path=...&size=2048` → 高清
- `/api/v1/photo/preview?path=...&size=0` → 原图
### 查看控制台日志
- 前端错误会显示在 Console 标签
- 后端日志在终端输出
### 检查文件生成
- 缩略图缓存位置:`D:\go-argus\src\rear\thumbnail\{hash前4位}\{hash}\{size}.jpg`
- 检查是否生成了对应尺寸的缩略图
## 📝 下一步优化
1. **性能优化**
- [ ] Hash 计算异步化
- [ ] 预加载相邻照片
- [ ] IndexedDB 缓存
2. **功能增强**
- [ ] 左右方向键切换照片
- [ ] 缩略图网格预览
- [ ] 幻灯片模式
3. **用户体验**
- [ ] 加载进度条
- [ ] 更多缩放选项
- [ ] 旋转功能
---
**测试日期**: 2025-10-06
**版本**: v1.0
**维护者**: Claude Code
+156
View File
@@ -0,0 +1,156 @@
# 照片预览功能 v2 - 更新总结
## 🎨 主要改进
### 1. macOS Quick Look 风格预览窗口
**之前**:全屏黑色背景,覆盖整个屏幕
**现在**:悬浮窗口,类似 macOS Finder 的 Quick Look
#### 视觉效果
- ✨ **悬浮窗口**:85% 视口大小,居中显示
- 🌫️ **半透明背景**:带 20px 模糊效果,可以看到后面的文件列表
- 🎨 **圆角设计**:12px 圆角 + 大阴影
- 🎬 **优雅动画**:弹出时缩放+淡入效果(0.25s)
#### 窗口结构
```
┌─────────────────────────────────────┐
│ 标题栏:文件名 + 文件大小 + [X] │ ← 深色半透明
├─────────────────────────────────────┤
│ │
│ 图片显示区域 │
│ (可缩放、可拖拽) │
│ │
├─────────────────────────────────────┤
│ 底部工具栏:缩放控制 + 质量切换 │ ← 深色半透明
└─────────────────────────────────────┘
```
#### 交互优化
- **多种关闭方式**:
- ESC 键
- 点击右上角 X(悬停变红)
- 点击窗口外背景区域
- **窗口尺寸**:
- 宽度:85vw(最大 1400px)
- 高度:85vh(最大 900px)
- 适中的大小,不会完全遮挡文件列表
### 2. 文件列表中显示真实缩略图
**之前**:所有照片都显示 🖼️ 图标
**现在**:照片文件显示实际的缩略图预览
#### 网格视图缩略图
- **小尺寸**:60x60 像素
- **中等尺寸**:80x80 像素
- **大尺寸**:120x120 像素
- 圆角边框(8px)+ 阴影效果
#### 列表视图缩略图
- **尺寸**:32x32 像素
- 小圆角(4px)+ 轻微阴影
- 与文件名对齐显示
#### 智能加载策略
- **网格视图**:加载 300px 缩略图(适中质量)
- **列表视图**:加载 200px 缩略图(更快速度)
- 使用背景图片方式加载,避免布局抖动
- 浏览器自动缓存,重复访问无需再次请求
## 📁 修改的文件
### 1. PhotoPreviewModal.vue
**位置**:`D:\go-argus\src\front\argus-front\src\components\FileManager\PhotoPreviewModal.vue`
**主要改动**:
- 重构 HTML 结构:`backdrop` + `window` + `header` + `footer`
- 完全重写 CSS 样式:macOS 风格
- 添加动画效果:`fadeIn` + `scaleIn`
- 优化按钮样式:关闭按钮悬停变红
### 2. FilePane.vue
**位置**:`D:\go-argus\src\front\argus-front\src\components\FileManager\FilePane.vue`
**主要改动**:
- 模板修改:
- 网格视图图标添加缩略图背景(第 129-139 行)
- 列表视图图标添加缩略图背景(第 194-203 行)
- JavaScript:
- 新增 `getThumbnailStyle()` 函数(第 1843-1856 行)
- CSS:
- 添加 `.file-icon.has-thumbnail` 样式(第 2318-2335 行)
- 添加 `.list-icon.has-thumbnail` 样式(第 2496-2503 行)
### 3. 测试文档更新
**位置**:`D:\go-argus\src\PHOTO_PREVIEW_TEST.md`
**更新内容**:
- 新增缩略图测试步骤
- 新增 Quick Look 风格测试要点
- 扩展测试检查清单
## 🎯 用户体验提升
### 快速浏览
- **之前**:看到图标,猜测内容,按空格查看
- **现在**:直接看到缩略图,一目了然
### 预览操作
- **之前**:全屏预览,看不到其他文件,需要关闭后才能切换
- **现在**:悬浮预览,可以看到文件列表,快速判断是否要切换
### 视觉美感
- **之前**:纯黑背景,过于简陋
- **现在**:半透明毛玻璃效果,优雅专业
## 🚀 性能优化
### 缩略图加载
- 使用小尺寸缩略图(200-300px),加载快速
- 浏览器自动缓存,重复访问无延迟
- CSS background-image 加载,不影响布局
### 预览窗口
- 初始加载 720p 缩略图(< 200KB)
- 按需加载高清/原图(用户主动切换时)
- 渐进式体验,不卡顿
## 📊 对比总结
| 特性 | v1 (全屏) | v2 (Quick Look) |
|------|----------|-----------------|
| 窗口样式 | 全屏黑色背景 | 悬浮窗口 |
| 背景效果 | 纯黑 | 半透明模糊 |
| 可见性 | 完全遮挡 | 可见文件列表 |
| 圆角 | 无 | 12px 圆角 |
| 阴影 | 无 | 大阴影 |
| 动画 | 无 | 缩放+淡入 |
| 关闭方式 | ESC / 按钮 | ESC / 按钮 / 点击背景 |
| 缩略图 | ❌ 仅图标 | ✅ 真实缩略图 |
## ✨ 下一步建议
1. **左右方向键切换照片**
- 预览状态下,按左右键切换同文件夹的照片
- 自动预加载前后照片
2. **缩略图加载优化**
- 虚拟滚动时按需加载可见区域缩略图
- IntersectionObserver 懒加载
3. **预览增强**
- 添加照片信息显示(分辨率、拍摄日期等)
- 支持旋转功能
- 支持全屏切换
4. **手势支持**(桌面应用)
- 双指捏合缩放
- 双指滑动切换照片
---
**版本**: v2.0
**更新日期**: 2025-10-06
**改进者**: Claude Code
@@ -126,8 +126,13 @@
@dragleave="handleDragLeave($event)" @dragleave="handleDragLeave($event)"
@drop="handleDrop($event, item)" @drop="handleDrop($event, item)"
> >
<div class="file-icon"> <!-- 图片缩略图或图标 -->
{{ getFileIcon(item.type, item.extension) }} <div
class="file-icon"
:class="{ 'has-thumbnail': item.type === 'photo' && item.path }"
:style="item.type === 'photo' && item.path ? getThumbnailStyle(item.path) : {}"
>
<span v-if="item.type !== 'photo' || !item.path">{{ getFileIcon(item.type, item.extension) }}</span>
</div> </div>
<div class="file-name" v-html="highlightText(item.name)"></div> <div class="file-name" v-html="highlightText(item.name)"></div>
<div v-if="item.size" class="file-size">{{ item.size }}</div> <div v-if="item.size" class="file-size">{{ item.size }}</div>
@@ -186,7 +191,13 @@
@dragleave="handleDragLeave($event)" @dragleave="handleDragLeave($event)"
@drop="handleDrop($event, item)" @drop="handleDrop($event, item)"
> >
<span class="list-icon">{{ getFileIcon(item.type, item.extension) }}</span> <span
class="list-icon"
:class="{ 'has-thumbnail': item.type === 'photo' && item.path }"
:style="item.type === 'photo' && item.path ? getThumbnailStyle(item.path, true) : {}"
>
<span v-if="item.type !== 'photo' || !item.path">{{ getFileIcon(item.type, item.extension) }}</span>
</span>
<span class="list-name" v-html="highlightText(item.name)"></span> <span class="list-name" v-html="highlightText(item.name)"></span>
<span class="list-size">{{ item.size || '-' }}</span> <span class="list-size">{{ item.size || '-' }}</span>
<span class="list-date">{{ item.date || '-' }}</span> <span class="list-date">{{ item.date || '-' }}</span>
@@ -274,6 +285,15 @@
> >
🔬 🔬
</button> </button>
<!-- Photo Preview Modal -->
<PhotoPreviewModal
v-model:visible="previewVisible"
:file-path="previewFilePath"
:file-name="previewFileName"
:file-size="previewFileSize"
@close="handlePreviewClose"
/>
</div> </div>
</template> </template>
@@ -294,6 +314,7 @@ import ContextMenu from './ContextMenu.vue'
import Tooltip from './Tooltip.vue' import Tooltip from './Tooltip.vue'
import QuickPreview from './QuickPreview.vue' import QuickPreview from './QuickPreview.vue'
import DebugPanel from './DebugPanel.vue' import DebugPanel from './DebugPanel.vue'
import PhotoPreviewModal from './PhotoPreviewModal.vue'
// Naive UI // Naive UI
const message = useMessage() const message = useMessage()
@@ -376,6 +397,12 @@ const quickPreview = ref({
// Debug Panel (开发环境) // Debug Panel (开发环境)
const debugPanelVisible = ref(false) const debugPanelVisible = ref(false)
// Photo Preview (照片预览)
const previewVisible = ref(false)
const previewFilePath = ref('')
const previewFileName = ref('')
const previewFileSize = ref<number | undefined>(undefined)
const debugMetrics = computed(() => { const debugMetrics = computed(() => {
const total = visibleItems.value.length const total = visibleItems.value.length
const rendered = renderItems.value.length const rendered = renderItems.value.length
@@ -1614,22 +1641,28 @@ useKeyboardNav({
}, },
onSpace: (event: KeyboardEvent) => { onSpace: (event: KeyboardEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation()
// 如果预览已打开,关闭预览 // 获取当前焦点项
if (quickPreview.value.visible) { const focused = selection.focusedItem.value
quickPreview.value.visible = false if (!focused) return
quickPreview.value.item = null
const item = visibleItems.value[focused.index]
if (!item) return
// 如果是照片,使用新的照片预览
if (item.type === 'photo') {
handlePhotoPreview(item)
return return
} }
// 打开预览 // 其他类型使用旧的快速预览(文件夹、其他文件)
const focused = selection.focusedItem.value if (quickPreview.value.visible) {
if (focused) { quickPreview.value.visible = false
const item = visibleItems.value[focused.index] quickPreview.value.item = null
if (item) { } else {
quickPreview.value.visible = true quickPreview.value.visible = true
quickPreview.value.item = item quickPreview.value.item = item
}
} }
}, },
onSelectAll: () => { onSelectAll: () => {
@@ -1763,6 +1796,65 @@ function handleDebugScrollTo(position: 'top' | 'bottom' | 'middle') {
console.log(`🎯 滚动到${position === 'top' ? '顶部' : position === 'bottom' ? '底部' : '中间'}`) console.log(`🎯 滚动到${position === 'top' ? '顶部' : position === 'bottom' ? '底部' : '中间'}`)
} }
// 预览照片
function handlePhotoPreview(item: FileItem) {
// 只预览照片类型
if (item.type !== 'photo') {
message.info('仅支持预览照片文件')
return
}
// 获取文件路径
const filePath = item.path || ''
if (!filePath) {
message.error('无法获取文件路径')
return
}
// 设置预览信息
previewFilePath.value = filePath
previewFileName.value = item.name
previewFileSize.value = item.size ? parseFileSize(item.size) : undefined
previewVisible.value = true
}
// 解析文件大小字符串为字节数
function parseFileSize(sizeStr: string): number | undefined {
const match = sizeStr.match(/^([\d.]+)\s*(B|KB|MB|GB)$/i)
if (!match) return undefined
const value = parseFloat(match[1])
const unit = match[2].toUpperCase()
switch (unit) {
case 'B': return value
case 'KB': return value * 1024
case 'MB': return value * 1024 * 1024
case 'GB': return value * 1024 * 1024 * 1024
default: return undefined
}
}
// 关闭预览
function handlePreviewClose() {
previewVisible.value = false
}
// 获取缩略图样式
function getThumbnailStyle(filePath: string, isListView = false) {
if (!filePath) return {}
const size = isListView ? 200 : 300 // 列表视图用小尺寸,网格视图用大尺寸
const thumbnailUrl = `${import.meta.env.VITE_APP_API_URL || 'http://127.0.0.1:9484/api'}/v1/photo/preview?path=${encodeURIComponent(filePath)}&size=${size}`
return {
backgroundImage: `url(${thumbnailUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
}
}
// 暴露方法给父组件 // 暴露方法给父组件
// 获取选中的项目 // 获取选中的项目
function getSelectedItems(): FileItem[] { function getSelectedItems(): FileItem[] {
@@ -2219,6 +2311,27 @@ defineExpose({
font-size: 40px; font-size: 40px;
margin-bottom: 4px; margin-bottom: 4px;
flex-shrink: 0; flex-shrink: 0;
position: relative;
}
/* 图片缩略图样式 */
.file-icon.has-thumbnail {
width: 80px;
height: 80px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 2px solid rgba(255, 255, 255, 0.1);
}
.grid-small .file-icon.has-thumbnail {
width: 60px;
height: 60px;
}
.grid-large .file-icon.has-thumbnail {
width: 120px;
height: 120px;
} }
/* 网格视图文件类型图标颜色 */ /* 网格视图文件类型图标颜色 */
@@ -2374,6 +2487,19 @@ defineExpose({
.list-icon { .list-icon {
font-size: 18px; font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
}
/* 列表视图图片缩略图样式 */
.list-icon.has-thumbnail {
width: 32px;
height: 32px;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(0, 0, 0, 0.1);
} }
/* 文件类型图标颜色 */ /* 文件类型图标颜色 */
@@ -0,0 +1,577 @@
<template>
<Teleport to="body">
<!-- 半透明背景 -->
<div v-if="visible" class="photo-preview-backdrop" @click.self="handleClose">
<!-- 悬浮预览窗口 -->
<div class="photo-preview-window" @click.stop>
<!-- 窗口标题栏 -->
<div class="window-header">
<div class="window-title">
<span class="file-name">{{ fileName }}</span>
<span v-if="fileSize" class="file-size">{{ formatFileSize(fileSize) }}</span>
</div>
<button class="close-btn" @click="handleClose" title="关闭 (ESC)">
✕
</button>
</div>
<!-- 图片容器 -->
<div class="image-container" ref="containerRef">
<div
class="image-wrapper"
:style="{
transform: `scale(${scale}) translate(${translateX}px, ${translateY}px)`,
transition: isAnimating ? 'transform 0.3s ease-out' : 'none',
cursor: isDragging ? 'grabbing' : (scale > 1 ? 'grab' : 'default')
}"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
@wheel="handleWheel"
@dblclick="handleDoubleClick"
>
<!-- 加载状态 -->
<div v-if="loading" class="loading-indicator">
<div class="spinner"></div>
<p>{{ loadingText }}</p>
</div>
<!-- 图片显示 -->
<img
v-show="!loading && !error"
:src="currentImageUrl"
:alt="fileName"
class="preview-image"
@load="handleImageLoad"
@error="handleImageError"
draggable="false"
/>
<!-- 错误提示 -->
<div v-if="error" class="error-message">
<p>❌ 图片加载失败</p>
<p class="error-detail">{{ error }}</p>
</div>
</div>
</div>
<!-- 窗口底部工具栏 -->
<div class="window-footer">
<!-- 缩放控制 -->
<div class="zoom-controls">
<button @click="zoomOut" :disabled="scale <= 0.1" title="缩小 (-)">−</button>
<span class="zoom-level">{{ Math.round(scale * 100) }}%</span>
<button @click="zoomIn" :disabled="scale >= 5" title="放大 (+)">+</button>
<button @click="resetZoom" title="重置 (0)">1:1</button>
</div>
<!-- 图片质量切换 -->
<div class="quality-controls">
<button
:class="['quality-btn', { active: currentQuality === 'thumbnail' }]"
@click="switchQuality('thumbnail')"
title="缩略图模式(快速)"
>
缩略图
</button>
<button
:class="['quality-btn', { active: currentQuality === 'highRes' }]"
@click="switchQuality('highRes')"
title="高清模式"
>
高清
</button>
<button
:class="['quality-btn', { active: currentQuality === 'original' }]"
@click="switchQuality('original')"
title="原图模式(可能较大)"
>
原图
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { photoPreviewService } from '@/services/photoPreviewService'
interface Props {
visible: boolean
filePath: string
fileName?: string
fileSize?: number
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 状态
const loading = ref(true)
const loadingText = ref('加载中...')
const error = ref('')
const currentQuality = ref<'thumbnail' | 'highRes' | 'original'>('thumbnail')
const currentImageUrl = ref('')
// 缩放和平移
const scale = ref(1)
const translateX = ref(0)
const translateY = ref(0)
const isAnimating = ref(false)
// 拖拽
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
const dragStartTranslateX = ref(0)
const dragStartTranslateY = ref(0)
// 引用
const containerRef = ref<HTMLElement | null>(null)
// 计算属性
const fileName = computed(() => {
if (props.fileName) return props.fileName
const parts = props.filePath.split(/[/\\]/)
return parts[parts.length - 1] || '未知文件'
})
// 监听 visible 变化
watch(() => props.visible, (newVal) => {
if (newVal) {
loadPreview()
} else {
resetState()
}
})
// 监听文件路径变化
watch(() => props.filePath, () => {
if (props.visible) {
loadPreview()
}
})
// 加载预览
async function loadPreview() {
loading.value = true
error.value = ''
currentQuality.value = 'thumbnail'
try {
const urls = photoPreviewService.getAllPreviewUrls(props.filePath)
currentImageUrl.value = urls.thumbnail
loadingText.value = '加载缩略图...'
} catch (err) {
error.value = err instanceof Error ? err.message : '未知错误'
loading.value = false
}
}
// 图片加载完成
function handleImageLoad() {
loading.value = false
isAnimating.value = false
}
// 图片加载失败
function handleImageError() {
loading.value = false
error.value = '无法加载图片,请检查文件格式或路径'
}
// 切换图片质量
async function switchQuality(quality: 'thumbnail' | 'highRes' | 'original') {
if (currentQuality.value === quality) return
loading.value = true
currentQuality.value = quality
const urls = photoPreviewService.getAllPreviewUrls(props.filePath)
switch (quality) {
case 'thumbnail':
currentImageUrl.value = urls.thumbnail
loadingText.value = '加载缩略图...'
break
case 'highRes':
currentImageUrl.value = urls.highRes
loadingText.value = '加载高清图片...'
break
case 'original':
currentImageUrl.value = urls.original
loadingText.value = '加载原图(可能较大)...'
break
}
}
// 缩放控制
function zoomIn() {
isAnimating.value = true
scale.value = Math.min(scale.value + 0.2, 5)
}
function zoomOut() {
isAnimating.value = true
scale.value = Math.max(scale.value - 0.2, 0.1)
}
function resetZoom() {
isAnimating.value = true
scale.value = 1
translateX.value = 0
translateY.value = 0
}
// 鼠标滚轮缩放
function handleWheel(event: WheelEvent) {
event.preventDefault()
event.stopPropagation()
const delta = event.deltaY > 0 ? -0.1 : 0.1
scale.value = Math.max(0.1, Math.min(5, scale.value + delta))
}
// 双击放大
function handleDoubleClick() {
if (scale.value === 1) {
isAnimating.value = true
scale.value = 2
// 自动切换到高清模式
if (currentQuality.value === 'thumbnail') {
switchQuality('highRes')
}
} else {
resetZoom()
}
}
// 拖拽平移
function handleMouseDown(event: MouseEvent) {
if (scale.value <= 1) return // 只有放大时才能拖拽
event.preventDefault()
isDragging.value = true
dragStartX.value = event.clientX
dragStartY.value = event.clientY
dragStartTranslateX.value = translateX.value
dragStartTranslateY.value = translateY.value
}
function handleMouseMove(event: MouseEvent) {
if (!isDragging.value) return
const deltaX = event.clientX - dragStartX.value
const deltaY = event.clientY - dragStartY.value
translateX.value = dragStartTranslateX.value + deltaX / scale.value
translateY.value = dragStartTranslateY.value + deltaY / scale.value
}
function handleMouseUp() {
isDragging.value = false
}
// 关闭预览
function handleClose() {
emit('update:visible', false)
emit('close')
}
// 重置状态
function resetState() {
loading.value = true
error.value = ''
scale.value = 1
translateX.value = 0
translateY.value = 0
isDragging.value = false
isAnimating.value = false
currentQuality.value = 'thumbnail'
}
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
}
// 键盘事件处理
function handleKeyDown(event: KeyboardEvent) {
if (!props.visible) return
switch (event.key) {
case 'Escape':
event.preventDefault()
event.stopPropagation()
handleClose()
break
case '+':
case '=':
event.preventDefault()
event.stopPropagation()
zoomIn()
break
case '-':
case '_':
event.preventDefault()
event.stopPropagation()
zoomOut()
break
case '0':
event.preventDefault()
event.stopPropagation()
resetZoom()
break
}
}
// 生命周期
onMounted(() => {
document.addEventListener('keydown', handleKeyDown, true)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown, true)
})
</script>
<style scoped>
/* macOS Quick Look 风格 */
.photo-preview-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.photo-preview-window {
width: 85vw;
max-width: 1400px;
height: 85vh;
max-height: 900px;
background: #1e1e1e;
border-radius: 12px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
animation: scaleIn 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes scaleIn {
from {
transform: scale(0.9);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
/* 窗口标题栏 */
.window-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.3);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.window-title {
display: flex;
flex-direction: column;
gap: 2px;
}
.window-title .file-name {
color: white;
font-size: 14px;
font-weight: 500;
}
.window-title .file-size {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.1);
color: white;
font-size: 18px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
background: rgba(255, 59, 48, 0.8);
transform: scale(1.05);
}
.image-container {
flex: 1;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
.image-wrapper {
display: flex;
align-items: center;
justify-content: center;
transform-origin: center;
will-change: transform;
}
.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
}
.loading-indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: white;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-message {
color: white;
text-align: center;
}
.error-detail {
margin-top: 8px;
font-size: 14px;
color: #ff6b6b;
}
/* 窗口底部工具栏 */
.window-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.3);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.zoom-controls {
display: flex;
align-items: center;
gap: 8px;
}
.zoom-controls button {
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.1);
color: white;
font-size: 18px;
cursor: pointer;
transition: all 0.2s;
}
.zoom-controls button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.2);
}
.zoom-controls button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.zoom-level {
color: white;
font-size: 14px;
min-width: 50px;
text-align: center;
}
.quality-controls {
display: flex;
gap: 8px;
}
.quality-btn {
padding: 6px 12px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background: rgba(255, 255, 255, 0.1);
color: white;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.quality-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.quality-btn.active {
background: rgba(59, 130, 246, 0.6);
border-color: rgba(59, 130, 246, 1);
font-weight: 500;
}
</style>
@@ -0,0 +1,116 @@
import { httpClient } from '@/utils/http'
export interface PreviewConfig {
filePath: string
fileSize?: number // 文件大小(字节)
}
export interface PreviewUrls {
thumbnail: string // 初始缩略图 (720p)
highRes: string // 高清版本 (2K)
original: string // 原图
}
class PhotoPreviewService {
private baseUrl = '/v1/photo/preview'
/**
* 根据文件大小智能选择预览策略
* @param filePath 文件路径
* @param fileSize 文件大小(字节)
* @returns 预览 URL
*/
getPreviewUrl(filePath: string, fileSize?: number): string {
const encodedPath = encodeURIComponent(filePath)
if (!fileSize) {
// 未知文件大小,默认加载 720p 缩略图
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=720`
}
const MB = 1024 * 1024
if (fileSize < 5 * MB) {
// 小于 5MB:直接加载原图
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=0`
} else if (fileSize < 20 * MB) {
// 5-20MB:加载 1080p 缩略图
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=1080`
} else {
// 超过 20MB:加载 720p 缩略图
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=720`
}
}
/**
* 获取所有级别的预览 URL
* @param filePath 文件路径
* @returns 包含不同分辨率的 URL 对象
*/
getAllPreviewUrls(filePath: string): PreviewUrls {
const encodedPath = encodeURIComponent(filePath)
const baseURL = httpClient.getAxiosInstance().defaults.baseURL
return {
thumbnail: `${baseURL}${this.baseUrl}?path=${encodedPath}&size=720`,
highRes: `${baseURL}${this.baseUrl}?path=${encodedPath}&size=2048`,
original: `${baseURL}${this.baseUrl}?path=${encodedPath}&size=0`
}
}
/**
* 获取高清版本 URL(用户放大时加载)
* @param filePath 文件路径
* @returns 高清 URL
*/
getHighResUrl(filePath: string): string {
const encodedPath = encodeURIComponent(filePath)
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=2048`
}
/**
* 获取原图 URL(用户明确需要时加载)
* @param filePath 文件路径
* @returns 原图 URL
*/
getOriginalUrl(filePath: string): string {
const encodedPath = encodeURIComponent(filePath)
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=0`
}
/**
* 获取自定义尺寸的预览 URL
* @param filePath 文件路径
* @param size 最长边尺寸(像素)
* @returns 预览 URL
*/
getCustomSizeUrl(filePath: string, size: number): string {
const encodedPath = encodeURIComponent(filePath)
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=${size}`
}
/**
* 预加载图片(用于提前缓存)
* @param url 图片 URL
* @returns Promise
*/
preloadImage(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve()
img.onerror = () => reject(new Error(`Failed to preload image: ${url}`))
img.src = url
})
}
/**
* 批量预加载图片
* @param urls 图片 URL 数组
* @returns Promise
*/
async preloadImages(urls: string[]): Promise<void> {
await Promise.all(urls.map(url => this.preloadImage(url)))
}
}
export const photoPreviewService = new PhotoPreviewService()
@@ -531,6 +531,55 @@ func (h *PhotoHandler) GetPhotos(c *gin.Context) {
}) })
} }
// GetPhotoPreview 快速预览接口
// GET /api/v1/photo/preview?path=/path/to/photo.CR2&size=720
func (h *PhotoHandler) GetPhotoPreview(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, model.Response{
Code: http.StatusBadRequest,
Message: "Missing path parameter",
})
return
}
sizeParam := c.DefaultQuery("size", "720")
size, err := strconv.Atoi(sizeParam)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{
Code: http.StatusBadRequest,
Message: "Invalid size parameter",
})
return
}
// 创建 ImageAPI(会自动计算 Hash 并检测格式)
imageAPI, err := api.NewImageAPI(path)
if err != nil {
logger.Error("创建ImageAPI失败", zap.String("path", path), zap.Error(err))
c.JSON(http.StatusInternalServerError, model.Response{
Code: http.StatusInternalServerError,
Message: "Failed to process image",
})
return
}
// 获取图片路径(ImageAPI 内部会处理格式转换和缩略图生成)
imagePath, err := imageAPI.GetImagePath(size)
if err != nil {
logger.Error("获取图片路径失败", zap.String("path", path), zap.Error(err))
c.JSON(http.StatusInternalServerError, model.Response{
Code: http.StatusInternalServerError,
Message: "Failed to get image",
})
return
}
// 设置响应头并返回文件
h.setImageHeaders(c, imagePath, imageAPI.GetFormat())
c.File(imagePath)
}
// setImageHeaders 设置图像响应头 // setImageHeaders 设置图像响应头
func (h *PhotoHandler) setImageHeaders(c *gin.Context, imagePath, format string) { func (h *PhotoHandler) setImageHeaders(c *gin.Context, imagePath, format string) {
// 设置Content-Type // 设置Content-Type
+2 -1
View File
@@ -73,7 +73,8 @@ func SetupRoutes(r *gin.Engine, contain *container.DbContainer, imgContain *cont
// 照片相关路由 // 照片相关路由
photo := v1.Group("/photo") photo := v1.Group("/photo")
{ {
photo.GET("/:hash", photoHandler.GetPhoto) // 获取图像文件 photo.GET("/:hash", photoHandler.GetPhoto) // 获取图像文件
photo.GET("/preview", photoHandler.GetPhotoPreview) // 预览图片(通过路径)
} }
// 照片列表相关路由 // 照片列表相关路由