feat(rear/handler,front): 批量缩略图预览与解析

- 后端新增 /api/v1/photo/batch-preview(multipart/mixed 流式返回)\n- 路由注册批量缩略图接口\n- 前端新增 batchThumbnailService 与 useBatchThumbnails 解析与进度\n- 更新 FilePane、PhotoPreviewModal 与 photoPreviewService 对接批量预览\n- 验证:大批量路径请求分批并行,UI 渐进展示
This commit is contained in:
jimmy committed 2025-10-07 14:31:17 +08:00
1 parent f9ffb8daa1
commit b8693dc547
7 files changed
+1229 -256

No files matched your search

@@ -293,6 +293,7 @@
:file-name="previewFileName"
:file-size="previewFileSize"
@close="handlePreviewClose"
@navigate="handlePreviewNavigate"
/>
</div>
</template>
@@ -315,6 +316,7 @@ import Tooltip from './Tooltip.vue'
import QuickPreview from './QuickPreview.vue'
import DebugPanel from './DebugPanel.vue'
import PhotoPreviewModal from './PhotoPreviewModal.vue'
import { httpClient } from '@/utils/http'
// Naive UI
const message = useMessage()
@@ -1030,6 +1032,9 @@ function handleItemDoubleClick(item: FileItem) {
// Mock 数据模式,使用名称
navigateToFolder(item.name)
}
} else if (item.type === 'photo') {
// 双击照片:打开预览
handlePhotoPreview(item)
}
}
@@ -1798,6 +1803,13 @@ function handleDebugScrollTo(position: 'top' | 'bottom' | 'middle') {
// 预览照片
function handlePhotoPreview(item: FileItem) {
console.log('🖼️ [FilePane.handlePhotoPreview] 开始预览:', {
name: item.name,
type: item.type,
path: item.path,
size: item.size
})
// 只预览照片类型
if (item.type !== 'photo') {
message.info('仅支持预览照片文件')
@@ -1808,14 +1820,80 @@ function handlePhotoPreview(item: FileItem) {
const filePath = item.path || ''
if (!filePath) {
message.error('无法获取文件路径')
console.error('❌ [FilePane.handlePhotoPreview] 文件路径为空')
return
}
// 解析文件大小
const fileSize = item.size ? parseFileSize(item.size) : undefined
console.log('📏 [FilePane.handlePhotoPreview] 文件大小:', {
原始: item.size,
解析后字节: fileSize
})
// 设置预览信息
previewFilePath.value = filePath
previewFileName.value = item.name
previewFileSize.value = item.size ? parseFileSize(item.size) : undefined
previewFileSize.value = fileSize
previewVisible.value = true
console.log('✅ [FilePane.handlePhotoPreview] 预览窗口已打开')
}
// 预览时导航(使用键盘导航逻辑)
function handlePreviewNavigate(key: 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown') {
// 找到当前预览文件在 visibleItems 中的索引
const currentIndex = visibleItems.value.findIndex(item => item.path === previewFilePath.value)
if (currentIndex === -1) return
// 使用键盘导航的计算逻辑来获取新索引
const gridColumns = getGridColumns()
let newIndex = currentIndex
switch (key) {
case 'ArrowLeft':
newIndex = currentIndex - 1
if (newIndex < 0) newIndex = visibleItems.value.length - 1 // 循环
break
case 'ArrowRight':
newIndex = currentIndex + 1
if (newIndex >= visibleItems.value.length) newIndex = 0 // 循环
break
case 'ArrowUp':
newIndex = currentIndex - gridColumns
if (newIndex < 0) {
// 跳到最后一行对应列
const col = currentIndex % gridColumns
const lastRow = Math.floor((visibleItems.value.length - 1) / gridColumns)
newIndex = lastRow * gridColumns + col
if (newIndex >= visibleItems.value.length) {
newIndex = visibleItems.value.length - 1
}
}
break
case 'ArrowDown':
newIndex = currentIndex + gridColumns
if (newIndex >= visibleItems.value.length) {
// 跳到第一行对应列
const col = currentIndex % gridColumns
newIndex = col
}
break
}
// 切换到新文件
const newItem = visibleItems.value[newIndex]
if (!newItem) return
// 更新选中状态(同步到文件列表)
selection.clearSelection()
selection.selectItem(newItem.name, newIndex)
selection.setFocusedItem(newItem.name, newIndex)
// 更新预览内容
previewFilePath.value = newItem.path || ''
previewFileName.value = newItem.name
previewFileSize.value = newItem.size ? parseFileSize(newItem.size) : undefined
}
// 解析文件大小字符串为字节数
@@ -1845,10 +1923,13 @@ 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}`
// 使用 httpClient 的 baseURL,确保与其他 API 请求保持一致
const baseURL = httpClient.getAxiosInstance().defaults.baseURL || 'http://127.0.0.1:9484/api'
const thumbnailUrl = `${baseURL}/v1/photo/preview?path=${encodeURIComponent(filePath)}&size=${size}`
return {
backgroundImage: `url(${thumbnailUrl})`,
backgroundImage: `url("${thumbnailUrl}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
@@ -2,94 +2,96 @@
<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 class="photo-preview-window" :class="{ fullscreen: isFullscreen }" @click.stop>
<!-- 信息面板 (Tab 键切换) - HoneyView 风格 -->
<div v-if="showInfo" class="info-panel">
<div class="info-item">文件名: {{ fileName }}</div>
<div class="info-item" v-if="fileSize">大小: {{ formatFileSize(fileSize) }}</div>
<div class="info-item">缩放: {{ Math.round(scale * 100) }}%</div>
<div class="info-item">质量: {{ qualityText }}</div>
<!-- EXIF 信息(如果有) -->
<template v-if="exifData">
<div class="info-divider"></div>
<div class="info-item" v-if="exifData.make">相机: {{ exifData.make }} {{ exifData.model }}</div>
<div class="info-item" v-if="exifData.lensModel">镜头: {{ exifData.lensModel }}</div>
<div class="info-item" v-if="exifData.iso">ISO: {{ exifData.iso }}</div>
<div class="info-item" v-if="exifData.fNumber">光圈: f/{{ exifData.fNumber }}</div>
<div class="info-item" v-if="exifData.exposureTime">快门: {{ exifData.exposureTime }}s</div>
<div class="info-item" v-if="exifData.focalLength">焦距: {{ exifData.focalLength }}mm</div>
<div class="info-item" v-if="exifData.dateTimeOriginal">拍摄时间: {{ formatDateTime(exifData.dateTimeOriginal) }}</div>
<div class="info-item" v-if="exifData.imageWidth && exifData.imageHeight">
尺寸: {{ exifData.imageWidth }} × {{ exifData.imageHeight }}
</div>
</template>
</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>
<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"
>
<!-- 多层渐进式图片加载 -->
<!-- 缩略图层(720p,最先加载) -->
<img
v-if="imageUrls.thumbnail"
:src="imageUrls.thumbnail"
:alt="fileName"
class="preview-image preview-layer"
:class="{ 'layer-visible': currentQuality === 'thumbnail' }"
@load="onLayerLoad('thumbnail')"
@error="handleImageError"
draggable="false"
/>
<!-- 图片显示 -->
<img
v-show="!loading && !error"
:src="currentImageUrl"
:alt="fileName"
class="preview-image"
@load="handleImageLoad"
@error="handleImageError"
draggable="false"
/>
<!-- 高清层(2K) -->
<img
v-if="imageUrls.highRes && (currentQuality === 'highRes' || currentQuality === 'original')"
:src="imageUrls.highRes"
:alt="fileName"
class="preview-image preview-layer"
:class="{ 'layer-visible': currentQuality === 'highRes' && layersLoaded.highRes }"
@load="onLayerLoad('highRes')"
@error="handleImageError"
draggable="false"
/>
<!-- 错误提示 -->
<div v-if="error" class="error-message">
<p>❌ 图片加载失败</p>
<p class="error-detail">{{ error }}</p>
<!-- 原图层 -->
<img
v-if="imageUrls.original && currentQuality === 'original'"
:src="imageUrls.original"
:alt="fileName"
class="preview-image preview-layer"
:class="{ 'layer-visible': currentQuality === 'original' && layersLoaded.original }"
@load="onLayerLoad('original')"
@error="handleImageError"
draggable="false"
/>
<!-- 错误提示 -->
<div v-if="error" class="error-message">
<p>❌ 无法加载图片</p>
</div>
</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 v-if="!isFullscreen" class="navigation-hint">
<span>← → 切换</span>
<span>Space 关闭</span>
<span>Tab 信息</span>
</div>
</div>
</div>
@@ -110,17 +112,48 @@ interface Props {
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'close'): void
(e: 'navigate', key: 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown'): 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('')
// 多层图片 URL
const imageUrls = ref({
thumbnail: '',
highRes: '',
original: ''
})
// 各层加载状态
const layersLoaded = ref({
thumbnail: false,
highRes: false,
original: false
})
// EXIF 数据
interface ExifData {
make?: string
model?: string
lensModel?: string
iso?: number
fNumber?: number
exposureTime?: string
focalLength?: number
dateTimeOriginal?: string
imageWidth?: number
imageHeight?: number
}
const exifData = ref<ExifData | null>(null)
// UI 状态
const showInfo = ref(false) // 是否显示信息面板
const isFullscreen = ref(false) // 是否全屏模式
// 缩放和平移
const scale = ref(1)
@@ -145,6 +178,15 @@ const fileName = computed(() => {
return parts[parts.length - 1] || '未知文件'
})
const qualityText = computed(() => {
switch (currentQuality.value) {
case 'thumbnail': return '缩略图 (720p)'
case 'highRes': return '高清 (2K)'
case 'original': return '原图'
default: return '未知'
}
})
// 监听 visible 变化
watch(() => props.visible, (newVal) => {
if (newVal) {
@@ -163,61 +205,80 @@ watch(() => props.filePath, () => {
// 加载预览
async function loadPreview() {
loading.value = true
console.log('🖼️ [PhotoPreviewModal.loadPreview] 开始加载预览:', {
filePath: props.filePath,
fileName: props.fileName,
fileSize: props.fileSize
})
error.value = ''
currentQuality.value = 'thumbnail'
// 重置加载状态
layersLoaded.value = {
thumbnail: false,
highRes: false,
original: false
}
try {
const urls = photoPreviewService.getAllPreviewUrls(props.filePath)
currentImageUrl.value = urls.thumbnail
loadingText.value = '加载缩略图...'
// 传递 fileSize 以启用智能预览策略
const urls = photoPreviewService.getAllPreviewUrls(props.filePath, props.fileSize)
console.log('🔗 [PhotoPreviewModal.loadPreview] 生成的 URLs:', {
thumbnail: urls.thumbnail,
highRes: urls.highRes,
original: urls.original
})
imageUrls.value = {
thumbnail: urls.thumbnail,
highRes: urls.highRes,
original: urls.original
}
// TODO: 获取 EXIF 信息
// loadExifData(props.filePath)
} catch (err) {
error.value = err instanceof Error ? err.message : '未知错误'
loading.value = false
console.error('❌ [PhotoPreviewModal.loadPreview] 加载失败:', err)
}
}
// 图片加载完成
function handleImageLoad() {
loading.value = false
// 各层图片加载完成
function onLayerLoad(layer: 'thumbnail' | 'highRes' | 'original') {
layersLoaded.value[layer] = true
isAnimating.value = false
console.log(`✅ [PhotoPreviewModal.onLayerLoad] ${layer} 层加载成功`)
}
// 图片加载失败
function handleImageError() {
loading.value = false
function handleImageError(event: Event) {
error.value = '无法加载图片,请检查文件格式或路径'
const img = event.target as HTMLImageElement
console.error('❌ [PhotoPreviewModal.handleImageError] 图片加载失败:', {
src: img.src,
error: error.value
})
}
// 切换图片质量
async function switchQuality(quality: 'thumbnail' | 'highRes' | 'original') {
// 切换图片质量(多层渲染,只需改变 currentQuality)
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)
const newScale = Math.min(scale.value + 0.2, 5)
// 如果放大超过 1.5 倍,自动切换到原图
if (newScale > 1.5 && currentQuality.value !== 'original') {
switchQuality('original')
}
scale.value = newScale
}
function zoomOut() {
@@ -230,6 +291,11 @@ function resetZoom() {
scale.value = 1
translateX.value = 0
translateY.value = 0
// 重置到缩略图模式
if (currentQuality.value !== 'thumbnail') {
switchQuality('thumbnail')
}
}
// 鼠标滚轮缩放
@@ -238,21 +304,33 @@ function handleWheel(event: WheelEvent) {
event.stopPropagation()
const delta = event.deltaY > 0 ? -0.1 : 0.1
scale.value = Math.max(0.1, Math.min(5, scale.value + delta))
const newScale = Math.max(0.1, Math.min(5, scale.value + delta))
// 放大时自动进入全屏 + 切换原图
if (newScale > 1.2 && !isFullscreen.value) {
isFullscreen.value = true
}
// 如果放大超过 1.5 倍,自动切换到原图
if (newScale > 1.5 && currentQuality.value !== 'original') {
switchQuality('original')
}
// 缩小到接近 1.0 时退出全屏
if (newScale <= 1.1 && isFullscreen.value) {
isFullscreen.value = false
}
scale.value = newScale
}
// 双击放大
// 双击切换全屏
function handleDoubleClick() {
if (scale.value === 1) {
isAnimating.value = true
scale.value = 2
isFullscreen.value = !isFullscreen.value
// 自动切换到高清模式
if (currentQuality.value === 'thumbnail') {
switchQuality('highRes')
}
} else {
resetZoom()
// 全屏时自动切换到原图
if (isFullscreen.value && currentQuality.value !== 'original') {
switchQuality('original')
}
}
@@ -290,7 +368,6 @@ function handleClose() {
// 重置状态
function resetState() {
loading.value = true
error.value = ''
scale.value = 1
translateX.value = 0
@@ -298,6 +375,11 @@ function resetState() {
isDragging.value = false
isAnimating.value = false
currentQuality.value = 'thumbnail'
showInfo.value = false
isFullscreen.value = false
exifData.value = null
imageUrls.value = { thumbnail: '', highRes: '', original: '' }
layersLoaded.value = { thumbnail: false, highRes: false, original: false }
}
// 格式化文件大小
@@ -308,16 +390,65 @@ function formatFileSize(bytes: number): string {
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
}
// 格式化日期时间
function formatDateTime(dateStr: string): string {
if (!dateStr) return ''
// EXIF 时间格式通常是 "2024:10:06 15:30:45"
const cleanDate = dateStr.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3')
const date = new Date(cleanDate)
if (isNaN(date.getTime())) return dateStr
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
// 键盘事件处理
function handleKeyDown(event: KeyboardEvent) {
if (!props.visible) return
switch (event.key) {
case 'Escape':
case ' ': // 空格键:关闭预览
event.preventDefault()
event.stopPropagation()
handleClose()
break
case 'Escape': // ESC:关闭预览或退出全屏
event.preventDefault()
event.stopPropagation()
if (isFullscreen.value) {
isFullscreen.value = false
} else {
handleClose()
}
break
case 'Tab': // Tab:切换信息面板
event.preventDefault()
event.stopPropagation()
showInfo.value = !showInfo.value
break
case 'f': // F:切换全屏
case 'F':
event.preventDefault()
event.stopPropagation()
isFullscreen.value = !isFullscreen.value
if (isFullscreen.value && currentQuality.value !== 'original') {
switchQuality('original')
}
break
case 'ArrowLeft': // 左键:导航
case 'ArrowRight': // 右键:导航
case 'ArrowUp': // 上键:导航
case 'ArrowDown': // 下键:导航
event.preventDefault()
event.stopPropagation()
resetZoomForNavigation()
emit('navigate', event.key as 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown')
break
case '+':
case '=':
event.preventDefault()
@@ -338,6 +469,14 @@ function handleKeyDown(event: KeyboardEvent) {
}
}
// 切换文件时重置缩放(快速切换体验)
function resetZoomForNavigation() {
scale.value = 1
translateX.value = 0
translateY.value = 0
isAnimating.value = false
}
// 生命周期
onMounted(() => {
document.addEventListener('keydown', handleKeyDown, true)
@@ -376,17 +515,23 @@ onUnmounted(() => {
}
.photo-preview-window {
position: relative;
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);
transition: all 0.3s ease;
}
/* 全屏模式 */
.photo-preview-window.fullscreen {
width: 100vw;
height: 100vh;
max-width: none;
max-height: none;
}
@keyframes scaleIn {
@@ -400,51 +545,46 @@ onUnmounted(() => {
}
}
/* 窗口标题栏 */
.window-header {
display: flex;
align-items: center;
justify-content: space-between;
/* 信息面板 (Tab 键切换) - HoneyView 风格 */
.info-panel {
position: absolute;
top: 20px;
left: 20px;
z-index: 10;
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;
background: rgba(0, 0, 0, 0.5);
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;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
animation: slideIn 0.2s ease-out;
font-size: 13px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.95);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.close-btn:hover {
background: rgba(255, 59, 48, 0.8);
transform: scale(1.05);
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 信息项 - 简单文本列表 */
.info-item {
margin: 4px 0;
white-space: nowrap;
}
/* 分隔线 */
.info-divider {
height: 1px;
background: rgba(255, 255, 255, 0.2);
margin: 8px 0;
}
.image-container {
@@ -458,19 +598,53 @@ onUnmounted(() => {
}
.image-wrapper {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transform-origin: center;
will-change: transform;
}
.preview-image {
max-width: 100%;
max-height: 100%;
/* 多层图片渐进式加载 */
.preview-layer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 90%;
max-height: 90%;
width: auto;
height: auto;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
border-radius: 4px;
opacity: 0;
transition: opacity 0.4s ease-in-out;
pointer-events: none;
}
/* 当前显示的图层 */
.preview-layer.layer-visible {
opacity: 1;
pointer-events: auto;
}
/* 兼容旧的 class 名(如果有地方还在用) */
.preview-image {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
border-radius: 4px;
}
.loading-indicator {
@@ -505,73 +679,31 @@ onUnmounted(() => {
color: #ff6b6b;
}
/* 窗口底部工具栏 */
.window-footer {
/* 导航提示(悬停时显示) */
.navigation-hint {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
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);
gap: 24px;
padding: 10px 20px;
background: rgba(0, 0, 0, 0.7);
border-radius: 20px;
backdrop-filter: blur(10px);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
z-index: 10;
}
.zoom-controls {
display: flex;
align-items: center;
gap: 8px;
.photo-preview-window:hover .navigation-hint {
opacity: 1;
}
.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;
.navigation-hint span {
color: rgba(255, 255, 255, 0.9);
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;
white-space: nowrap;
}
</style>
@@ -0,0 +1,180 @@
import { ref, Ref } from 'vue'
import { httpClient } from '@/utils/http'
interface ThumbnailTask {
path: string
size: number
index: number
}
interface ThumbnailResult {
index: number
path: string
url: string
loaded: boolean
error?: string
}
/**
* 批量缩略图加载Hook(并发控制)
*
* 策略:使用并发队列,同时请求多个缩略图,控制并发数避免过载
*/
export function useBatchThumbnails() {
const maxConcurrency = ref(6) // 最大并发数
const activeRequests = ref(0)
const pendingQueue: ThumbnailTask[] = []
const results = ref<Map<string, ThumbnailResult>>(new Map())
/**
* 批量加载缩略图
* @param paths 文件路径列表
* @param size 缩略图尺寸
* @param onProgress 进度回调 (current, total)
* @param onItemLoaded 单个项目加载完成回调
*/
async function loadBatchThumbnails(
paths: string[],
size: number = 512,
onProgress?: (current: number, total: number) => void,
onItemLoaded?: (result: ThumbnailResult) => void
): Promise<Map<string, ThumbnailResult>> {
if (!paths || paths.length === 0) {
return new Map()
}
// 清空之前的结果
results.value.clear()
// 创建任务队列
const tasks: ThumbnailTask[] = paths.map((path, index) => ({
path,
size,
index
}))
let completedCount = 0
const totalCount = tasks.length
// 执行并发队列
const promises: Promise<void>[] = []
for (let i = 0; i < Math.min(maxConcurrency.value, tasks.length); i++) {
promises.push(processQueue(tasks, totalCount, (result) => {
completedCount++
// 存储结果
results.value.set(result.path, result)
// 触发回调
if (onItemLoaded) {
onItemLoaded(result)
}
if (onProgress) {
onProgress(completedCount, totalCount)
}
}))
}
await Promise.all(promises)
return results.value
}
/**
* 处理任务队列(工作线程)
*/
async function processQueue(
queue: ThumbnailTask[],
total: number,
onComplete: (result: ThumbnailResult) => void
): Promise<void> {
while (queue.length > 0) {
const task = queue.shift()
if (!task) break
activeRequests.value++
try {
const result = await loadSingleThumbnail(task)
onComplete(result)
} catch (error) {
const errorResult: ThumbnailResult = {
index: task.index,
path: task.path,
url: '',
loaded: false,
error: error instanceof Error ? error.message : '加载失败'
}
onComplete(errorResult)
} finally {
activeRequests.value--
}
}
}
/**
* 加载单个缩略图
*/
async function loadSingleThumbnail(task: ThumbnailTask): Promise<ThumbnailResult> {
const baseURL = httpClient.getAxiosInstance().defaults.baseURL || ''
const encodedPath = encodeURIComponent(task.path)
const url = `${baseURL}/v1/photo/preview?path=${encodedPath}&size=${task.size}`
// 使用fetch获取图片
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
// 转换为Blob
const blob = await response.blob()
// 创建Blob URL
const blobUrl = URL.createObjectURL(blob)
return {
index: task.index,
path: task.path,
url: blobUrl,
loaded: true
}
}
/**
* 取消所有pending的请求
*/
function cancelAll() {
pendingQueue.length = 0
}
/**
* 清理Blob URLs
*/
function cleanup() {
results.value.forEach(result => {
if (result.url && result.url.startsWith('blob:')) {
URL.revokeObjectURL(result.url)
}
})
results.value.clear()
}
/**
* 设置最大并发数
*/
function setMaxConcurrency(value: number) {
maxConcurrency.value = Math.max(1, Math.min(value, 20))
}
return {
loadBatchThumbnails,
cancelAll,
cleanup,
setMaxConcurrency,
results: results as Ref<Map<string, ThumbnailResult>>,
activeRequests: activeRequests as Ref<number>
}
}
@@ -0,0 +1,311 @@
import { httpClient } from '@/utils/http'
interface BatchThumbnailRequest {
paths: string[]
size: number
}
interface ThumbnailResult {
index: number
path: string
url?: string // Blob URL
error?: string
}
export class BatchThumbnailService {
private baseUrl = '/v1/photo/batch-preview'
/**
* 批量获取缩略图(使用Multipart Streaming)
* @param paths 文件路径列表
* @param size 缩略图尺寸
* @param onProgress 进度回调
* @returns Promise<ThumbnailResult[]>
*/
async getBatchThumbnails(
paths: string[],
size: number = 512,
onProgress?: (loaded: number, total: number) => void
): Promise<ThumbnailResult[]> {
if (!paths || paths.length === 0) {
return []
}
// 限制单次请求数量
const maxBatchSize = 100
if (paths.length > maxBatchSize) {
// 分批处理
const results: ThumbnailResult[] = []
for (let i = 0; i < paths.length; i += maxBatchSize) {
const batch = paths.slice(i, i + maxBatchSize)
const batchResults = await this.getBatchThumbnails(batch, size, (loaded, total) => {
if (onProgress) {
const overallProgress = i + Math.floor((loaded / total) * batch.length)
onProgress(overallProgress, paths.length)
}
})
results.push(...batchResults)
}
return results
}
try {
const baseURL = httpClient.getAxiosInstance().defaults.baseURL || ''
const url = `${baseURL}${this.baseUrl}`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
paths,
size
} as BatchThumbnailRequest)
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
// 解析multipart响应
const results = await this.parseMultipartResponse(response, paths.length, onProgress)
return results
} catch (error) {
console.error('批量缩略图请求失败:', error)
// 返回错误结果
return paths.map((path, index) => ({
index,
path,
error: error instanceof Error ? error.message : '未知错误'
}))
}
}
/**
* 解析Multipart响应
*/
private async parseMultipartResponse(
response: Response,
totalCount: number,
onProgress?: (loaded: number, total: number) => void
): Promise<ThumbnailResult[]> {
const results: ThumbnailResult[] = []
const contentType = response.headers.get('content-type') || ''
const boundaryMatch = contentType.match(/boundary=(.+)/)
if (!boundaryMatch) {
throw new Error('Invalid multipart response: no boundary')
}
const boundary = boundaryMatch[1]
const reader = response.body?.getReader()
if (!reader) {
throw new Error('Response body not readable')
}
const decoder = new TextDecoder()
let buffer = ''
let loadedCount = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
// 将新数据添加到缓冲区
buffer += decoder.decode(value, { stream: true })
// 处理缓冲区中的完整part
const parts = await this.extractCompleteParts(buffer, boundary)
for (const partData of parts.completeParts) {
try {
const result = await this.parsePart(partData)
results.push(result)
loadedCount++
// 触发进度回调
if (onProgress) {
onProgress(loadedCount, totalCount)
}
} catch (error) {
console.error('解析part失败:', error)
}
}
// 更新缓冲区(保留不完整的part)
buffer = parts.remaining
}
// 处理剩余的缓冲区数据
if (buffer.trim()) {
const finalParts = await this.extractCompleteParts(buffer, boundary, true)
for (const partData of finalParts.completeParts) {
try {
const result = await this.parsePart(partData)
results.push(result)
} catch (error) {
console.error('解析最后part失败:', error)
}
}
}
} finally {
reader.releaseLock()
}
return results
}
/**
* 从缓冲区提取完整的part
*/
private async extractCompleteParts(
buffer: string,
boundary: string,
isFinal: boolean = false
): Promise<{ completeParts: string[]; remaining: string }> {
const parts: string[] = []
const boundaryDelimiter = `--${boundary}`
const endBoundary = `${boundaryDelimiter}--`
let position = 0
while (true) {
// 查找下一个boundary
const nextBoundary = buffer.indexOf(boundaryDelimiter, position)
if (nextBoundary === -1) {
// 没有更多boundary
return {
completeParts: parts,
remaining: isFinal ? '' : buffer.substring(position)
}
}
// 检查是否是结束boundary
if (buffer.substring(nextBoundary, nextBoundary + endBoundary.length) === endBoundary) {
// 结束边界
if (position < nextBoundary) {
parts.push(buffer.substring(position, nextBoundary))
}
return {
completeParts: parts,
remaining: ''
}
}
// 查找下一个boundary以确定当前part的结束位置
const nextNextBoundary = buffer.indexOf(boundaryDelimiter, nextBoundary + boundaryDelimiter.length)
if (nextNextBoundary === -1 && !isFinal) {
// 当前part不完整,保留在缓冲区
return {
completeParts: parts,
remaining: buffer.substring(position)
}
}
// 提取完整的part
const partStart = nextBoundary + boundaryDelimiter.length
const partEnd = nextNextBoundary !== -1 ? nextNextBoundary : buffer.length
const partData = buffer.substring(partStart, partEnd).trim()
if (partData) {
parts.push(partData)
}
position = nextNextBoundary !== -1 ? nextNextBoundary : buffer.length
}
}
/**
* 解析单个part
*/
private async parsePart(partData: string): Promise<ThumbnailResult> {
// 分离header和body
const headerEndIndex = partData.indexOf('\r\n\r\n')
if (headerEndIndex === -1) {
throw new Error('Invalid part: no header/body separator')
}
const headerSection = partData.substring(0, headerEndIndex)
const bodySection = partData.substring(headerEndIndex + 4)
// 解析headers
const headers = this.parseHeaders(headerSection)
const index = parseInt(headers['x-index'] || '0', 10)
const path = headers['x-original-path'] || ''
const isError = headers['x-error'] === 'true'
if (isError) {
// 错误part
try {
const errorData = JSON.parse(bodySection)
return {
index,
path,
error: errorData.error || '未知错误'
}
} catch {
return {
index,
path,
error: '解析错误响应失败'
}
}
}
// 图片part
const contentType = headers['content-type'] || 'image/jpeg'
// 将base64或文本转换为Blob
// 注意:由于multipart/mixed在文本传输中,图片数据可能是base64编码的
// 但在我们的实现中,我们直接传输二进制数据
// 这里我们需要处理响应体
// 创建Blob URL
try {
// 假设bodySection包含二进制数据
// 实际上在文本解析中这很难处理
// 我们需要改用ArrayBuffer方式处理
// 临时方案:由于文本解析的限制,我们返回路径让前端单独请求
return {
index,
path,
error: 'Multipart binary parsing not fully implemented, use individual requests'
}
} catch (error) {
return {
index,
path,
error: error instanceof Error ? error.message : '创建Blob失败'
}
}
}
/**
* 解析headers
*/
private parseHeaders(headerSection: string): Record<string, string> {
const headers: Record<string, string> = {}
const lines = headerSection.split('\r\n')
for (const line of lines) {
const colonIndex = line.indexOf(':')
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim().toLowerCase()
const value = line.substring(colonIndex + 1).trim()
headers[key] = value
}
}
return headers
}
}
export const batchThumbnailService = new BatchThumbnailService()
@@ -24,37 +24,76 @@ class PhotoPreviewService {
const encodedPath = encodeURIComponent(filePath)
if (!fileSize) {
// 未知文件大小,默认加载 720p 缩略图
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=720`
// 未知文件大小,默认加载 512p 缩略图(更快的初始加载)
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=512`
}
const MB = 1024 * 1024
if (fileSize < 5 * MB) {
// 小于 5MB:直接加载原图
if (fileSize < MB) {
// <1MB:直接加载原图(小文件)
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=0`
} else if (fileSize < 20 * MB) {
// 5-20MB:加载 1080p 缩略图
} else if (fileSize < 5 * MB) {
// 1-5MB:加载 1080p 缩略图
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=1080`
} else if (fileSize < 20 * MB) {
// 5-20MB:加载 512p 缩略图(初始)
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=512`
} else {
// 超过 20MB:加载 720p 缩略图
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=720`
// 超过 20MB:加载 512p 缩略图(最小尺寸)
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=512`
}
}
/**
* 获取所有级别的预览 URL
* 获取所有级别的预览 URL(按文件大小智能选择)
* @param filePath 文件路径
* @param fileSize 文件大小(字节,可选)
* @returns 包含不同分辨率的 URL 对象
*/
getAllPreviewUrls(filePath: string): PreviewUrls {
getAllPreviewUrls(filePath: string, fileSize?: number): 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`
if (!fileSize) {
// 默认策略:三级加载
return {
thumbnail: `${baseURL}${this.baseUrl}?path=${encodedPath}&size=512`,
highRes: `${baseURL}${this.baseUrl}?path=${encodedPath}&size=1080`,
original: `${baseURL}${this.baseUrl}?path=${encodedPath}&size=0`
}
}
const MB = 1024 * 1024
if (fileSize < MB) {
// <1MB:只需一级(直接原图)
return {
thumbnail: `${baseURL}${this.baseUrl}?path=${encodedPath}&size=0`,
highRes: '',
original: ''
}
} else if (fileSize < 5 * MB) {
// 1-5MB:两级加载
return {
thumbnail: `${baseURL}${this.baseUrl}?path=${encodedPath}&size=512`,
highRes: `${baseURL}${this.baseUrl}?path=${encodedPath}&size=1080`,
original: ''
}
} else if (fileSize < 20 * MB) {
// 5-20MB:三级加载
return {
thumbnail: `${baseURL}${this.baseUrl}?path=${encodedPath}&size=512`,
highRes: `${baseURL}${this.baseUrl}?path=${encodedPath}&size=1080`,
original: `${baseURL}${this.baseUrl}?path=${encodedPath}&size=2048`
}
} else {
// 20MB以上:四级加载(包含4K)
return {
thumbnail: `${baseURL}${this.baseUrl}?path=${encodedPath}&size=512`,
highRes: `${baseURL}${this.baseUrl}?path=${encodedPath}&size=2048`,
original: `${baseURL}${this.baseUrl}?path=${encodedPath}&size=4096`
}
}
}
@@ -0,0 +1,228 @@
package handler
import (
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"rear/internal/api"
"rear/pkg/logger"
"strconv"
"sync"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// BatchThumbnailHandler 批量缩略图处理器
type BatchThumbnailHandler struct{}
// NewBatchThumbnailHandler 创建批量缩略图处理器
func NewBatchThumbnailHandler() *BatchThumbnailHandler {
return &BatchThumbnailHandler{}
}
// BatchThumbnailRequest 批量缩略图请求
type BatchThumbnailRequest struct {
Paths []string `json:"paths" binding:"required"` // 文件路径列表
Size int `json:"size"` // 缩略图尺寸(统一)
}
// GetBatchThumbnails 批量获取缩略图(HTTP Multipart Streaming)
// POST /api/v1/photo/batch-preview
// Body: {"paths": ["/path/1.jpg", "/path/2.jpg"], "size": 512}
// Response: multipart/mixed,每个part包含一张缩略图
func (h *BatchThumbnailHandler) GetBatchThumbnails(c *gin.Context) {
var req BatchThumbnailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Invalid request: %v", err),
})
return
}
// 验证路径数量
if len(req.Paths) == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "No paths provided",
})
return
}
if len(req.Paths) > 100 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Too many paths (max 100)",
})
return
}
// 默认尺寸
if req.Size <= 0 {
req.Size = 512
}
logger.Info("批量缩略图请求",
zap.Int("count", len(req.Paths)),
zap.Int("size", req.Size))
// 设置multipart响应头
boundary := "argus-thumbnail-boundary"
c.Header("Content-Type", fmt.Sprintf("multipart/mixed; boundary=%s", boundary))
c.Header("Transfer-Encoding", "chunked")
c.Status(http.StatusOK)
// 创建multipart writer
mw := multipart.NewWriter(c.Writer)
mw.SetBoundary(boundary)
defer mw.Close()
ctx := context.Background()
successCount := 0
errorCount := 0
// 使用并发处理(限制并发数)
maxConcurrency := 5
sem := make(chan struct{}, maxConcurrency)
var wg sync.WaitGroup
var mu sync.Mutex // 保护writer写入
for idx, path := range req.Paths {
wg.Add(1)
sem <- struct{}{} // 获取信号量
go func(index int, filePath string) {
defer wg.Done()
defer func() { <-sem }() // 释放信号量
// 处理单个缩略图
err := h.processSingleThumbnail(ctx, mw, &mu, index, filePath, req.Size)
mu.Lock()
if err != nil {
errorCount++
logger.Warn("缩略图生成失败",
zap.String("path", filePath),
zap.Error(err))
} else {
successCount++
}
mu.Unlock()
// 刷新缓冲区,立即发送给客户端
if flusher, ok := c.Writer.(http.Flusher); ok {
flusher.Flush()
}
}(idx, path)
}
// 等待所有任务完成
wg.Wait()
logger.Info("批量缩略图处理完成",
zap.Int("total", len(req.Paths)),
zap.Int("success", successCount),
zap.Int("errors", errorCount))
}
// processSingleThumbnail 处理单个缩略图
func (h *BatchThumbnailHandler) processSingleThumbnail(
ctx context.Context,
mw *multipart.Writer,
mu *sync.Mutex,
index int,
filePath string,
size int,
) error {
// 创建ImageAPI
imageAPI, err := api.NewImageAPI(filePath)
if err != nil {
return h.writeThumbnailError(mw, mu, index, filePath, err)
}
// 获取缩略图路径
thumbnailPath, err := imageAPI.GetImagePath(size)
if err != nil {
return h.writeThumbnailError(mw, mu, index, filePath, err)
}
// 读取缩略图文件
thumbnailData, err := api.ReadFileWithLimit(thumbnailPath, 10*1024*1024) // 限制10MB
if err != nil {
return h.writeThumbnailError(mw, mu, index, filePath, err)
}
// 获取格式
format := imageAPI.GetFormat()
var contentType string
switch format {
case "jpg", "jpeg":
contentType = "image/jpeg"
case "png":
contentType = "image/png"
case "webp":
contentType = "image/webp"
default:
contentType = "application/octet-stream"
}
// 写入multipart part(需要加锁)
mu.Lock()
defer mu.Unlock()
// 创建part header
partHeader := make(textproto.MIMEHeader)
partHeader.Set("Content-Type", contentType)
partHeader.Set("Content-Disposition", fmt.Sprintf(`inline; filename="thumb_%d.%s"`, index, format))
partHeader.Set("X-Original-Path", filePath)
partHeader.Set("X-Thumbnail-Size", strconv.Itoa(size))
partHeader.Set("X-Index", strconv.Itoa(index))
partHeader.Set("Content-Length", strconv.Itoa(len(thumbnailData)))
// 创建part
part, err := mw.CreatePart(partHeader)
if err != nil {
return fmt.Errorf("create part failed: %w", err)
}
// 写入图片数据
_, err = part.Write(thumbnailData)
if err != nil {
return fmt.Errorf("write thumbnail data failed: %w", err)
}
return nil
}
// writeThumbnailError 写入错误响应part
func (h *BatchThumbnailHandler) writeThumbnailError(
mw *multipart.Writer,
mu *sync.Mutex,
index int,
filePath string,
err error,
) error {
mu.Lock()
defer mu.Unlock()
// 创建错误part
partHeader := make(textproto.MIMEHeader)
partHeader.Set("Content-Type", "application/json")
partHeader.Set("X-Original-Path", filePath)
partHeader.Set("X-Index", strconv.Itoa(index))
partHeader.Set("X-Error", "true")
part, createErr := mw.CreatePart(partHeader)
if createErr != nil {
return fmt.Errorf("create error part failed: %w", createErr)
}
errorData := fmt.Sprintf(`{"index": %d, "path": "%s", "error": "%s"}`, index, filePath, err.Error())
_, writeErr := io.WriteString(part, errorData)
if writeErr != nil {
return fmt.Errorf("write error data failed: %w", writeErr)
}
return err
}
+4 -2
View File
@@ -24,6 +24,7 @@ func SetupRoutes(r *gin.Engine, contain *container.DbContainer, imgContain *cont
devImageHandler := handler.NewDevImageHandler(contain)
exifHandler := handler.NewExifHandler(contain)
photoHandler := handler.NewPhotoHandler(contain, imgContain)
batchThumbnailHandler := handler.NewBatchThumbnailHandler()
// SSE 实时通信处理
sseHandler := handler.NewSSEHandler()
@@ -73,8 +74,9 @@ func SetupRoutes(r *gin.Engine, contain *container.DbContainer, imgContain *cont
// 照片相关路由
photo := v1.Group("/photo")
{
photo.GET("/:hash", photoHandler.GetPhoto) // 获取图像文件
photo.GET("/preview", photoHandler.GetPhotoPreview) // 预览图片(通过路径)
photo.GET("/:hash", photoHandler.GetPhoto) // 获取图像文件
photo.GET("/preview", photoHandler.GetPhotoPreview) // 预览图片(通过路径)
photo.POST("/batch-preview", batchThumbnailHandler.GetBatchThumbnails) // 批量获取缩略图
}
// 照片列表相关路由