From 2f1b69f0bdc45082adfd426e1d0389d39e02051b Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 15 Nov 2025 23:34:07 +0800 Subject: [PATCH] feat: implement photo upload workflow with processing and error handling - Add ProcessingPanel component to display processing stages and logs. - Create UploadFileList component to show upload progress for each file. - Define constants for workflow steps, file statuses, and processing stages. - Implement steps for completed, error, processing, review, uploading, and uploading steps. - Create a Zustand store for managing photo upload state and actions. - Add utility functions for file handling, error messages, and tag sanitization. - Update hooks to support new upload options and progress tracking. - Integrate new components and store into the photo upload module. Signed-off-by: Innei --- .../photo/assets/photo-asset.service.ts | 7 +- .../content/photo/assets/photo.controller.ts | 9 + be/apps/dashboard/package.json | 5 +- be/apps/dashboard/src/modules/photos/api.ts | 142 +++++- .../modules/photos/components/PhotoPage.tsx | 31 +- .../photos/components/PhotoPageActions.tsx | 6 +- .../library/PhotoLibraryActionBar.tsx | 12 +- .../components/library/PhotoLibraryGrid.tsx | 161 ++++++- .../library/PhotoUploadConfirmModal.tsx | 174 +------- .../library/photo-upload/AutoSelect.tsx | 124 ++++++ .../library/photo-upload/ProcessingPanel.tsx | 62 +++ .../library/photo-upload/UploadFileList.tsx | 73 ++++ .../library/photo-upload/constants.ts | 68 +++ .../photo-upload/steps/CompletedStep.tsx | 32 ++ .../library/photo-upload/steps/ErrorStep.tsx | 52 +++ .../photo-upload/steps/ProcessingStep.tsx | 44 ++ .../library/photo-upload/steps/ReviewStep.tsx | 87 ++++ .../photo-upload/steps/UploadingStep.tsx | 41 ++ .../library/photo-upload/steps/index.tsx | 21 + .../components/library/photo-upload/store.tsx | 413 ++++++++++++++++++ .../components/library/photo-upload/types.ts | 41 ++ .../components/library/photo-upload/utils.ts | 151 +++++++ .../photos/components/library/upload.types.ts | 7 + be/apps/dashboard/src/modules/photos/hooks.ts | 19 +- pnpm-lock.yaml | 3 + 25 files changed, 1578 insertions(+), 207 deletions(-) create mode 100644 be/apps/dashboard/src/modules/photos/components/library/photo-upload/AutoSelect.tsx create mode 100644 be/apps/dashboard/src/modules/photos/components/library/photo-upload/ProcessingPanel.tsx create mode 100644 be/apps/dashboard/src/modules/photos/components/library/photo-upload/UploadFileList.tsx create mode 100644 be/apps/dashboard/src/modules/photos/components/library/photo-upload/constants.ts create mode 100644 be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/CompletedStep.tsx create mode 100644 be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ErrorStep.tsx create mode 100644 be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ProcessingStep.tsx create mode 100644 be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ReviewStep.tsx create mode 100644 be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/UploadingStep.tsx create mode 100644 be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/index.tsx create mode 100644 be/apps/dashboard/src/modules/photos/components/library/photo-upload/store.tsx create mode 100644 be/apps/dashboard/src/modules/photos/components/library/photo-upload/types.ts create mode 100644 be/apps/dashboard/src/modules/photos/components/library/photo-upload/utils.ts create mode 100644 be/apps/dashboard/src/modules/photos/components/library/upload.types.ts diff --git a/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts b/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts index 4351b365..d3a2343e 100644 --- a/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts +++ b/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts @@ -49,6 +49,7 @@ export interface UploadAssetInput { filename: string buffer: Buffer contentType?: string + directory?: string | null } const VIDEO_EXTENSIONS = new Set(['mov', 'mp4']) @@ -723,9 +724,11 @@ export class PhotoAssetService { const base = path.basename(input.filename, ext).trim() const timestamp = Date.now().toString() - const directory = this.resolveStorageDirectory(storageConfig) + const storageDirectory = this.resolveStorageDirectory(storageConfig) + const customDirectory = this.normalizeDirectory(input.directory) + const combinedDirectory = this.joinStorageSegments(storageDirectory, customDirectory) const keySegment = base || timestamp - const normalized = directory ? `${directory}/${keySegment}${ext}` : `${keySegment}${ext}` + const normalized = combinedDirectory ? `${combinedDirectory}/${keySegment}${ext}` : `${keySegment}${ext}` return this.normalizeKeyPath(normalized) } diff --git a/be/apps/core/src/modules/content/photo/assets/photo.controller.ts b/be/apps/core/src/modules/content/photo/assets/photo.controller.ts index c08d1701..cbb6caee 100644 --- a/be/apps/core/src/modules/content/photo/assets/photo.controller.ts +++ b/be/apps/core/src/modules/content/photo/assets/photo.controller.ts @@ -38,6 +38,14 @@ export class PhotoController { @Post('assets/upload') async uploadAssets(@ContextParam() context: Context) { const payload = await context.req.parseBody() + let directory: string | null = null + + if (typeof payload['directory'] === 'string') { + directory = payload['directory'] + } else if (Array.isArray(payload['directory'])) { + const candidate = payload['directory'].find((entry) => typeof entry === 'string') + directory = typeof candidate === 'string' ? candidate : null + } const files: File[] = [] for (const value of Object.values(payload)) { @@ -63,6 +71,7 @@ export class PhotoController { filename: file.name, buffer: Buffer.from(await file.arrayBuffer()), contentType: file.type || undefined, + directory, })), ) diff --git a/be/apps/dashboard/package.json b/be/apps/dashboard/package.json index e60a7000..c1567cd1 100644 --- a/be/apps/dashboard/package.json +++ b/be/apps/dashboard/package.json @@ -54,7 +54,8 @@ "react-scan": "0.4.3", "sonner": "2.0.7", "tailwind-merge": "3.4.0", - "usehooks-ts": "3.1.1" + "usehooks-ts": "3.1.1", + "zustand": "5.0.8" }, "devDependencies": { "@egoist/tailwindcss-icons": "1.9.0", @@ -101,4 +102,4 @@ "eslint --fix" ] } -} \ No newline at end of file +} diff --git a/be/apps/dashboard/src/modules/photos/api.ts b/be/apps/dashboard/src/modules/photos/api.ts index 229567f6..eb862f4e 100644 --- a/be/apps/dashboard/src/modules/photos/api.ts +++ b/be/apps/dashboard/src/modules/photos/api.ts @@ -20,6 +20,26 @@ type RunPhotoSyncOptions = { onEvent?: (event: PhotoSyncProgressEvent) => void } +export type PhotoUploadFileProgress = { + index: number + name: string + size: number + uploadedBytes: number + progress: number +} + +export type PhotoUploadProgressSnapshot = { + totalBytes: number + uploadedBytes: number + files: PhotoUploadFileProgress[] +} + +export type UploadPhotoAssetsOptions = { + directory?: string + signal?: AbortSignal + onProgress?: (snapshot: PhotoUploadProgressSnapshot) => void +} + export async function runPhotoSync( payload: RunPhotoSyncPayload, options?: RunPhotoSyncOptions, @@ -176,8 +196,12 @@ export async function deletePhotoAssets(ids: string[], options?: { deleteFromSto export async function uploadPhotoAssets( files: File[], - options?: { directory?: string }, + options?: UploadPhotoAssetsOptions, ): Promise { + if (files.length === 0) { + return [] + } + const formData = new FormData() if (options?.directory) { @@ -188,14 +212,116 @@ export async function uploadPhotoAssets( formData.append('files', file) } - const response = await coreApi<{ assets: PhotoAssetListItem[] }>('/photos/assets/upload', { - method: 'POST', - body: formData, + if (typeof XMLHttpRequest === 'undefined') { + const fallbackResponse = await coreApi<{ assets: PhotoAssetListItem[] }>('/photos/assets/upload', { + method: 'POST', + body: formData, + }) + const fallbackData = camelCaseKeys<{ assets: PhotoAssetListItem[] }>(fallbackResponse) + return fallbackData.assets + } + + const fileMetadata = files.map((file, index) => ({ + index, + name: file.name, + size: file.size, + })) + const totalBytes = fileMetadata.reduce((sum, file) => sum + file.size, 0) + + const snapshotFromLoaded = (loaded: number): PhotoUploadProgressSnapshot => { + let remaining = loaded + const filesProgress: PhotoUploadFileProgress[] = fileMetadata.map((meta) => { + const uploadedForFile = Math.max(0, Math.min(meta.size, remaining)) + remaining -= uploadedForFile + return { + index: meta.index, + name: meta.name, + size: meta.size, + uploadedBytes: uploadedForFile, + progress: meta.size === 0 ? 1 : Math.min(1, uploadedForFile / meta.size), + } + }) + + return { + totalBytes, + uploadedBytes: Math.min(loaded, totalBytes), + files: filesProgress, + } + } + + return await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('POST', `${coreApiBaseURL}/photos/assets/upload`, true) + xhr.withCredentials = true + xhr.responseType = 'json' + + const handleAbort = () => { + xhr.abort() + } + + const cleanup = () => { + if (options?.signal) { + options.signal.removeEventListener('abort', handleAbort) + } + } + + if (options?.signal) { + if (options.signal.aborted) { + cleanup() + reject(new DOMException('Upload aborted', 'AbortError')) + return + } + options.signal.addEventListener('abort', handleAbort) + } + + xhr.upload.onprogress = (event: ProgressEvent) => { + if (!options?.onProgress) { + return + } + const loaded = event.lengthComputable ? event.loaded : totalBytes + options.onProgress(snapshotFromLoaded(loaded)) + } + + xhr.onerror = () => { + cleanup() + reject(new Error('上传过程中出现网络错误,请稍后再试。')) + } + + xhr.onabort = () => { + cleanup() + reject(new DOMException('Upload aborted', 'AbortError')) + } + + xhr.onload = () => { + cleanup() + if (xhr.status >= 200 && xhr.status < 300) { + try { + const rawResponse = typeof xhr.response === 'string' ? JSON.parse(xhr.response) : xhr.response + const parsed = camelCaseKeys<{ assets: PhotoAssetListItem[] }>(rawResponse) + resolve(parsed.assets) + } catch (error) { + reject(error instanceof Error ? error : new Error('无法解析上传响应')) + } + return + } + + let message = `上传失败:${xhr.status}` + const {responseText} = xhr + if (responseText) { + try { + const parsed = JSON.parse(responseText) + if (parsed && typeof parsed.message === 'string') { + message = parsed.message + } + } catch { + // ignore parse error + } + } + reject(new Error(message)) + } + + xhr.send(formData) }) - - const data = camelCaseKeys<{ assets: PhotoAssetListItem[] }>(response) - - return data.assets } export async function getPhotoStorageUrl(storageKey: string): Promise { diff --git a/be/apps/dashboard/src/modules/photos/components/PhotoPage.tsx b/be/apps/dashboard/src/modules/photos/components/PhotoPage.tsx index 18ae4508..416d4f66 100644 --- a/be/apps/dashboard/src/modules/photos/components/PhotoPage.tsx +++ b/be/apps/dashboard/src/modules/photos/components/PhotoPage.tsx @@ -31,6 +31,7 @@ import type { import { DeleteFromStorageOption } from './library/DeleteFromStorageOption' import type { DeleteAssetOptions } from './library/PhotoLibraryGrid' import { PhotoLibraryGrid } from './library/PhotoLibraryGrid' +import type { PhotoUploadRequestOptions } from './library/upload.types' import { PhotoPageActions } from './PhotoPageActions' import { PhotoSyncConflictsPanel } from './sync/PhotoSyncConflictsPanel' import { PhotoSyncProgressPanel } from './sync/PhotoSyncProgressPanel' @@ -106,6 +107,25 @@ export function PhotoPage() { const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) const isListLoading = listQuery.isLoading || listQuery.isFetching const libraryAssetCount = listQuery.data?.length ?? 0 + const availableTags = useMemo(() => { + if (!listQuery.data || listQuery.data.length === 0) { + return [] + } + const tagSet = new Set() + for (const asset of listQuery.data) { + const tags = asset.manifest?.data?.tags + if (!Array.isArray(tags)) { + continue + } + for (const tag of tags) { + const normalized = typeof tag === 'string' ? tag.trim() : '' + if (normalized) { + tagSet.add(normalized) + } + } + } + return Array.from(tagSet).sort((a, b) => a.localeCompare(b)) + }, [listQuery.data]) const handleToggleSelect = (id: string) => { setSelectedIds((prev) => { @@ -277,16 +297,22 @@ export function PhotoPage() { ) const handleUploadAssets = useCallback( - async (files: FileList) => { + async (files: FileList, options?: PhotoUploadRequestOptions) => { const fileArray = Array.from(files) if (fileArray.length === 0) return try { - await uploadMutation.mutateAsync(fileArray) + await uploadMutation.mutateAsync({ + files: fileArray, + onProgress: options?.onUploadProgress, + signal: options?.signal, + directory: options?.directory ?? undefined, + }) toast.success(`成功上传 ${fileArray.length} 张图片`) void listQuery.refetch() } catch (error) { const message = getRequestErrorMessage(error, '上传失败,请稍后重试。') toast.error('上传失败', { description: message }) + throw error } }, [listQuery, uploadMutation], @@ -518,6 +544,7 @@ export function PhotoPage() { onDeleteSelected={handleDeleteSelected} onClearSelection={handleClearSelection} onSelectAll={handleSelectAll} + availableTags={availableTags} onSyncCompleted={handleSyncCompleted} onSyncProgress={handleProgressEvent} onSyncError={handleSyncError} diff --git a/be/apps/dashboard/src/modules/photos/components/PhotoPageActions.tsx b/be/apps/dashboard/src/modules/photos/components/PhotoPageActions.tsx index 5f603c1d..2a0c9be4 100644 --- a/be/apps/dashboard/src/modules/photos/components/PhotoPageActions.tsx +++ b/be/apps/dashboard/src/modules/photos/components/PhotoPageActions.tsx @@ -4,6 +4,7 @@ import { MainPageLayout } from '~/components/layouts/MainPageLayout' import type { PhotoSyncProgressEvent, PhotoSyncResult } from '../types' import { PhotoLibraryActionBar } from './library/PhotoLibraryActionBar' +import type { PhotoUploadRequestOptions } from './library/upload.types' import type { PhotoPageTab } from './PhotoPage' import { PhotoSyncActions } from './sync/PhotoSyncActions' @@ -13,7 +14,8 @@ type PhotoPageActionsProps = { libraryTotalCount: number isUploading: boolean isDeleting: boolean - onUpload: (files: FileList) => void | Promise + availableTags: string[] + onUpload: (files: FileList, options?: PhotoUploadRequestOptions) => void | Promise onDeleteSelected: () => void onClearSelection: () => void onSelectAll: () => void @@ -28,6 +30,7 @@ export function PhotoPageActions({ libraryTotalCount, isUploading, isDeleting, + availableTags, onUpload, onDeleteSelected, onClearSelection, @@ -56,6 +59,7 @@ export function PhotoPageActions({ totalCount={libraryTotalCount} isUploading={isUploading} isDeleting={isDeleting} + availableTags={availableTags} onUpload={onUpload} onDeleteSelected={onDeleteSelected} onClearSelection={onClearSelection} diff --git a/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryActionBar.tsx b/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryActionBar.tsx index b462519f..b485fb0b 100644 --- a/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryActionBar.tsx +++ b/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryActionBar.tsx @@ -5,13 +5,15 @@ import type { ChangeEventHandler } from 'react' import { useRef } from 'react' import { PhotoUploadConfirmModal } from './PhotoUploadConfirmModal' +import type { PhotoUploadRequestOptions } from './upload.types' type PhotoLibraryActionBarProps = { selectionCount: number totalCount: number isUploading: boolean isDeleting: boolean - onUpload: (files: FileList) => void | Promise + availableTags: string[] + onUpload: (files: FileList, options?: PhotoUploadRequestOptions) => void | Promise onDeleteSelected: () => void onClearSelection: () => void onSelectAll: () => void @@ -22,6 +24,7 @@ export function PhotoLibraryActionBar({ totalCount, isUploading, isDeleting, + availableTags, onUpload, onDeleteSelected, onClearSelection, @@ -44,9 +47,8 @@ export function PhotoLibraryActionBar({ Modal.present(PhotoUploadConfirmModal, { files: selectedFiles, - onConfirm: (confirmedFiles) => { - void onUpload(confirmedFiles) - }, + availableTags, + onUpload, }) if (fileInputRef.current) { @@ -118,7 +120,7 @@ export function PhotoLibraryActionBar({ onClick={onSelectAll} className="flex items-center gap-1 text-text-secondary hover:text-text" > - + {hasAssets ? (canSelectAll ? '全选' : '已全选') : '全选'} diff --git a/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryGrid.tsx b/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryGrid.tsx index 2acc25df..c6b84a3b 100644 --- a/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryGrid.tsx +++ b/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryGrid.tsx @@ -1,7 +1,18 @@ -import { Button, Modal, Prompt, Thumbhash } from '@afilmory/ui' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Modal, + Prompt, + Thumbhash, +} from '@afilmory/ui' import { clsxm } from '@afilmory/utils' import { useAtomValue } from 'jotai' import { DynamicIcon } from 'lucide-react/dynamic' +import type { ReactNode } from 'react' +import { useMemo, useState } from 'react' import { viewportAtom } from '~/atoms/viewport' import { LinearBorderPanel } from '~/components/common/GlassPanel' @@ -26,6 +37,35 @@ type PhotoLibraryGridProps = { isDeleting?: boolean } +type PhotoLibrarySortBy = 'uploadedAt' | 'capturedAt' +type PhotoLibrarySortOrder = 'desc' | 'asc' + +const SORT_BY_OPTIONS: { value: PhotoLibrarySortBy; label: string; icon: string }[] = [ + { value: 'uploadedAt', label: '按上传时间', icon: 'upload' }, + { value: 'capturedAt', label: '按拍摄时间', icon: 'camera' }, +] + +const SORT_ORDER_OPTIONS: { value: PhotoLibrarySortOrder; label: string; icon: string }[] = [ + { value: 'desc', label: '最新优先', icon: 'arrow-down' }, + { value: 'asc', label: '最早优先', icon: 'arrow-up' }, +] + +function parseDate(value?: string | number | null) { + if (!value) return 0 + if (typeof value === 'number') { + return Number.isFinite(value) ? value : 0 + } + const timestamp = Date.parse(value) + return Number.isNaN(timestamp) ? 0 : timestamp +} + +function getSortTimestamp(asset: PhotoAssetListItem, sortBy: PhotoLibrarySortBy) { + if (sortBy === 'capturedAt') { + return parseDate(asset.manifest?.data?.dateTaken) || parseDate(asset.manifest?.data?.exif?.DateTimeOriginal) + } + return parseDate(asset.createdAt) +} + function PhotoGridItem({ asset, isSelected, @@ -193,9 +233,21 @@ export function PhotoLibraryGrid({ }: PhotoLibraryGridProps) { const viewport = useAtomValue(viewportAtom) const columnWidth = viewport.sm ? 320 : 160 + const [sortBy, setSortBy] = useState('uploadedAt') + const [sortOrder, setSortOrder] = useState('desc') + + const sortedAssets = useMemo(() => { + if (!assets) return + return assets.toSorted((a, b) => { + const diff = getSortTimestamp(b, sortBy) - getSortTimestamp(a, sortBy) + return sortOrder === 'desc' ? diff : -diff + }) + }, [assets, sortBy, sortOrder]) + + let content: ReactNode if (isLoading) { - return ( + content = (
{Array.from({ length: 6 }, (_, i) => `photo-skeleton-${i + 1}`).map((key) => (
@@ -204,35 +256,98 @@ export function PhotoLibraryGrid({ ))}
) - } - - if (!assets || assets.length === 0) { - return ( + } else if (!sortedAssets || sortedAssets.length === 0) { + content = (

当前没有图片资源

使用右上角的"上传图片"按钮可以为图库添加新的照片。

) + } else { + content = ( +
+ asset.id} + render={({ data }) => ( + + )} + /> +
+ ) } + const currentSortBy = SORT_BY_OPTIONS.find((option) => option.value === sortBy) ?? SORT_BY_OPTIONS[0] + const currentSortOrder = SORT_ORDER_OPTIONS.find((option) => option.value === sortOrder) ?? SORT_ORDER_OPTIONS[0] + return ( -
- asset.id} - render={({ data }) => ( - - )} - /> +
+
+ + + + + + {SORT_BY_OPTIONS.map((option) => ( + } + onSelect={() => setSortBy(option.value)} + > + {option.label} + + ))} + + + + + + + + + {SORT_ORDER_OPTIONS.map((option) => ( + } + onSelect={() => setSortOrder(option.value)} + > + {option.label} + + ))} + + +
+ + {content}
) } diff --git a/be/apps/dashboard/src/modules/photos/components/library/PhotoUploadConfirmModal.tsx b/be/apps/dashboard/src/modules/photos/components/library/PhotoUploadConfirmModal.tsx index de6958d6..c497084d 100644 --- a/be/apps/dashboard/src/modules/photos/components/library/PhotoUploadConfirmModal.tsx +++ b/be/apps/dashboard/src/modules/photos/components/library/PhotoUploadConfirmModal.tsx @@ -1,178 +1,28 @@ import type { ModalComponent } from '@afilmory/ui' -import { Button, LinearDivider } from '@afilmory/ui' -import { clsxm, Spring } from '@afilmory/utils' -import { m } from 'motion/react' -import { useMemo } from 'react' -function formatBytes(bytes: number) { - if (!Number.isFinite(bytes) || bytes <= 0) return '未知大小' - const units = ['B', 'KB', 'MB', 'GB', 'TB'] - const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) - const size = bytes / 1024 ** exponent - return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[exponent]}` -} - -const IMAGE_EXTENSIONS = new Set([ - 'jpg', - 'jpeg', - 'png', - 'gif', - 'webp', - 'bmp', - 'tiff', - 'tif', - 'heic', - 'heif', - 'hif', - 'avif', - 'raw', - 'dng', -]) - -function getFileExtension(name: string) { - const normalized = name.toLowerCase() - const lastDotIndex = normalized.lastIndexOf('.') - return lastDotIndex === -1 ? '' : normalized.slice(lastDotIndex + 1) -} - -function getBaseName(name: string) { - const normalized = name.toLowerCase() - const lastDotIndex = normalized.lastIndexOf('.') - return lastDotIndex === -1 ? normalized : normalized.slice(0, lastDotIndex) -} - -const isMovFile = (name: string) => name.toLowerCase().endsWith('.mov') +import { PhotoUploadSteps } from './photo-upload/steps' +import { PhotoUploadStoreProvider } from './photo-upload/store' +import type { PhotoUploadRequestOptions } from './upload.types' type PhotoUploadConfirmModalProps = { files: File[] - onConfirm: (files: FileList) => void | Promise + availableTags: string[] + onUpload: (files: FileList, options: PhotoUploadRequestOptions) => void | Promise } export const PhotoUploadConfirmModal: ModalComponent = ({ files, - onConfirm, + availableTags, + onUpload, dismiss, }) => { - const fileItems = useMemo(() => files, [files]) - const totalSize = useMemo(() => fileItems.reduce((sum, file) => sum + file.size, 0), [fileItems]) - - const imageBaseNames = useMemo(() => { - return new Set( - fileItems - .filter((file) => IMAGE_EXTENSIONS.has(getFileExtension(file.name))) - .map((file) => getBaseName(file.name)), - ) - }, [fileItems]) - - const unmatchedMovFiles = useMemo(() => { - return fileItems.filter((file) => { - if (!isMovFile(file.name)) return false - return !imageBaseNames.has(getBaseName(file.name)) - }) - }, [fileItems, imageBaseNames]) - - const hasUnmatchedMov = unmatchedMovFiles.length > 0 - const hasMovFile = useMemo(() => fileItems.some((file) => isMovFile(file.name)), [fileItems]) - - const createFileList = (fileArray: File[]): FileList => { - if (typeof DataTransfer !== 'undefined') { - const transfer = new DataTransfer() - fileArray.forEach((file) => transfer.items.add(file)) - return transfer.files - } - - const fallback: Record & { length: number; item: (index: number) => File | null } = { - length: fileArray.length, - item: (index: number) => fileArray[index] ?? null, - } - - fileArray.forEach((file, index) => { - fallback[index] = file - }) - - return fallback as unknown as FileList - } - - const handleConfirm = () => { - if (hasUnmatchedMov) return - const fileList = createFileList(fileItems) - void onConfirm(fileList) - dismiss() - } - return ( -
-
-

确认上传这些文件?

-

- 共选择 {fileItems.length} 项,预计占用 {formatBytes(totalSize)}。确认后将立即开始上传。 -

- {hasUnmatchedMov ? ( -
-

以下 MOV 文件缺少同名的图像文件,请补齐后再尝试上传:

-
    - {unmatchedMovFiles.map((file) => ( -
  • {file.name}
  • - ))} -
-
- ) : ( - hasMovFile && ( -

已检测到 MOV 文件,将与同名图片一起作为 Live Photo 处理。

- ) - )} + +
+
- - - -
-
- - {fileItems.map((file) => { - const isUnmatchedMov = unmatchedMovFiles.includes(file) - return ( -
  • - - {file.name} - -
    - {isUnmatchedMov ? 缺少同名图片 : null} - {formatBytes(file.size)} -
    -
  • - ) - })} -
    -
    -
    - -
    - - -
    -
    + ) } -PhotoUploadConfirmModal.contentClassName = 'w-[min(420px,90vw)] p-6' +PhotoUploadConfirmModal.contentClassName = 'w-[min(520px,92vw)] p-6' diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/AutoSelect.tsx b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/AutoSelect.tsx new file mode 100644 index 00000000..5fe64582 --- /dev/null +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/AutoSelect.tsx @@ -0,0 +1,124 @@ +import { Button, Input } from '@afilmory/ui' +import { clsxm } from '@afilmory/utils' +import type { KeyboardEvent } from 'react' +import { useMemo, useState } from 'react' + +import { LinearBorderPanel } from '~/components/common/GlassPanel' + +type AutoSelectOption = { + label: string + value: string +} + +type AutoSelectProps = { + options: AutoSelectOption[] + value: string[] + onChange: (next: string[]) => void + placeholder?: string + disabled?: boolean +} + +export function AutoSelect({ options, value, onChange, placeholder, disabled }: AutoSelectProps) { + const [query, setQuery] = useState('') + const normalizedValueSet = useMemo(() => new Set(value.map((item) => item.toLowerCase())), [value]) + const normalizedQuery = query.trim().toLowerCase() + const filteredOptions = useMemo(() => { + const available = options.filter((option) => !normalizedValueSet.has(option.value.toLowerCase())) + if (!normalizedQuery) { + return available.slice(0, 8) + } + return available.filter((option) => option.label.toLowerCase().includes(normalizedQuery)).slice(0, 8) + }, [normalizedQuery, normalizedValueSet, options]) + + const handleAddTag = (tag: string) => { + const trimmed = tag.trim() + if (!trimmed || normalizedValueSet.has(trimmed.toLowerCase())) { + return + } + onChange([...value, trimmed]) + setQuery('') + } + + const handleRemove = (tag: string) => { + onChange(value.filter((item) => item !== tag)) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + handleAddTag(query) + } + if (event.key === 'Backspace' && !query && value.length > 0) { + event.preventDefault() + handleRemove(value.at(-1)) + } + } + + const showCreateOption = Boolean(normalizedQuery && !normalizedValueSet.has(normalizedQuery)) + + return ( +
    + +
    + {value.map((tag) => ( + + ))} +
    + setQuery(event.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="border-none px-1 py-0 text-xs focus:ring-0" + disabled={disabled} + /> +
    +
    +
    + + {(filteredOptions.length > 0 || showCreateOption) && !disabled ? ( + +

    选择或创建标签

    +
    + {filteredOptions.map((option) => ( + + ))} + {showCreateOption ? ( + + ) : null} +
    +
    + ) : null} +
    + ) +} diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/ProcessingPanel.tsx b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/ProcessingPanel.tsx new file mode 100644 index 00000000..5d33a0d3 --- /dev/null +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/ProcessingPanel.tsx @@ -0,0 +1,62 @@ +import { LinearBorderPanel } from '~/components/common/GlassPanel' + +import { STAGE_CONFIG, STAGE_ORDER,SUMMARY_FIELDS } from './constants' +import type { ProcessingState } from './types' + +type ProcessingPanelProps = { + state: ProcessingState | null +} + +export function ProcessingPanel({ state }: ProcessingPanelProps) { + if (!state) { + return ( + + 正在等待服务器处理开始... + + ) + } + + return ( +
    +
    + {STAGE_ORDER.map((stage) => { + const stageState = state.stages[stage] + const ratio = stageState.total === 0 ? 1 : Math.min(1, stageState.processed / stageState.total) + const config = STAGE_CONFIG[stage] + return ( + +
    + {config.label} + + {stageState.status === 'completed' + ? '已完成' + : stageState.total === 0 + ? '无需处理' + : `${stageState.processed} / ${stageState.total}`} + +
    +
    +
    +
    +

    {config.description}

    + + ) + })} +
    +
    + {SUMMARY_FIELDS.map((field) => ( + +

    {field.label}

    +

    {state.summary[field.key]}

    +
    + ))} +
    + {state.latestLog ? ( + + 最新日志: + {state.latestLog.message} + + ) : null} +
    + ) +} diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/UploadFileList.tsx b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/UploadFileList.tsx new file mode 100644 index 00000000..f74433c9 --- /dev/null +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/UploadFileList.tsx @@ -0,0 +1,73 @@ +import { Spring } from '@afilmory/utils' +import { m } from 'motion/react' + +import { LinearBorderPanel } from '~/components/common/GlassPanel' + +import { FILE_STATUS_CLASS, FILE_STATUS_LABEL } from './constants' +import type { FileProgressEntry } from './types' +import { formatBytes } from './utils' + +type UploadFileListProps = { + entries: FileProgressEntry[] + overallProgress: number +} + +export function UploadFileList({ entries, overallProgress }: UploadFileListProps) { + return ( +
    +
    + 上传进度 + {Math.round(overallProgress * 100)}% +
    +
    + +
    + + + {entries.map((entry) => ( +
  • +
    +
    + + {entry.name} + +

    {formatBytes(entry.size)}

    +
    + + {FILE_STATUS_LABEL[entry.status]} + +
    +
    +
    +
    +
  • + ))} +
    +
    +
    + ) +} diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/constants.ts b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/constants.ts new file mode 100644 index 00000000..b09f2310 --- /dev/null +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/constants.ts @@ -0,0 +1,68 @@ +import type { PhotoSyncProgressStage, PhotoSyncResultSummary } from '../../../types' +import type { WorkflowPhase } from './types' + +export const WORKFLOW_STEP_LABEL: Record, string> = { + review: '校验文件', + uploading: '上传中', + processing: '服务器处理', + completed: '完成', +} + +export const DISPLAY_STEPS: Array> = ['review', 'uploading', 'processing', 'completed'] + +export const STAGE_CONFIG: Record< + PhotoSyncProgressStage, + { + label: string + description: string + } +> = { + 'missing-in-db': { + label: '导入新照片', + description: '将新文件写入数据库记录', + }, + 'orphan-in-db': { + label: '校验孤立记录', + description: '确认缺失文件的旧记录状态', + }, + 'metadata-conflicts': { + label: '比对元数据', + description: '检查文件与数据库间的元数据差异', + }, + 'status-reconciliation': { + label: '状态对齐', + description: '写入最新缩略图与状态', + }, +} + +export const STAGE_ORDER: PhotoSyncProgressStage[] = [ + 'missing-in-db', + 'orphan-in-db', + 'metadata-conflicts', + 'status-reconciliation', +] + +export const SUMMARY_FIELDS: Array<{ key: keyof PhotoSyncResultSummary; label: string }> = [ + { key: 'inserted', label: '新增' }, + { key: 'updated', label: '更新' }, + { key: 'conflicts', label: '冲突' }, + { key: 'errors', label: '错误' }, +] + +export const FILE_STATUS_LABEL = { + pending: '待上传', + uploading: '上传中', + uploaded: '已上传', + processing: '处理中', + done: '完成', + error: '失败', +} as const + +export const FILE_STATUS_CLASS = { + pending: 'text-text-tertiary', + uploading: 'text-accent', + uploaded: 'text-sky-300', + processing: 'text-amber-300', + done: 'text-emerald-300', + error: 'text-rose-300', +} as const diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/CompletedStep.tsx b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/CompletedStep.tsx new file mode 100644 index 00000000..267f6d3c --- /dev/null +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/CompletedStep.tsx @@ -0,0 +1,32 @@ +import { Button } from '@afilmory/ui' +import { useShallow } from 'zustand/shallow' + +import { usePhotoUploadStore } from '../store' +import { UploadFileList } from '../UploadFileList' + +export function CompletedStep() { + const { uploadEntries, progress } = usePhotoUploadStore( + useShallow((state) => ({ + uploadEntries: state.uploadEntries, + progress: state.totalSize === 0 ? 0 : Math.min(1, state.uploadedBytes / state.totalSize), + })), + ) + const closeModal = usePhotoUploadStore((state) => state.closeModal) + + return ( +
    +
    +

    上传完成

    +

    所有文件均已上传并处理完成,新的照片已加入图库。

    +
    + + + +
    + +
    +
    + ) +} diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ErrorStep.tsx b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ErrorStep.tsx new file mode 100644 index 00000000..df43dd33 --- /dev/null +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ErrorStep.tsx @@ -0,0 +1,52 @@ +import { Button } from '@afilmory/ui' +import { useShallow } from 'zustand/shallow' + +import { LinearBorderPanel } from '~/components/common/GlassPanel' + +import { ProcessingPanel } from '../ProcessingPanel' +import { usePhotoUploadStore } from '../store' +import { UploadFileList } from '../UploadFileList' + +export function ErrorStep() { + const { uploadEntries, progress, processingState, errorMessage } = usePhotoUploadStore( + useShallow((state) => ({ + uploadEntries: state.uploadEntries, + progress: state.totalSize === 0 ? 0 : Math.min(1, state.uploadedBytes / state.totalSize), + processingState: state.processingState, + errorMessage: state.uploadError ?? state.processingError ?? '上传过程中发生错误,请稍后再试。', + })), + ) + const reset = usePhotoUploadStore((state) => state.reset) + const closeModal = usePhotoUploadStore((state) => state.closeModal) + + return ( +
    +
    +

    上传失败

    +

    请查看错误信息后重试,或稍后再尝试上传。

    +
    + + + {errorMessage} + + + + + +
    + + +
    +
    + ) +} diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ProcessingStep.tsx b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ProcessingStep.tsx new file mode 100644 index 00000000..bd30f173 --- /dev/null +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ProcessingStep.tsx @@ -0,0 +1,44 @@ +import { Button } from '@afilmory/ui' +import { useShallow } from 'zustand/shallow' + +import { ProcessingPanel } from '../ProcessingPanel' +import { usePhotoUploadStore } from '../store' +import { UploadFileList } from '../UploadFileList' + +export function ProcessingStep() { + const { uploadEntries, progress, processingState } = usePhotoUploadStore( + useShallow((state) => ({ + uploadEntries: state.uploadEntries, + progress: state.totalSize === 0 ? 0 : Math.min(1, state.uploadedBytes / state.totalSize), + processingState: state.processingState, + })), + ) + const abortCurrent = usePhotoUploadStore((state) => state.abortCurrent) + + return ( +
    +
    +

    服务器处理进行中

    +

    已完成文件上传,正在同步元数据和缩略图,请稍候。

    +
    + + + + +
    + + +
    +
    + ) +} diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ReviewStep.tsx b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ReviewStep.tsx new file mode 100644 index 00000000..49cc5673 --- /dev/null +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ReviewStep.tsx @@ -0,0 +1,87 @@ +import { Button } from '@afilmory/ui' +import { useMemo } from 'react' +import { useShallow } from 'zustand/shallow' + +import { LinearBorderPanel } from '~/components/common/GlassPanel' + +import { AutoSelect } from '../AutoSelect' +import { usePhotoUploadStore } from '../store' +import { UploadFileList } from '../UploadFileList' +import { formatBytes } from '../utils' + +export function ReviewStep() { + const { filesCount, totalSize, hasMovFile, unmatchedMovFiles, availableTags, selectedTags } = usePhotoUploadStore( + useShallow((state) => ({ + filesCount: state.files.length, + totalSize: state.totalSize, + hasMovFile: state.hasMovFile, + unmatchedMovFiles: state.unmatchedMovFiles, + availableTags: state.availableTags, + selectedTags: state.selectedTags, + })), + ) + const { uploadEntries, progress } = usePhotoUploadStore( + useShallow((state) => ({ + uploadEntries: state.uploadEntries, + progress: state.totalSize === 0 ? 0 : Math.min(1, state.uploadedBytes / state.totalSize), + })), + ) + + const beginUpload = usePhotoUploadStore((state) => state.beginUpload) + const closeModal = usePhotoUploadStore((state) => state.closeModal) + const setSelectedTags = usePhotoUploadStore((state) => state.setSelectedTags) + + const tagOptions = useMemo( + () => availableTags.map((tag) => ({ label: tag, value: tag.toLowerCase() })), + [availableTags], + ) + const hasUnmatched = unmatchedMovFiles.length > 0 + + return ( +
    +
    +

    确认上传这些文件?

    +

    + 共选择 {filesCount} 项,预计占用 {formatBytes(totalSize)}。请先设置标签后开始上传。 +

    +
    + + {hasUnmatched ? ( + +

    以下 MOV 文件缺少同名的图像文件,请补齐后再尝试上传:

    +
      + {unmatchedMovFiles.map((file) => ( +
    • {file.name}
    • + ))} +
    +
    + ) : hasMovFile ? ( +

    已检测到 MOV 文件,将与同名图片一起作为 Live Photo 处理。

    + ) : null} + + + + + +
    + + +
    +
    + ) +} diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/UploadingStep.tsx b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/UploadingStep.tsx new file mode 100644 index 00000000..ad540595 --- /dev/null +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/UploadingStep.tsx @@ -0,0 +1,41 @@ +import { Button } from '@afilmory/ui' +import { useShallow } from 'zustand/shallow' + +import { usePhotoUploadStore } from '../store' +import { UploadFileList } from '../UploadFileList' + +export function UploadingStep() { + const { uploadEntries, progress } = usePhotoUploadStore( + useShallow((state) => ({ + uploadEntries: state.uploadEntries, + progress: state.totalSize === 0 ? 0 : Math.min(1, state.uploadedBytes / state.totalSize), + })), + ) + const abortCurrent = usePhotoUploadStore((state) => state.abortCurrent) + + return ( +
    +
    +

    正在上传文件

    +

    正在处理选中的文件,请保持页面打开,以免中断进度。

    +
    + + + +
    + + +
    +
    + ) +} diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/index.tsx b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/index.tsx new file mode 100644 index 00000000..18368921 --- /dev/null +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/index.tsx @@ -0,0 +1,21 @@ +import { usePhotoUploadStore } from '../store' +import type { WorkflowPhase } from '../types' +import { CompletedStep } from './CompletedStep' +import { ErrorStep } from './ErrorStep' +import { ProcessingStep } from './ProcessingStep' +import { ReviewStep } from './ReviewStep' +import { UploadingStep } from './UploadingStep' + +const STEP_COMPONENTS: Record React.JSX.Element> = { + review: ReviewStep, + uploading: UploadingStep, + processing: ProcessingStep, + completed: CompletedStep, + error: ErrorStep, +} + +export function PhotoUploadSteps() { + const phase = usePhotoUploadStore((state) => state.phase) + const StepComponent = STEP_COMPONENTS[phase] ?? ReviewStep + return +} diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/store.tsx b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/store.tsx new file mode 100644 index 00000000..4054ce80 --- /dev/null +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/store.tsx @@ -0,0 +1,413 @@ +import type { ReactNode } from 'react' +import { createContext, use, useEffect, useMemo } from 'react' +import type { StoreApi } from 'zustand' +import { useStore } from 'zustand' +import { createStore } from 'zustand/vanilla' + +import { runPhotoSync } from '../../../api' +import type { PhotoSyncProgressEvent } from '../../../types' +import type { PhotoUploadRequestOptions } from '../upload.types' +import type { FileProgressEntry, ProcessingState, WorkflowPhase } from './types' +import { + calculateTotalSize, + calculateUploadedBytes, + collectUnmatchedMovFiles, + createFileEntries, + createFileList, + createStageStateFromTotals, + deriveDirectoryFromTags, + getErrorMessage, +} from './utils' + +type PhotoUploadStoreState = { + files: File[] + totalSize: number + uploadedBytes: number + availableTags: string[] + selectedTags: string[] + unmatchedMovFiles: File[] + hasMovFile: boolean + phase: WorkflowPhase + uploadEntries: FileProgressEntry[] + uploadError: string | null + processingError: string | null + processingState: ProcessingState | null + beginUpload: () => Promise + abortCurrent: () => void + reset: () => void + closeModal: () => void + setSelectedTags: (tags: string[]) => void + cleanup: () => void +} + +type PhotoUploadStoreParams = { + files: File[] + availableTags: string[] + onUpload: (files: FileList, options: PhotoUploadRequestOptions) => void | Promise + onClose: () => void +} + +export type PhotoUploadStore = StoreApi + +const PhotoUploadStoreContext = createContext(null) + +const computeUploadedBytes = (entries: FileProgressEntry[]) => calculateUploadedBytes(entries) + +export function createPhotoUploadStore(params: PhotoUploadStoreParams): PhotoUploadStore { + const { files, availableTags, onUpload, onClose } = params + const initialEntries = createFileEntries(files) + const totalSize = calculateTotalSize(files) + const { unmatched: unmatchedMovFiles, hasMov } = collectUnmatchedMovFiles(files) + + let uploadAbortController: AbortController | null = null + let processingAbortController: AbortController | null = null + + const store = createStore((set, get) => { + const updateEntries = (updater: (entries: FileProgressEntry[]) => FileProgressEntry[]) => { + set((state) => { + const nextEntries = updater(state.uploadEntries) + return { + uploadEntries: nextEntries, + uploadedBytes: computeUploadedBytes(nextEntries), + } + }) + } + + const handleProcessingEvent = (event: PhotoSyncProgressEvent) => { + if (event.type === 'start') { + const { summary, totals, options } = event.payload + set({ + processingState: { + dryRun: options.dryRun ?? false, + summary, + totals, + stages: createStageStateFromTotals(totals), + completed: false, + }, + }) + return + } + + set((state) => { + const prev = state.processingState + if (!prev) { + return {} + } + + switch (event.type) { + case 'stage': { + const { stage, status, processed, total, summary } = event.payload + return { + processingState: { + ...prev, + summary, + stages: { + ...prev.stages, + [stage]: { + status: status === 'complete' || total === 0 ? 'completed' : 'running', + processed, + total, + }, + }, + }, + } + } + case 'action': { + const { stage, index, total, summary } = event.payload + return { + processingState: { + ...prev, + summary, + stages: { + ...prev.stages, + [stage]: { + status: total === 0 ? 'completed' : 'running', + processed: index, + total, + }, + }, + }, + } + } + case 'log': { + const timestamp = Date.parse(event.payload.timestamp) + return { + processingState: { + ...prev, + latestLog: { + message: event.payload.message, + level: event.payload.level, + timestamp: Number.isNaN(timestamp) ? Date.now() : timestamp, + }, + }, + } + } + case 'error': { + return { + processingState: { + ...prev, + error: event.payload.message, + }, + } + } + case 'complete': { + return { + processingState: { + ...prev, + summary: event.payload.summary, + completed: true, + }, + } + } + default: { + return {} + } + } + }) + } + + const startProcessing = async () => { + set((state) => ({ + phase: 'processing', + processingError: null, + processingState: state.processingState, + })) + + updateEntries((entries) => + entries.map((entry) => ({ + ...entry, + status: entry.status === 'uploaded' ? 'processing' : entry.status, + })), + ) + + const controller = new AbortController() + processingAbortController = controller + + try { + await runPhotoSync( + { dryRun: false }, + { + signal: controller.signal, + onEvent: handleProcessingEvent, + }, + ) + + updateEntries((entries) => + entries.map((entry) => ({ + ...entry, + status: entry.status === 'processing' ? 'done' : entry.status, + })), + ) + + set((state) => ({ + phase: 'completed', + processingState: state.processingState + ? { + ...state.processingState, + completed: true, + } + : state.processingState, + })) + } catch (error) { + const isAbort = (error as DOMException)?.name === 'AbortError' + const message = isAbort ? '服务器处理已终止' : getErrorMessage(error, '服务器处理失败,请稍后再试。') + + updateEntries((entries) => + entries.map((entry) => ({ + ...entry, + status: entry.status === 'processing' ? 'error' : entry.status, + })), + ) + + set({ + processingError: message, + phase: 'error', + }) + } finally { + processingAbortController = null + } + } + + const handleUploadProgress: NonNullable = (snapshot) => { + const progressMap = new Map(snapshot.files.map((file) => [file.index, file])) + updateEntries((entries) => + entries.map((entry) => { + const current = progressMap.get(entry.index) + if (!current) { + return entry + } + return { + ...entry, + status: entry.status === 'pending' ? 'uploading' : entry.status, + progress: current.progress, + uploadedBytes: current.uploadedBytes, + } + }), + ) + } + + return { + files, + totalSize, + uploadedBytes: 0, + availableTags, + selectedTags: [], + unmatchedMovFiles, + hasMovFile: hasMov, + phase: 'review', + uploadEntries: initialEntries, + uploadError: null, + processingError: null, + processingState: null, + beginUpload: async () => { + if (get().unmatchedMovFiles.length > 0 || get().phase === 'uploading' || get().phase === 'processing') { + return + } + + set({ + uploadError: null, + processingError: null, + processingState: null, + phase: 'uploading', + }) + + updateEntries((entries) => + entries.map((entry) => ({ + ...entry, + status: 'uploading', + })), + ) + + const controller = new AbortController() + uploadAbortController = controller + + try { + const directory = deriveDirectoryFromTags(get().selectedTags) + const fileList = createFileList(files) + await onUpload(fileList, { + signal: controller.signal, + directory: directory ?? undefined, + onUploadProgress: handleUploadProgress, + }) + + updateEntries((entries) => + entries.map((entry) => ({ + ...entry, + status: 'uploaded', + progress: 1, + uploadedBytes: entry.size, + })), + ) + + await startProcessing() + } catch (error) { + const isAbort = (error as DOMException)?.name === 'AbortError' + if (isAbort) { + set({ phase: 'review' }) + updateEntries(() => createFileEntries(files)) + } else { + const message = getErrorMessage(error, '上传失败,请稍后再试。') + set({ + uploadError: message, + phase: 'error', + }) + updateEntries((entries) => + entries.map((entry) => ({ + ...entry, + status: entry.status === 'uploading' ? 'error' : entry.status, + })), + ) + } + } finally { + uploadAbortController = null + } + }, + abortCurrent: () => { + const { phase } = get() + if (phase === 'uploading') { + uploadAbortController?.abort() + uploadAbortController = null + set({ phase: 'review' }) + updateEntries(() => createFileEntries(files)) + return + } + if (phase === 'processing') { + processingAbortController?.abort() + processingAbortController = null + set({ + processingError: '服务器处理已终止', + phase: 'error', + }) + updateEntries((entries) => + entries.map((entry) => ({ + ...entry, + status: entry.status === 'processing' ? 'error' : entry.status, + })), + ) + return + } + }, + reset: () => { + uploadAbortController?.abort() + processingAbortController?.abort() + uploadAbortController = null + processingAbortController = null + set({ + phase: 'review', + uploadError: null, + processingError: null, + processingState: null, + }) + updateEntries(() => createFileEntries(files)) + }, + closeModal: () => { + get().cleanup() + onClose() + }, + setSelectedTags: (tags: string[]) => { + set({ selectedTags: tags }) + }, + cleanup: () => { + uploadAbortController?.abort() + processingAbortController?.abort() + uploadAbortController = null + processingAbortController = null + }, + } + }) + + return store +} + +type PhotoUploadStoreProviderProps = PhotoUploadStoreParams & { + children: ReactNode +} + +export function PhotoUploadStoreProvider({ + children, + files, + availableTags, + onUpload, + onClose, +}: PhotoUploadStoreProviderProps) { + const store = useMemo( + () => createPhotoUploadStore({ files, availableTags, onUpload, onClose }), + [files, availableTags, onUpload, onClose], + ) + + useEffect(() => { + return () => { + store.getState().cleanup() + } + }, [store]) + + return {children} +} + +export function usePhotoUploadStore(selector: (state: PhotoUploadStoreState) => U) { + const store = use(PhotoUploadStoreContext) + if (!store) { + throw new Error('usePhotoUploadStore must be used within PhotoUploadStoreProvider') + } + return useStore(store, selector) +} diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/types.ts b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/types.ts new file mode 100644 index 00000000..ba6303f7 --- /dev/null +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/types.ts @@ -0,0 +1,41 @@ +import type { + PhotoSyncLogLevel, + PhotoSyncProgressStage, + PhotoSyncResultSummary, + PhotoSyncStageTotals, +} from '../../../types' + +export type FileUploadStatus = 'pending' | 'uploading' | 'uploaded' | 'processing' | 'done' | 'error' + +export type FileProgressEntry = { + index: number + name: string + size: number + status: FileUploadStatus + uploadedBytes: number + progress: number +} + +export type WorkflowPhase = 'review' | 'uploading' | 'processing' | 'completed' | 'error' + +export type ProcessingStageState = { + status: 'pending' | 'running' | 'completed' + processed: number + total: number +} + +export type ProcessingLatestLog = { + message: string + level: PhotoSyncLogLevel + timestamp: number +} + +export type ProcessingState = { + dryRun: boolean + summary: PhotoSyncResultSummary + totals: PhotoSyncStageTotals + stages: Record + completed: boolean + latestLog?: ProcessingLatestLog + error?: string +} diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/utils.ts b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/utils.ts new file mode 100644 index 00000000..54fd72a9 --- /dev/null +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/utils.ts @@ -0,0 +1,151 @@ +import type { PhotoSyncProgressStage, PhotoSyncStageTotals } from '../../../types' +import { STAGE_ORDER } from './constants' +import type { FileProgressEntry, ProcessingStageState } from './types' + +const IMAGE_EXTENSIONS = new Set([ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + 'bmp', + 'tiff', + 'tif', + 'heic', + 'heif', + 'hif', + 'avif', + 'raw', + 'dng', +]) + +const isMovFile = (name: string) => name.toLowerCase().endsWith('.mov') + +const getFileExtension = (name: string) => { + const normalized = name.toLowerCase() + const lastDotIndex = normalized.lastIndexOf('.') + return lastDotIndex === -1 ? '' : normalized.slice(lastDotIndex + 1) +} + +const getBaseName = (name: string) => { + const normalized = name.toLowerCase() + const lastDotIndex = normalized.lastIndexOf('.') + return lastDotIndex === -1 ? normalized : normalized.slice(0, lastDotIndex) +} + +export function formatBytes(bytes: number) { + if (!Number.isFinite(bytes) || bytes <= 0) return '未知大小' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) + const size = bytes / 1024 ** exponent + return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[exponent]}` +} + +export function createFileEntries(files: File[]): FileProgressEntry[] { + return files.map((file, index) => ({ + index, + name: file.name, + size: file.size, + status: 'pending', + uploadedBytes: 0, + progress: 0, + })) +} + +export function createStageStateFromTotals( + totals: PhotoSyncStageTotals, +): Record { + return STAGE_ORDER.reduce>( + (acc, stage) => { + const total = totals[stage] + acc[stage] = { + status: total === 0 ? 'completed' : 'pending', + processed: 0, + total, + } + return acc + }, + {} as Record, + ) +} + +export function getErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) { + return error.message + } + if (typeof error === 'object' && error && 'message' in error) { + const candidate = (error as { message?: unknown }).message + if (typeof candidate === 'string' && candidate.trim()) { + return candidate + } + } + return fallback +} + +export function createFileList(fileArray: File[]): FileList { + if (typeof DataTransfer !== 'undefined') { + const transfer = new DataTransfer() + fileArray.forEach((file) => transfer.items.add(file)) + return transfer.files + } + + const fallback: Record & { length: number; item: (index: number) => File | null } = { + length: fileArray.length, + item: (index: number) => fileArray[index] ?? null, + } + + fileArray.forEach((file, index) => { + fallback[index] = file + }) + + return fallback as unknown as FileList +} + +export function sanitizeTagSegment(tag: string): string { + if (typeof tag !== 'string') { + return '' + } + const normalized = tag + .normalize('NFKC') + .trim() + .replaceAll(/[\\/]+/g, '-') + .replaceAll(/\s+/g, '-') + .replaceAll(/[^\w\u00A0-\uFFFF.-]/g, '-') + .replaceAll(/-+/g, '-') + .replaceAll(/^-+|-+$/g, '') + return normalized +} + +export function deriveDirectoryFromTags(tags: string[]): string | null { + if (!Array.isArray(tags) || tags.length === 0) { + return null + } + const segments = tags.map((element) => sanitizeTagSegment(element)).filter((segment) => segment.length > 0) + + if (segments.length === 0) { + return null + } + + return segments.join('/') +} + +export function collectUnmatchedMovFiles(files: File[]) { + const imageBaseNames = new Set( + files.filter((file) => IMAGE_EXTENSIONS.has(getFileExtension(file.name))).map((file) => getBaseName(file.name)), + ) + + const unmatched = files.filter((file) => isMovFile(file.name) && !imageBaseNames.has(getBaseName(file.name))) + + return { + unmatched, + hasMov: files.some((file) => isMovFile(file.name)), + } +} + +export function calculateTotalSize(files: File[]): number { + return files.reduce((sum, file) => sum + file.size, 0) +} + +export function calculateUploadedBytes(entries: FileProgressEntry[]): number { + return entries.reduce((sum, entry) => sum + Math.min(entry.uploadedBytes, entry.size), 0) +} diff --git a/be/apps/dashboard/src/modules/photos/components/library/upload.types.ts b/be/apps/dashboard/src/modules/photos/components/library/upload.types.ts new file mode 100644 index 00000000..e3d8f55c --- /dev/null +++ b/be/apps/dashboard/src/modules/photos/components/library/upload.types.ts @@ -0,0 +1,7 @@ +import type { PhotoUploadProgressSnapshot } from '../../api' + +export type PhotoUploadRequestOptions = { + signal?: AbortSignal + onUploadProgress?: (snapshot: PhotoUploadProgressSnapshot) => void + directory?: string | null +} diff --git a/be/apps/dashboard/src/modules/photos/hooks.ts b/be/apps/dashboard/src/modules/photos/hooks.ts index 4ef60a28..44b4dbdd 100644 --- a/be/apps/dashboard/src/modules/photos/hooks.ts +++ b/be/apps/dashboard/src/modules/photos/hooks.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { UploadPhotoAssetsOptions } from './api' import { deletePhotoAssets, getPhotoAssetSummary, @@ -78,8 +79,22 @@ export function useUploadPhotoAssetsMutation() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (files: File[]) => { - return await uploadPhotoAssets(files) + mutationFn: async ({ + files, + directory, + signal, + onProgress, + }: { + files: File[] + directory?: string | null + signal?: AbortSignal + onProgress?: UploadPhotoAssetsOptions['onProgress'] + }) => { + return await uploadPhotoAssets(files, { + directory: directory ?? undefined, + signal, + onProgress, + }) }, onSuccess: () => { void queryClient.invalidateQueries({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee944a09..49cb54ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1113,6 +1113,9 @@ importers: usehooks-ts: specifier: 3.1.1 version: 3.1.1(react@19.2.0) + zustand: + specifier: 5.0.8 + version: 5.0.8(@types/react@19.2.3)(immer@10.2.0)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) devDependencies: '@egoist/tailwindcss-icons': specifier: 1.9.0