Compare commits
4
Commits
e5009ee984
...
98feb62cde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98feb62cde | ||
|
|
c42628f38d | ||
|
|
1241d147a6 | ||
|
|
f15fb85251 |
No files matched your search
@@ -0,0 +1,42 @@
|
||||
# Repository Guidelines
|
||||
|
||||
This document is the top‑level guide for working in the `go-argus/src` monorepo. Project‑specific details live in `rear/AGENTS.md` and `front/argus-front/AGENTS.md`.
|
||||
|
||||
## Project Structure & Organization
|
||||
|
||||
- `rear/` – Go backend API and worker services (main entry: `rear/main.go`).
|
||||
- `front/argus-front/` – Vue 3 + Vite frontend UI.
|
||||
- `service/` – Experimental Python services and AI demos (not in the main build).
|
||||
- `design/` – HTML/UX prototypes; treat files here as reference only.
|
||||
- `codex.md`, `todo.md` – Architecture notes and ongoing work items.
|
||||
|
||||
## Build, Test & Development Commands
|
||||
|
||||
- Backend: `cd rear && go mod download && go run main.go` to run, `go test ./...` for Go tests, `go build -o rear.exe .` for a local binary.
|
||||
- Frontend: `cd front/argus-front && pnpm install && pnpm dev` for dev, `pnpm build` for production build, `pnpm test:unit` for unit tests.
|
||||
- Python experiments: run only in isolation from `service/src-python-v1` after setting up a virtualenv; keep any new dependencies local and documented.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
- Backend: follow Go conventions and `rear/AGENTS.md` (use `gofmt`, small lowercase package names, clear separation of API, services, and repositories).
|
||||
- Frontend: follow ESLint + Prettier rules defined in `front/argus-front` (2‑space indent, TypeScript, Vue `<script setup>` where appropriate).
|
||||
- Python: prefer explicit, typed code and standard tooling (e.g., `black`, `ruff`) if introduced; keep modules small and focused.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- For backend changes, add or update Go tests near the code (`*_test.go`) and use focused runs like `go test ./internal/...`.
|
||||
- For frontend changes, cover behavior with Vitest tests close to components or in `__tests__/`.
|
||||
- Python and design prototypes do not require strict test coverage but should remain manually verifiable.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
- Use clear, conventional commit messages where possible, e.g., `feat(front): add timeline filters`, `fix(rear): handle missing exif`.
|
||||
- Keep each commit logically scoped; avoid mixing backend, frontend, and service changes unless necessary.
|
||||
- PRs should describe motivation, key changes, affected areas (`rear`, `front`, `service`), and how to test (commands, URLs, sample requests or screenshots).
|
||||
|
||||
## Agent-Specific Instructions
|
||||
|
||||
- Before changing code, read `codex.md` and any `AGENTS.md` in the relevant subdirectory.
|
||||
- Prefer extending existing modules and patterns over introducing new top‑level folders or frameworks.
|
||||
- Do not upgrade core dependencies, change build pipelines, or modify external tool usage without an explicit user request and matching documentation updates.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Repository Guidelines
|
||||
|
||||
This document describes how to work effectively in the `argus-front` Vue 3 + TypeScript project.
|
||||
|
||||
## Project Structure & Modules
|
||||
|
||||
- App source lives in `src/` (views, components, stores, services, router, composables, utils).
|
||||
- Global styles and icons are under `src/assets/`.
|
||||
- Reusable UI is in `src/components/`; route-level pages are in `src/views/`.
|
||||
- Shared types, models, and constants live in `src/types/`, `src/model/`, and `src/constants/`.
|
||||
- Additional architecture notes are in `CLAUDE.md` and `docs/`.
|
||||
|
||||
## Build, Test & Development
|
||||
|
||||
- `pnpm dev` – run Vite dev server.
|
||||
- `pnpm build` – type-check and production build.
|
||||
- `pnpm preview` – preview built app.
|
||||
- `pnpm test:unit` – run Vitest unit tests.
|
||||
- `pnpm lint` – run ESLint with auto-fix.
|
||||
- `pnpm format` – run Prettier on `src/`.
|
||||
|
||||
## Coding Style & Naming
|
||||
|
||||
- Use TypeScript, Vue 3 `<script setup>` where appropriate.
|
||||
- Follow ESLint + Prettier rules (`.editorconfig`, `.prettierrc.json`, `eslint.config.ts`).
|
||||
- Use 2-space indentation; avoid unused exports and `any`.
|
||||
- Name Vue components in `PascalCase` (`FileManagerView.vue`), composables as `useXxx.ts`, and stores as `useXxxStore.ts`.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Prefer Vitest + `@vue/test-utils` for component and logic tests.
|
||||
- Place tests next to code (e.g. `FileManager.spec.ts`) or in a `__tests__` folder.
|
||||
- When changing behavior, add or update tests to cover the new path; keep tests deterministic.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
- Write focused commits with imperative, English subjects (e.g. `feat: add folder selection toolbar`, `fix: debounce file search input`).
|
||||
- Reference related issues in the body when applicable.
|
||||
- For PRs, include: purpose, main changes, testing performed (`pnpm test:unit`, `pnpm lint`), and screenshots for UI changes.
|
||||
- Keep PRs small and cohesive; prefer follow-up PRs over large, mixed changes.
|
||||
|
||||
## Agent-Specific Notes
|
||||
|
||||
- Prefer modifying existing patterns over introducing new abstractions.
|
||||
- Do not change build tooling or dependency versions unless explicitly requested.
|
||||
- When in doubt, align with the architecture described in `CLAUDE.md`.
|
||||
|
||||
@@ -330,6 +330,32 @@ const isDevelopment = import.meta.env.DEV
|
||||
// 文件系统 API 集成
|
||||
const fileSystemAPI = useFileSystemAPI()
|
||||
|
||||
const THUMBNAIL_SIZES = {
|
||||
grid: 300,
|
||||
list: 200
|
||||
}
|
||||
const MAX_VISIBLE_THUMBNAILS = 160
|
||||
const MAX_THUMBNAIL_CONCURRENCY = 6
|
||||
|
||||
interface ThumbnailRequest {
|
||||
key: string
|
||||
path: string
|
||||
size: number
|
||||
}
|
||||
|
||||
const thumbnailCache = reactive<Record<string, string>>({})
|
||||
const thumbnailQueue: ThumbnailRequest[] = []
|
||||
const inFlightThumbnailKeys = new Set<string>()
|
||||
const thumbnailControllers = new Map<string, AbortController>()
|
||||
let activeThumbnailWorkers = 0
|
||||
|
||||
const THUMBNAIL_BASE_STYLE = {
|
||||
backgroundColor: '#f3f4f6',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat'
|
||||
} as const
|
||||
|
||||
const props = defineProps<{
|
||||
paneId: PaneId
|
||||
viewMode: ViewMode
|
||||
@@ -581,6 +607,18 @@ const renderItems = computed(() => {
|
||||
: gridVirtual.visibleItems.value
|
||||
})
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
items: renderItems.value,
|
||||
mode: props.viewMode
|
||||
}),
|
||||
({ items, mode }) => {
|
||||
const targetSize = mode === 'list' ? THUMBNAIL_SIZES.list : THUMBNAIL_SIZES.grid
|
||||
scheduleVisibleThumbnails(items ?? [], targetSize)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 虚拟滚动容器样式
|
||||
const virtualContainerStyle = computed(() => {
|
||||
if (!shouldUseVirtualScroll.value) {
|
||||
@@ -1600,6 +1638,8 @@ onUnmounted(() => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
cleanupThumbnailResources()
|
||||
})
|
||||
|
||||
// 键盘导航
|
||||
@@ -1922,20 +1962,133 @@ function handlePreviewClose() {
|
||||
function getThumbnailStyle(filePath: string, isListView = false) {
|
||||
if (!filePath) return {}
|
||||
|
||||
const size = isListView ? 200 : 300 // 列表视图用小尺寸,网格视图用大尺寸
|
||||
const size = isListView ? THUMBNAIL_SIZES.list : THUMBNAIL_SIZES.grid
|
||||
const cacheKey = buildThumbnailCacheKey(filePath, size)
|
||||
const cachedUrl = thumbnailCache[cacheKey]
|
||||
|
||||
// 使用 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}`
|
||||
if (!cachedUrl) {
|
||||
enqueueThumbnailRequest(filePath, size, cacheKey)
|
||||
return THUMBNAIL_BASE_STYLE
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundImage: `url("${thumbnailUrl}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat'
|
||||
...THUMBNAIL_BASE_STYLE,
|
||||
backgroundImage: `url("${cachedUrl}")`
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleVisibleThumbnails(items: FileItem[], size: number) {
|
||||
if (!items || items.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidates = items
|
||||
.filter(item => item.type === 'photo' && item.path)
|
||||
.slice(0, MAX_VISIBLE_THUMBNAILS)
|
||||
|
||||
if (!candidates.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const allowedKeys = new Set<string>()
|
||||
|
||||
candidates.forEach(item => {
|
||||
const key = buildThumbnailCacheKey(item.path!, size)
|
||||
allowedKeys.add(key)
|
||||
enqueueThumbnailRequest(item.path!, size, key)
|
||||
})
|
||||
|
||||
prunePendingThumbnailQueue(allowedKeys)
|
||||
}
|
||||
|
||||
function enqueueThumbnailRequest(path: string, size: number, cacheKey?: string) {
|
||||
if (!path) return
|
||||
|
||||
const key = cacheKey ?? buildThumbnailCacheKey(path, size)
|
||||
if (thumbnailCache[key] || inFlightThumbnailKeys.has(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
thumbnailQueue.push({ key, path, size })
|
||||
processThumbnailQueue()
|
||||
}
|
||||
|
||||
function processThumbnailQueue() {
|
||||
while (activeThumbnailWorkers < MAX_THUMBNAIL_CONCURRENCY && thumbnailQueue.length > 0) {
|
||||
const task = thumbnailQueue.shift()
|
||||
if (!task) break
|
||||
|
||||
activeThumbnailWorkers++
|
||||
inFlightThumbnailKeys.add(task.key)
|
||||
|
||||
const controller = new AbortController()
|
||||
thumbnailControllers.set(task.key, controller)
|
||||
|
||||
fetchThumbnailForPath(task, controller)
|
||||
.catch(error => {
|
||||
if (!controller.signal.aborted) {
|
||||
console.warn('[FilePane] 缩略图加载失败', task.path, error)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
activeThumbnailWorkers--
|
||||
inFlightThumbnailKeys.delete(task.key)
|
||||
thumbnailControllers.delete(task.key)
|
||||
processThumbnailQueue()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchThumbnailForPath(task: ThumbnailRequest, controller: AbortController): Promise<void> {
|
||||
const baseURL = httpClient.getAxiosInstance().defaults.baseURL || 'http://127.0.0.1:9484/api'
|
||||
const encodedPath = encodeURIComponent(task.path)
|
||||
const requestUrl = `${baseURL}/v1/photo/preview?path=${encodedPath}&size=${task.size}`
|
||||
|
||||
const response = await fetch(requestUrl, { signal: controller.signal })
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
setThumbnailCacheValue(task.key, objectUrl)
|
||||
}
|
||||
|
||||
function prunePendingThumbnailQueue(allowedKeys: Set<string>) {
|
||||
for (let i = thumbnailQueue.length - 1; i >= 0; i -= 1) {
|
||||
if (!allowedKeys.has(thumbnailQueue[i].key)) {
|
||||
thumbnailQueue.splice(i, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setThumbnailCacheValue(key: string, url: string) {
|
||||
const previous = thumbnailCache[key]
|
||||
if (previous && previous.startsWith('blob:') && previous !== url) {
|
||||
URL.revokeObjectURL(previous)
|
||||
}
|
||||
thumbnailCache[key] = url
|
||||
}
|
||||
|
||||
function buildThumbnailCacheKey(path: string, size: number) {
|
||||
return `${path}|${size}`
|
||||
}
|
||||
|
||||
function cleanupThumbnailResources() {
|
||||
thumbnailQueue.length = 0
|
||||
inFlightThumbnailKeys.clear()
|
||||
thumbnailControllers.forEach(controller => controller.abort())
|
||||
thumbnailControllers.clear()
|
||||
|
||||
Object.keys(thumbnailCache).forEach(key => {
|
||||
const url = thumbnailCache[key]
|
||||
if (url && url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
delete thumbnailCache[key]
|
||||
})
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
// 获取选中的项目
|
||||
function getSelectedItems(): FileItem[] {
|
||||
@@ -2672,4 +2825,4 @@ defineExpose({
|
||||
.debug-toggle:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,8 +1,13 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- 半透明背景 -->
|
||||
<div v-if="visible" class="photo-preview-backdrop" @click.self="handleClose">
|
||||
<!-- 悬浮预览窗口(支持全屏) -->
|
||||
<div
|
||||
v-if="visible"
|
||||
class="photo-preview-backdrop"
|
||||
@click.self="handleClose"
|
||||
@wheel.stop.prevent="handleWheel"
|
||||
>
|
||||
<!-- 预览窗口(全屏) -->
|
||||
<div class="photo-preview-window" :class="{ fullscreen: isFullscreen }" @click.stop>
|
||||
<!-- 信息面板 (Tab 键切换) - HoneyView 风格 -->
|
||||
<div v-if="showInfo" class="info-panel">
|
||||
@@ -40,7 +45,6 @@
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseup="handleMouseUp"
|
||||
@mouseleave="handleMouseUp"
|
||||
@wheel="handleWheel"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
<!-- 多层渐进式图片加载 -->
|
||||
@@ -51,7 +55,7 @@
|
||||
:alt="fileName"
|
||||
class="preview-image preview-layer"
|
||||
:class="{ 'layer-visible': currentQuality === 'thumbnail' }"
|
||||
@load="onLayerLoad('thumbnail')"
|
||||
@load="onLayerLoad('thumbnail', $event)"
|
||||
@error="handleImageError"
|
||||
draggable="false"
|
||||
/>
|
||||
@@ -63,7 +67,7 @@
|
||||
:alt="fileName"
|
||||
class="preview-image preview-layer"
|
||||
:class="{ 'layer-visible': currentQuality === 'highRes' && layersLoaded.highRes }"
|
||||
@load="onLayerLoad('highRes')"
|
||||
@load="onLayerLoad('highRes', $event)"
|
||||
@error="handleImageError"
|
||||
draggable="false"
|
||||
/>
|
||||
@@ -75,7 +79,7 @@
|
||||
:alt="fileName"
|
||||
class="preview-image preview-layer"
|
||||
:class="{ 'layer-visible': currentQuality === 'original' && layersLoaded.original }"
|
||||
@load="onLayerLoad('original')"
|
||||
@load="onLayerLoad('original', $event)"
|
||||
@error="handleImageError"
|
||||
draggable="false"
|
||||
/>
|
||||
@@ -136,6 +140,12 @@ const layersLoaded = ref({
|
||||
original: false
|
||||
})
|
||||
|
||||
const layerSizes = ref({
|
||||
thumbnail: { width: 0, height: 0 },
|
||||
highRes: { width: 0, height: 0 },
|
||||
original: { width: 0, height: 0 }
|
||||
})
|
||||
|
||||
// EXIF 数据
|
||||
interface ExifData {
|
||||
make?: string
|
||||
@@ -237,6 +247,8 @@ async function loadPreview() {
|
||||
original: urls.original
|
||||
}
|
||||
|
||||
applyQualityForScale(1)
|
||||
|
||||
// TODO: 获取 EXIF 信息
|
||||
// loadExifData(props.filePath)
|
||||
} catch (err) {
|
||||
@@ -246,7 +258,7 @@ async function loadPreview() {
|
||||
}
|
||||
|
||||
// 各层图片加载完成
|
||||
function onLayerLoad(layer: 'thumbnail' | 'highRes' | 'original') {
|
||||
function onLayerLoad(layer: 'thumbnail' | 'highRes' | 'original', event: Event) {
|
||||
layersLoaded.value[layer] = true
|
||||
isAnimating.value = false
|
||||
console.log(`✅ [PhotoPreviewModal.onLayerLoad] ${layer} 层加载成功`)
|
||||
@@ -268,22 +280,52 @@ function switchQuality(quality: 'thumbnail' | 'highRes' | 'original') {
|
||||
currentQuality.value = quality
|
||||
}
|
||||
|
||||
function pickQualityForScale(nextScale: number): 'thumbnail' | 'highRes' | 'original' {
|
||||
const hasHighRes = !!imageUrls.value.highRes
|
||||
const hasOriginal = !!imageUrls.value.original
|
||||
|
||||
// 如果原图(或最高清晰度预览)已经加载过,后续始终优先使用它
|
||||
if (hasOriginal && layersLoaded.value.original) {
|
||||
return 'original'
|
||||
}
|
||||
|
||||
if (hasOriginal && nextScale >= 2) {
|
||||
return 'original'
|
||||
}
|
||||
|
||||
if (hasHighRes && nextScale >= 1) {
|
||||
return 'highRes'
|
||||
}
|
||||
|
||||
return 'thumbnail'
|
||||
}
|
||||
|
||||
function applyQualityForScale(nextScale: number) {
|
||||
const target = pickQualityForScale(nextScale)
|
||||
if (target !== currentQuality.value) {
|
||||
switchQuality(target)
|
||||
}
|
||||
}
|
||||
|
||||
// 缩放控制
|
||||
function zoomIn() {
|
||||
isAnimating.value = true
|
||||
const newScale = Math.min(scale.value + 0.2, 5)
|
||||
const newScale = Math.min(scale.value * 1.2, 5)
|
||||
|
||||
// 如果放大超过 1.5 倍,自动切换到原图
|
||||
if (newScale > 1.5 && currentQuality.value !== 'original') {
|
||||
if (false && newScale > 1.5 && currentQuality.value !== 'original') {
|
||||
switchQuality('original')
|
||||
}
|
||||
|
||||
applyQualityForScale(newScale)
|
||||
scale.value = newScale
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
isAnimating.value = true
|
||||
scale.value = Math.max(scale.value - 0.2, 0.1)
|
||||
const newScale = Math.max(scale.value / 1.2, 0.1)
|
||||
applyQualityForScale(newScale)
|
||||
scale.value = newScale
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
@@ -292,19 +334,45 @@ function resetZoom() {
|
||||
translateX.value = 0
|
||||
translateY.value = 0
|
||||
|
||||
// 重置到缩略图模式
|
||||
if (currentQuality.value !== 'thumbnail') {
|
||||
switchQuality('thumbnail')
|
||||
}
|
||||
applyQualityForScale(1)
|
||||
}
|
||||
|
||||
function setActualSize() {
|
||||
if (!containerRef.value) return
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
const boxWidth = rect.width || 1
|
||||
|
||||
const targetLayer =
|
||||
imageUrls.value.original ? 'original'
|
||||
: imageUrls.value.highRes ? 'highRes'
|
||||
: 'thumbnail'
|
||||
|
||||
const size = layerSizes.value[targetLayer]
|
||||
if (!size.width) return
|
||||
|
||||
const targetScale = Math.max(0.1, Math.min((size.width / dpr) / boxWidth, 5))
|
||||
|
||||
scale.value = targetScale
|
||||
translateX.value = 0
|
||||
translateY.value = 0
|
||||
|
||||
switchQuality(targetLayer)
|
||||
}
|
||||
|
||||
// 鼠标滚轮缩放
|
||||
function handleWheel(event: WheelEvent) {
|
||||
// 在任意位置滚轮都缩放
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
isAnimating.value = true
|
||||
|
||||
const delta = event.deltaY > 0 ? -0.1 : 0.1
|
||||
const newScale = Math.max(0.1, Math.min(5, scale.value + delta))
|
||||
// 使用指数函数让缩放更平滑、与硬件无关
|
||||
const zoomIntensity = 0.0015
|
||||
const zoomFactor = Math.exp(-event.deltaY * zoomIntensity)
|
||||
const newScale = Math.max(0.1, Math.min(5, scale.value * zoomFactor))
|
||||
applyQualityForScale(newScale)
|
||||
|
||||
// 放大时自动进入全屏 + 切换原图
|
||||
if (newScale > 1.2 && !isFullscreen.value) {
|
||||
@@ -312,7 +380,7 @@ function handleWheel(event: WheelEvent) {
|
||||
}
|
||||
|
||||
// 如果放大超过 1.5 倍,自动切换到原图
|
||||
if (newScale > 1.5 && currentQuality.value !== 'original') {
|
||||
if (false && newScale > 1.5 && currentQuality.value !== 'original') {
|
||||
switchQuality('original')
|
||||
}
|
||||
|
||||
@@ -380,6 +448,7 @@ function resetState() {
|
||||
exifData.value = null
|
||||
imageUrls.value = { thumbnail: '', highRes: '', original: '' }
|
||||
layersLoaded.value = { thumbnail: false, highRes: false, original: false }
|
||||
layerSizes.value = { thumbnail: { width: 0, height: 0 }, highRes: { width: 0, height: 0 }, original: { width: 0, height: 0 } }
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
@@ -461,6 +530,11 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
event.stopPropagation()
|
||||
zoomOut()
|
||||
break
|
||||
case '1':
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setActualSize()
|
||||
break
|
||||
case '0':
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -475,6 +549,7 @@ function resetZoomForNavigation() {
|
||||
translateX.value = 0
|
||||
translateY.value = 0
|
||||
isAnimating.value = false
|
||||
applyQualityForScale(1)
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
@@ -516,10 +591,8 @@ onUnmounted(() => {
|
||||
|
||||
.photo-preview-window {
|
||||
position: relative;
|
||||
width: 85vw;
|
||||
max-width: 1400px;
|
||||
height: 85vh;
|
||||
max-height: 900px;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: scaleIn 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
@@ -614,15 +687,12 @@ onUnmounted(() => {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
border-radius: 4px;
|
||||
border-radius: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease-in-out;
|
||||
pointer-events: none;
|
||||
@@ -636,15 +706,12 @@ onUnmounted(() => {
|
||||
|
||||
/* 兼容旧的 class 名(如果有地方还在用) */
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
border-radius: 4px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
|
||||
@@ -6,101 +6,99 @@ export interface PreviewConfig {
|
||||
}
|
||||
|
||||
export interface PreviewUrls {
|
||||
thumbnail: string // 初始缩略图 (720p)
|
||||
highRes: string // 高清版本 (2K)
|
||||
original: string // 原图
|
||||
thumbnail: string // 初始预览(首屏)
|
||||
highRes: string // 更高清版本(放大时)
|
||||
original: string // 原图级别(或接近原图)
|
||||
}
|
||||
|
||||
class PhotoPreviewService {
|
||||
private baseUrl = '/v1/photo/preview'
|
||||
|
||||
/**
|
||||
* 根据文件大小智能选择预览策略
|
||||
* @param filePath 文件路径
|
||||
* @param fileSize 文件大小(字节)
|
||||
* @returns 预览 URL
|
||||
* 单一预览 URL(老接口保留给其他地方使用),内部也按屏幕和文件大小做决策
|
||||
*/
|
||||
getPreviewUrl(filePath: string, fileSize?: number): string {
|
||||
const encodedPath = encodeURIComponent(filePath)
|
||||
|
||||
if (!fileSize) {
|
||||
// 未知文件大小,默认加载 512p 缩略图(更快的初始加载)
|
||||
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=512`
|
||||
}
|
||||
|
||||
const baseURL = httpClient.getAxiosInstance().defaults.baseURL
|
||||
const MB = 1024 * 1024
|
||||
const buildUrl = (size: number) =>
|
||||
`${baseURL}${this.baseUrl}?path=${encodedPath}&size=${size}`
|
||||
|
||||
if (fileSize < MB) {
|
||||
// <1MB:直接加载原图(小文件)
|
||||
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=0`
|
||||
} 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:加载 512p 缩略图(最小尺寸)
|
||||
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=512`
|
||||
const viewportLongSide = this.getViewportLongSide()
|
||||
|
||||
// 未知大小:按屏幕尺寸给一个适中的预览
|
||||
if (!fileSize) {
|
||||
const baseSize = Math.min(Math.max(viewportLongSide, 1080), 2048)
|
||||
return buildUrl(baseSize)
|
||||
}
|
||||
|
||||
// 小文件:直接原图(只压缩质量,不压缩尺寸)
|
||||
if (fileSize <= 4 * MB) {
|
||||
return buildUrl(0)
|
||||
}
|
||||
|
||||
// 较大的文件:按屏幕尺寸裁一档预览
|
||||
const baseSize = Math.min(Math.max(viewportLongSide, 1280), 2560)
|
||||
return buildUrl(baseSize)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有级别的预览 URL(按文件大小智能选择)
|
||||
* @param filePath 文件路径
|
||||
* @param fileSize 文件大小(字节,可选)
|
||||
* @returns 包含不同分辨率的 URL 对象
|
||||
* 获取所有级别的预览 URL:首屏 + 放大 + 原图级别
|
||||
*/
|
||||
getAllPreviewUrls(filePath: string, fileSize?: number): PreviewUrls {
|
||||
const encodedPath = encodeURIComponent(filePath)
|
||||
const baseURL = httpClient.getAxiosInstance().defaults.baseURL
|
||||
const MB = 1024 * 1024
|
||||
|
||||
const buildUrl = (size: number) =>
|
||||
`${baseURL}${this.baseUrl}?path=${encodedPath}&size=${size}`
|
||||
|
||||
const viewportLongSide = this.getViewportLongSide()
|
||||
|
||||
// 没有文件大小信息:按屏幕尺寸给一个较清晰的首屏 + 更高质量的放大层
|
||||
if (!fileSize) {
|
||||
// 默认策略:三级加载
|
||||
const baseSize = Math.min(Math.max(viewportLongSide, 1080), 2560)
|
||||
const highSize = Math.min(baseSize * 1.5, 4096)
|
||||
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`
|
||||
thumbnail: buildUrl(baseSize),
|
||||
highRes: buildUrl(Math.round(highSize)),
|
||||
original: buildUrl(0)
|
||||
}
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024
|
||||
|
||||
if (fileSize < MB) {
|
||||
// <1MB:只需一级(直接原图)
|
||||
// 小文件:直接使用原图,不做尺寸压缩(只依赖后端的质量设置)
|
||||
if (fileSize <= 4 * MB) {
|
||||
return {
|
||||
thumbnail: `${baseURL}${this.baseUrl}?path=${encodedPath}&size=0`,
|
||||
thumbnail: buildUrl(0),
|
||||
highRes: '',
|
||||
original: ''
|
||||
}
|
||||
} else if (fileSize < 5 * MB) {
|
||||
// 1-5MB:两级加载
|
||||
}
|
||||
|
||||
// 中等大小文件:首屏使用接近屏幕分辨率的预览,放大时使用更高质量,再需要时才走“原图级别”
|
||||
if (fileSize <= 20 * MB) {
|
||||
const baseSize = Math.min(Math.max(viewportLongSide, 1280), 2560)
|
||||
const highSize = Math.min(baseSize * 1.5, 4096)
|
||||
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`
|
||||
thumbnail: buildUrl(baseSize),
|
||||
highRes: buildUrl(Math.round(highSize)),
|
||||
original: buildUrl(0)
|
||||
}
|
||||
}
|
||||
|
||||
// 超大文件:首屏略低一档,放大和进一步查看时再拉高质量 / 原图
|
||||
const baseSize = Math.min(Math.max(Math.round(viewportLongSide * 0.8), 1080), 2560)
|
||||
const highSize = Math.min(baseSize * 1.5, 4096)
|
||||
|
||||
return {
|
||||
thumbnail: buildUrl(baseSize),
|
||||
highRes: buildUrl(Math.round(highSize)),
|
||||
original: buildUrl(0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取高清版本 URL(用户放大时加载)
|
||||
* @param filePath 文件路径
|
||||
* @returns 高清 URL
|
||||
*/
|
||||
getHighResUrl(filePath: string): string {
|
||||
const encodedPath = encodeURIComponent(filePath)
|
||||
@@ -109,8 +107,6 @@ class PhotoPreviewService {
|
||||
|
||||
/**
|
||||
* 获取原图 URL(用户明确需要时加载)
|
||||
* @param filePath 文件路径
|
||||
* @returns 原图 URL
|
||||
*/
|
||||
getOriginalUrl(filePath: string): string {
|
||||
const encodedPath = encodeURIComponent(filePath)
|
||||
@@ -118,10 +114,7 @@ class PhotoPreviewService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取自定义尺寸的预览 URL
|
||||
* @param filePath 文件路径
|
||||
* @param size 最长边尺寸(像素)
|
||||
* @returns 预览 URL
|
||||
* 获取自定义尺寸的预览 URL(按最长边)
|
||||
*/
|
||||
getCustomSizeUrl(filePath: string, size: number): string {
|
||||
const encodedPath = encodeURIComponent(filePath)
|
||||
@@ -130,8 +123,6 @@ class PhotoPreviewService {
|
||||
|
||||
/**
|
||||
* 预加载图片(用于提前缓存)
|
||||
* @param url 图片 URL
|
||||
* @returns Promise
|
||||
*/
|
||||
preloadImage(url: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -144,12 +135,22 @@ class PhotoPreviewService {
|
||||
|
||||
/**
|
||||
* 批量预加载图片
|
||||
* @param urls 图片 URL 数组
|
||||
* @returns Promise
|
||||
*/
|
||||
async preloadImages(urls: string[]): Promise<void> {
|
||||
await Promise.all(urls.map(url => this.preloadImage(url)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算当前屏幕的最长边(像素),结合设备像素比
|
||||
*/
|
||||
private getViewportLongSide(): number {
|
||||
if (typeof window === 'undefined') {
|
||||
return 1440
|
||||
}
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
return Math.round(Math.max(window.innerWidth, window.innerHeight) * dpr)
|
||||
}
|
||||
}
|
||||
|
||||
export const photoPreviewService = new PhotoPreviewService()
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
2025年8月16日 13点56分
|
||||
1. 空文件夹自动删除
|
||||
2. 不支持的文件通过ICON或文字配置可展示对应的图标或内容
|
||||
3. ZIP、Tar 等常见压缩格式,支持自动解压或者手动解压
|
||||
4. 重复照片检测并存储,方便删除
|
||||
@@ -0,0 +1,7 @@
|
||||
2025-08-16 13:06
|
||||
|
||||
1. 空文件夹自动删除
|
||||
2. 不支持的文件通过 ICON 或文字配置可展示对应的图标或内容
|
||||
3. ZIP、Tar 等常见压缩格式,支持自动解压或者手动解压
|
||||
4. 重复照片检测并存储,方便删除
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 视频处理策略与经验
|
||||
|
||||
本项目需要统一管理本地视频(多格式、超高分辨率、HDR 等),并在桌面环境中提供稳定、流畅的播放体验。下面是后续实现视频处理功能时可以参考的思路。
|
||||
|
||||
## 目标与原则
|
||||
- 统一播放体验:前端始终播放“易解码、兼容好”的视频流,不让前端处理格式细节。
|
||||
- 按需转换:只对用户真正播放的视频做预处理,避免全库批量转码。
|
||||
- 控制资源占用:在 CPU / 磁盘空间 / I/O 之间平衡,避免 8K / 10G 视频拖垮整机。
|
||||
|
||||
## 格式与编码策略
|
||||
- 优先直接播放(无需转码):
|
||||
- 容器 `mp4`/`webm`,视频编码 H.264,音频 AAC,分辨率 ≤1080p,非 HDR。
|
||||
- 这类文件可直接通过 HTTP Range 输出原始文件给 `<video>` 播放。
|
||||
- 统一代理格式:
|
||||
- 对 mov、mkv、avi 等容器,或 HEVC/AV1 等复杂编码,一律转成 `MP4 + H.264 + AAC + SDR + 1080p`。
|
||||
- 使用 ffmpeg 封装(`ffmpeg_tool.go`)提供探测信息 + 转码接口。
|
||||
|
||||
## 分辨率与大文件
|
||||
- 屏幕驱动分辨率:
|
||||
- 前端固定在 1080p 布局时,超出 1080p 的分辨率意义不大,可以统一生成 1080p 代理。
|
||||
- 按需生成代理视频:
|
||||
- 用户第一次点击播放时:
|
||||
- 若满足“可直接播放”条件 → 直接播放原片;
|
||||
- 否则投递“生成 1080p 预览/代理”的后台任务,并记录状态。
|
||||
- 不在扫描阶段批量转码所有视频。
|
||||
- 超大文件(如 10G+):
|
||||
- 后端必须用流式读取 + Range 响应,禁止一次性读完整个文件。
|
||||
- 若用户频繁观看,可为其生成 1080p 代理,减少后续 I/O 压力。
|
||||
|
||||
## HDR 与色彩空间
|
||||
- 不考虑原生 HDR 播放时:
|
||||
- 在转码过程中将 HDR 映射为 SDR(Rec.709,8-bit),统一处理为普通视频。
|
||||
- 在元数据 / UI 中标注“该视频为 HDR,已转为 SDR 预览”,原片保留给外部工具或高级用户。
|
||||
|
||||
## 缓存与磁盘占用
|
||||
- 所有代理视频放在独立缓存目录(如 `cache/video`):
|
||||
- 使用统一命名(基于哈希 + 分辨率 / 格式),避免重复生成。
|
||||
- 维护“最大缓存体积”配置(如 `video_cache_max_size_gb`),溢出时按 LRU 删除旧代理。
|
||||
- 对已满足播放要求的源文件,不生成代理,直接使用原片。
|
||||
|
||||
## 实时性与转码模式
|
||||
- 离线转码优先:
|
||||
- 先实现“先转完再播”:用户发起播放 → 若需代理则排队转码 → 完成后返回 1080p 代理进行播放。
|
||||
- 转码进度通过任务系统 + SSE 推送到前端。
|
||||
- 流式 / 实时转码(后期优化):
|
||||
- 对特大文件或需要“秒播”的场景,可考虑:
|
||||
- ffmpeg 输出 HLS(`.m3u8 + .ts`),前端边拉边播;
|
||||
- 或使用分片 MP4,边写边通过 Range 输出。
|
||||
|
||||
## 前后端协作建议
|
||||
- 后端:
|
||||
- 提供统一播放接口(如 `GET /api/v1/video/play?id=...`),内部自动选择原片或代理;
|
||||
- 提供“确保预览 / 代理存在”的接口,通过任务队列执行 ffmpeg 转码;
|
||||
- 暴露视频基础信息 API(时长、分辨率、编码、是否 HDR)。
|
||||
- 前端:
|
||||
- 固定使用一个播放 URL,不关心具体格式;
|
||||
- 根据“预览状态”显示等待 / 进度 / 可播放等 UI,避免用户误以为程序卡死。
|
||||
|
||||
整体思路:能直接播就直接播,播不稳或不兼容就为它做一份 1080p SDR 代理,并用有限缓存控制空间,这样既能覆盖 8K / HDR / 特殊格式,又不会无限占用用户磁盘。
|
||||
|
||||
@@ -130,8 +130,9 @@ func (api *ImageAPI) GetExif() error {
|
||||
5. 如果是缩略图,则判断是否存在对应宽度的缩略图,如果存在则返回该路径,如果不存在则生成缩略图并返回(缩略图宽度会是固定的值)
|
||||
6. 如果传入的参数很大,比如超过原图的宽度,则返回原图路径
|
||||
*/
|
||||
func (api *ImageAPI) GetImagePath(longSide int) (string, error) {
|
||||
ctx := context.Background()
|
||||
func (api *ImageAPI) GetImagePath(ctx context.Context, longSide int) (string, error) {
|
||||
// 避免上层请求过早取消导致外部工具失败,同时保留上下文的值
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
|
||||
// 小于等于0获取原图
|
||||
if longSide <= 0 {
|
||||
@@ -522,7 +523,7 @@ func (api *ImageAPI) generateAllThumbnails(ctx context.Context) error {
|
||||
thumbnailSizes := api.getThumbnailSizes()
|
||||
|
||||
for _, size := range thumbnailSizes {
|
||||
thumbnailPath, err := api.GetImagePath(size)
|
||||
thumbnailPath, err := api.GetImagePath(ctx, size)
|
||||
if err != nil {
|
||||
logger.Warn("生成缩略图失败",
|
||||
zap.String("path", api.path),
|
||||
@@ -551,7 +552,7 @@ func (api *ImageAPI) generateSmartThumbnails(ctx context.Context, fileSize int64
|
||||
zap.Ints("sizes", thumbnailSizes))
|
||||
|
||||
for _, size := range thumbnailSizes {
|
||||
thumbnailPath, err := api.GetImagePath(size)
|
||||
thumbnailPath, err := api.GetImagePath(ctx, size)
|
||||
if err != nil {
|
||||
logger.Warn("生成缩略图失败",
|
||||
zap.String("path", api.path),
|
||||
@@ -669,7 +670,7 @@ func (api *ImageAPI) HasTiles() bool {
|
||||
|
||||
// CompressedImg 压缩图片获取路径
|
||||
func (api *ImageAPI) CompressedImg(longSide int) (string, error) {
|
||||
return api.GetImagePath(longSide)
|
||||
return api.GetImagePath(context.Background(), longSide)
|
||||
}
|
||||
|
||||
// ReadFileWithLimit 读取文件内容(限制大小)
|
||||
|
||||
@@ -78,7 +78,7 @@ func (h *BatchThumbnailHandler) GetBatchThumbnails(c *gin.Context) {
|
||||
mw.SetBoundary(boundary)
|
||||
defer mw.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := c.Request.Context()
|
||||
successCount := 0
|
||||
errorCount := 0
|
||||
|
||||
@@ -142,7 +142,7 @@ func (h *BatchThumbnailHandler) processSingleThumbnail(
|
||||
}
|
||||
|
||||
// 获取缩略图路径
|
||||
thumbnailPath, err := imageAPI.GetImagePath(size)
|
||||
thumbnailPath, err := imageAPI.GetImagePath(ctx, size)
|
||||
if err != nil {
|
||||
return h.writeThumbnailError(mw, mu, index, filePath, err)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"rear/internal/api"
|
||||
"rear/internal/config"
|
||||
"rear/internal/container"
|
||||
@@ -11,6 +14,7 @@ import (
|
||||
"rear/internal/repositories"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"rear/pkg/logger"
|
||||
@@ -24,6 +28,25 @@ type PhotoHandler struct {
|
||||
imgContain *container.TaskContainer
|
||||
}
|
||||
|
||||
const defaultPreviewConcurrency = 6
|
||||
|
||||
var (
|
||||
previewLimiter = make(chan struct{}, defaultPreviewConcurrency)
|
||||
previewInflightMu sync.Mutex
|
||||
previewInflightMap = make(map[string]*previewCall)
|
||||
)
|
||||
|
||||
type previewCall struct {
|
||||
done chan struct{}
|
||||
result previewResult
|
||||
}
|
||||
|
||||
type previewResult struct {
|
||||
imagePath string
|
||||
format string
|
||||
err error
|
||||
}
|
||||
|
||||
func NewPhotoHandler(container *container.DbContainer, imgContain *container.TaskContainer) *PhotoHandler {
|
||||
return &PhotoHandler{
|
||||
container: container,
|
||||
@@ -189,6 +212,7 @@ func (h *PhotoHandler) GetPhoto(c *gin.Context) {
|
||||
}
|
||||
|
||||
var imagePath string
|
||||
ctx := c.Request.Context()
|
||||
|
||||
switch format {
|
||||
case "thumbnail":
|
||||
@@ -203,7 +227,7 @@ func (h *PhotoHandler) GetPhoto(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 使用ImageAPI获取缩略图
|
||||
imagePath, err = imageAPI.GetImagePath(size)
|
||||
imagePath, err = imageAPI.GetImagePath(ctx, size)
|
||||
if err != nil {
|
||||
logger.Error("获取缩略图失败", zap.String("hash", hash), zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, model.Response{
|
||||
@@ -553,20 +577,14 @@ func (h *PhotoHandler) GetPhotoPreview(c *gin.Context) {
|
||||
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)
|
||||
reqCtx := c.Request.Context()
|
||||
imagePath, format, err := h.getPreviewWithDedupe(reqCtx, path, size)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
logger.Warn("预览请求取消", zap.String("path", path), zap.Int("size", size))
|
||||
return
|
||||
}
|
||||
logger.Error("获取图片路径失败", zap.String("path", path), zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, model.Response{
|
||||
Code: http.StatusInternalServerError,
|
||||
@@ -576,7 +594,7 @@ func (h *PhotoHandler) GetPhotoPreview(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 设置响应头并返回文件
|
||||
h.setImageHeaders(c, imagePath, imageAPI.GetFormat())
|
||||
h.setImageHeaders(c, imagePath, format)
|
||||
c.File(imagePath)
|
||||
}
|
||||
|
||||
@@ -606,3 +624,79 @@ func (h *PhotoHandler) setImageHeaders(c *gin.Context, imagePath, format string)
|
||||
fileName := fmt.Sprintf("image.%s", format)
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, fileName))
|
||||
}
|
||||
|
||||
func (h *PhotoHandler) getPreviewWithDedupe(ctx context.Context, filePath string, size int) (string, string, error) {
|
||||
cacheKey := buildPreviewCacheKey(filePath, size)
|
||||
return getOrCreatePreview(ctx, cacheKey, func(execCtx context.Context) (string, string, error) {
|
||||
if err := acquirePreviewSlot(execCtx); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer releasePreviewSlot()
|
||||
|
||||
imageAPI, err := api.NewImageAPI(filePath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
imagePath, err := imageAPI.GetImagePath(execCtx, size)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return imagePath, imageAPI.GetFormat(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func acquirePreviewSlot(ctx context.Context) error {
|
||||
select {
|
||||
case previewLimiter <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func releasePreviewSlot() {
|
||||
select {
|
||||
case <-previewLimiter:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func buildPreviewCacheKey(path string, size int) string {
|
||||
normalized := strings.ToLower(filepath.ToSlash(filepath.Clean(path)))
|
||||
return fmt.Sprintf("%s|%d", normalized, size)
|
||||
}
|
||||
|
||||
func getOrCreatePreview(ctx context.Context, key string, fn func(context.Context) (string, string, error)) (string, string, error) {
|
||||
previewInflightMu.Lock()
|
||||
if call, ok := previewInflightMap[key]; ok {
|
||||
previewInflightMu.Unlock()
|
||||
select {
|
||||
case <-call.done:
|
||||
return call.result.imagePath, call.result.format, call.result.err
|
||||
case <-ctx.Done():
|
||||
return "", "", ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
call := &previewCall{
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
previewInflightMap[key] = call
|
||||
previewInflightMu.Unlock()
|
||||
|
||||
imagePath, format, err := fn(ctx)
|
||||
call.result = previewResult{
|
||||
imagePath: imagePath,
|
||||
format: format,
|
||||
err: err,
|
||||
}
|
||||
|
||||
previewInflightMu.Lock()
|
||||
delete(previewInflightMap, key)
|
||||
previewInflightMu.Unlock()
|
||||
close(call.done)
|
||||
|
||||
return imagePath, format, err
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
//nolint:typecheck
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -415,26 +416,57 @@ func getVipsVersion() (string, error) {
|
||||
return "", fmt.Errorf("LibVips path not set")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := ExecuteCommand(ctx, VipsPath, "--version")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get LibVips version: %w", err)
|
||||
// 这里不用 ExecuteCommand,避免在某些 vips.exe 构建上
|
||||
// 返回非零退出码时打出「命令执行失败」的错误日志。
|
||||
// 我们自己以较安静的方式尝试获取版本信息:先试 "--version",失败再试 "-v"。
|
||||
tryArgs := [][]string{
|
||||
{"--version"},
|
||||
{"-v"},
|
||||
}
|
||||
|
||||
// 解析版本信息
|
||||
output := string(result.Stdout)
|
||||
lines := bytes.Split(result.Stdout, []byte("\n"))
|
||||
if len(lines) > 0 {
|
||||
firstLine := string(lines[0])
|
||||
// 提取版本号 (例如: "vips-8.17.0")
|
||||
if strings.Contains(firstLine, "vips") {
|
||||
return strings.TrimSpace(firstLine), nil
|
||||
for idx, args := range tryArgs {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
cmd := exec.CommandContext(ctx, VipsPath, args...) //nolint:gosec
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
start := time.Now()
|
||||
err := cmd.Run()
|
||||
duration := time.Since(start)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
// 首次失败只打 debug 日志并尝试下一种参数,最后一次失败再返回错误
|
||||
logger.Debug("获取 LibVips 版本失败",
|
||||
zap.String("program", VipsPath),
|
||||
zap.Strings("args", args),
|
||||
zap.Duration("duration", duration),
|
||||
zap.String("stdout", stdout.String()),
|
||||
zap.String("stderr", stderr.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
if idx == len(tryArgs)-1 {
|
||||
return "", fmt.Errorf("failed to get LibVips version: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析版本信息
|
||||
output := stdout.Bytes()
|
||||
lines := bytes.Split(output, []byte("\n"))
|
||||
if len(lines) > 0 {
|
||||
firstLine := string(lines[0])
|
||||
// 提取版本号 (例如: "vips-8.17.0")
|
||||
if strings.Contains(firstLine, "vips") {
|
||||
return strings.TrimSpace(firstLine), nil
|
||||
}
|
||||
}
|
||||
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
return output, nil
|
||||
return "", fmt.Errorf("failed to get LibVips version")
|
||||
}
|
||||
|
||||
// ensureExecutable 确保文件具有执行权限(仅适用于 Unix 系统)
|
||||
|
||||
@@ -359,7 +359,7 @@ func (pt *PictureTask) setStatus(s TaskStatus) {
|
||||
func (pt *PictureTask) saveToDatabase(imageAPI *api.ImageAPI) error {
|
||||
exifData := imageAPI.GetExifData()
|
||||
hash := imageAPI.GetHash()
|
||||
|
||||
|
||||
if hash == "" {
|
||||
return fmt.Errorf("图像哈希值为空")
|
||||
}
|
||||
@@ -372,7 +372,7 @@ func (pt *PictureTask) saveToDatabase(imageAPI *api.ImageAPI) error {
|
||||
if exifData != nil && !isExifRepoNil(pt.exifRepo) {
|
||||
// 创建PhotoExif表记录
|
||||
photoExif := tables.NewPhotoExifFromParsed(hash, exifData)
|
||||
|
||||
|
||||
// 保存到数据库
|
||||
err := pt.exifRepo.CreateOrUpdate(photoExif)
|
||||
if err != nil {
|
||||
@@ -396,7 +396,7 @@ func (pt *PictureTask) saveToDatabase(imageAPI *api.ImageAPI) error {
|
||||
// 2. 保存Photo基础信息到photos表
|
||||
if !isPhotoRepoNil(pt.photoRepo) {
|
||||
photo := pt.photoRepo.CreatePhotoFromImageAPI(hash, pt.Path, exifData)
|
||||
|
||||
|
||||
err := pt.photoRepo.CreateOrUpdate(photo)
|
||||
if err != nil {
|
||||
logger.Error("保存Photo信息失败",
|
||||
@@ -405,13 +405,13 @@ func (pt *PictureTask) saveToDatabase(imageAPI *api.ImageAPI) error {
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("保存Photo信息失败: %w", err)
|
||||
}
|
||||
|
||||
|
||||
logger.Info("Photo信息保存成功",
|
||||
zap.String("hash", hash),
|
||||
zap.String("path", pt.Path),
|
||||
zap.Int("width", photo.Width),
|
||||
zap.Int("height", photo.Height))
|
||||
|
||||
|
||||
// 记录时间相关信息
|
||||
if photo.TakenAt != nil {
|
||||
logger.Info("照片拍摄时间",
|
||||
@@ -446,6 +446,8 @@ func (pt *PictureTask) performIntelligentAnalysis(imageAPI *api.ImageAPI) {
|
||||
type ImgTaskManager struct {
|
||||
tasks map[string]*PictureTask
|
||||
queue chan *PictureTask
|
||||
highQueue chan *PictureTask
|
||||
pathIndex map[string]string
|
||||
workerPool chan struct{}
|
||||
workerLimit int
|
||||
mu sync.RWMutex
|
||||
@@ -468,6 +470,8 @@ func NewImgTaskManager(concurrency int, exifRepo *repositories.ExifRepository, p
|
||||
tm := &ImgTaskManager{
|
||||
tasks: make(map[string]*PictureTask),
|
||||
queue: make(chan *PictureTask, taskConfig.QueueCapacity),
|
||||
highQueue: make(chan *PictureTask, taskConfig.QueueCapacity),
|
||||
pathIndex: make(map[string]string),
|
||||
workerPool: make(chan struct{}, concurrency),
|
||||
workerLimit: concurrency,
|
||||
globalPause: make(chan struct{}, 1),
|
||||
@@ -574,15 +578,33 @@ func (tm *ImgTaskManager) SetConcurrency(n int) {
|
||||
tm.mu.Unlock()
|
||||
}
|
||||
|
||||
func (tm *ImgTaskManager) AddTask(path string) string {
|
||||
task := NewPictureTask(path, tm.exifRepo, tm.photoRepo)
|
||||
// AddTaskWithPriority 添加任务,可指定是否为高优先级
|
||||
func (tm *ImgTaskManager) AddTaskWithPriority(path string, highPriority bool) string {
|
||||
tm.mu.Lock()
|
||||
if existingID, ok := tm.pathIndex[path]; ok {
|
||||
if existingTask, exists := tm.tasks[existingID]; exists && existingTask.Status != StatusDone && existingTask.Status != StatusFailed {
|
||||
tm.mu.Unlock()
|
||||
return existingID
|
||||
}
|
||||
}
|
||||
|
||||
task := NewPictureTask(path, tm.exifRepo, tm.photoRepo)
|
||||
tm.tasks[task.ID] = task
|
||||
tm.pathIndex[path] = task.ID
|
||||
tm.mu.Unlock()
|
||||
tm.queue <- task
|
||||
if highPriority {
|
||||
tm.highQueue <- task
|
||||
} else {
|
||||
tm.queue <- task
|
||||
}
|
||||
return task.ID
|
||||
}
|
||||
|
||||
// AddTask 添加任务,默认普通优先级(兼容旧调用)
|
||||
func (tm *ImgTaskManager) AddTask(path string) string {
|
||||
return tm.AddTaskWithPriority(path, false)
|
||||
}
|
||||
|
||||
func (tm *ImgTaskManager) PauseAll() {
|
||||
select {
|
||||
case tm.globalPause <- struct{}{}:
|
||||
@@ -611,7 +633,17 @@ func (tm *ImgTaskManager) applyGlobalPauseResume(task *PictureTask) {
|
||||
}
|
||||
|
||||
func (tm *ImgTaskManager) run() {
|
||||
for task := range tm.queue {
|
||||
for {
|
||||
var task *PictureTask
|
||||
select {
|
||||
case task = <-tm.highQueue:
|
||||
case task = <-tm.queue:
|
||||
}
|
||||
|
||||
if task == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
tm.workerPool <- struct{}{}
|
||||
tm.applyGlobalPauseResume(task)
|
||||
go func(t *PictureTask) {
|
||||
@@ -621,6 +653,10 @@ func (tm *ImgTaskManager) run() {
|
||||
if t.Status == StatusDone {
|
||||
tm.doneCount++
|
||||
}
|
||||
if t.Status == StatusDone || t.Status == StatusFailed {
|
||||
// 允许后续同路径任务再次入队
|
||||
delete(tm.pathIndex, t.Path)
|
||||
}
|
||||
tm.mu.Unlock()
|
||||
}(task)
|
||||
}
|
||||
@@ -1149,6 +1185,16 @@ func AddImageTask(imagePath string) string {
|
||||
return taskManager.AddTask(imagePath)
|
||||
}
|
||||
|
||||
// AddImageTaskWithPriority 添加图像处理任务并指定是否高优先级
|
||||
func AddImageTaskWithPriority(imagePath string, highPriority bool) string {
|
||||
taskManager := GetGlobalTaskManager()
|
||||
if taskManager == nil {
|
||||
logger.Error("任务管理器未初始化")
|
||||
return ""
|
||||
}
|
||||
return taskManager.AddTaskWithPriority(imagePath, highPriority)
|
||||
}
|
||||
|
||||
// AddImageTasks 批量添加图像处理任务 (全局便捷函数)
|
||||
func AddImageTasks(imagePaths []string) []string {
|
||||
taskManager := GetGlobalTaskManager()
|
||||
|
||||
@@ -157,3 +157,13 @@
|
||||
{"level":"info","time":"2025-11-16 14:43:16.424","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-11-16 17:26:19.223","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-11-16 17:32:50.846","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-11-16 18:19:32.861","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-11-16 18:26:28.326","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-11-16 19:25:56.732","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-11-16 19:26:02.076","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-11-16 19:38:50.665","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-11-16 20:16:03.635","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-11-16 20:52:59.290","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-11-16 20:59:09.711","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-11-16 22:14:43.808","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-11-16 22:19:31.382","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
Reference in new issue
Block a user