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 <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-15 23:34:07 +08:00
parent 796f9c960e
commit 2f1b69f0bd
25 changed files with 1578 additions and 207 deletions

View File

@@ -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)
}

View File

@@ -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,
})),
)

View File

@@ -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"
]
}
}
}

View File

@@ -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<PhotoAssetListItem[]> {
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<PhotoAssetListItem[]>((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<EventTarget>) => {
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<string> {

View File

@@ -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<string>()
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}

View File

@@ -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<void>
availableTags: string[]
onUpload: (files: FileList, options?: PhotoUploadRequestOptions) => void | Promise<void>
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}

View File

@@ -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<void>
availableTags: string[]
onUpload: (files: FileList, options?: PhotoUploadRequestOptions) => void | Promise<void>
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"
>
<DynamicIcon name={canSelectAll ? 'square' : 'check-square'} className="h-3.5 w-3.5" />
<DynamicIcon name={canSelectAll ? 'square' : 'check-square'} className="size-4" />
{hasAssets ? (canSelectAll ? '全选' : '已全选') : '全选'}
</Button>
</div>

View File

@@ -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<PhotoLibrarySortBy>('uploadedAt')
const [sortOrder, setSortOrder] = useState<PhotoLibrarySortOrder>('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 = (
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
{Array.from({ length: 6 }, (_, i) => `photo-skeleton-${i + 1}`).map((key) => (
<div key={key} className="mb-4 break-inside-avoid">
@@ -204,35 +256,98 @@ export function PhotoLibraryGrid({
))}
</div>
)
}
if (!assets || assets.length === 0) {
return (
} else if (!sortedAssets || sortedAssets.length === 0) {
content = (
<LinearBorderPanel className="bg-background-tertiary relative overflow-hidden p-4 sm:p-8 text-center">
<p className="text-text text-sm sm:text-base font-semibold"></p>
<p className="text-text-tertiary mt-2 text-xs sm:text-sm">使"上传图片"</p>
</LinearBorderPanel>
)
} else {
content = (
<div className="lg:mx-[calc(calc((3rem+100vw)-(var(--container-7xl)))*-1/2)] -mx-2 lg:mt-0 mt-12 p-1">
<Masonry
items={sortedAssets}
columnGutter={8}
columnWidth={columnWidth}
itemKey={(asset) => asset.id}
render={({ data }) => (
<PhotoGridItem
asset={data}
isSelected={selectedIds.has(data.id)}
onToggleSelect={onToggleSelect}
onOpenAsset={onOpenAsset}
onDeleteAsset={onDeleteAsset}
isDeleting={isDeleting}
/>
)}
/>
</div>
)
}
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 (
<div className="lg:mx-[calc(calc((3rem+100vw)-(var(--container-7xl)))*-1/2)] -mx-2 lg:mt-0 mt-12 p-1">
<Masonry
items={assets}
columnGutter={8}
columnWidth={columnWidth}
itemKey={(asset) => asset.id}
render={({ data }) => (
<PhotoGridItem
asset={data}
isSelected={selectedIds.has(data.id)}
onToggleSelect={onToggleSelect}
onOpenAsset={onOpenAsset}
onDeleteAsset={onDeleteAsset}
isDeleting={isDeleting}
/>
)}
/>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-end gap-2 text-xs absolute lg:translate-y-[-50px] -translate-y-10 -translate-x-2 lg:translate-x-0 lg:right-[calc((100vw-var(--container-7xl))/2+0.75rem+100px)]">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="hover:bg-background-secondary/70 flex items-center gap-1.5 rounded-full border px-3 h-8 text-text"
>
<DynamicIcon name={currentSortBy.icon as any} className="size-4" />
<span className="font-medium">{currentSortBy.label}</span>
<DynamicIcon name="chevron-down" className="h-3 w-3 text-text-tertiary" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
{SORT_BY_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
active={option.value === sortBy}
icon={<DynamicIcon name={option.icon as any} className="size-4" />}
onSelect={() => setSortBy(option.value)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="hover:bg-background-secondary/70 flex items-center gap-1.5 rounded-full border px-3 h-8 text-text"
>
<DynamicIcon name={currentSortOrder.icon as any} className="size-4" />
<span className="font-medium">{currentSortOrder.label}</span>
<DynamicIcon name="chevron-down" className="h-3 w-3 text-text-tertiary" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
{SORT_ORDER_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
active={option.value === sortOrder}
icon={<DynamicIcon name={option.icon as any} className="size-4" />}
onSelect={() => setSortOrder(option.value)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{content}
</div>
)
}

View File

@@ -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<void>
availableTags: string[]
onUpload: (files: FileList, options: PhotoUploadRequestOptions) => void | Promise<void>
}
export const PhotoUploadConfirmModal: ModalComponent<PhotoUploadConfirmModalProps> = ({
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<number, File> & { 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 (
<div className="flex max-h-[80vh] w-full flex-col gap-5">
<div className="space-y-2">
<h2 className="text-text text-lg font-semibold"></h2>
<p className="text-text-tertiary text-sm">
{fileItems.length} {formatBytes(totalSize)}
</p>
{hasUnmatchedMov ? (
<div className="rounded-lg border border-rose-400/40 bg-rose-500/5 px-3 py-2 text-xs text-rose-300">
<p> MOV </p>
<ul className="mt-1 space-y-1">
{unmatchedMovFiles.map((file) => (
<li key={`${file.name}-${file.lastModified}`}>{file.name}</li>
))}
</ul>
</div>
) : (
hasMovFile && (
<p className="text-text-tertiary text-xs"> MOV Live Photo </p>
)
)}
<PhotoUploadStoreProvider files={files} availableTags={availableTags} onUpload={onUpload} onClose={dismiss}>
<div className="flex max-h-[80vh] w-full flex-col gap-5">
<PhotoUploadSteps />
</div>
<LinearDivider />
<div className="overflow-hidden">
<div className="border-fill-tertiary/40 bg-background/30 rounded-lg border">
<m.ul
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="divide-fill-tertiary/40 max-h-60 divide-y overflow-auto"
>
{fileItems.map((file) => {
const isUnmatchedMov = unmatchedMovFiles.includes(file)
return (
<li
key={`${file.name}-${file.lastModified}`}
className={clsxm(
'text-text-secondary flex items-center justify-between gap-3 px-4 py-2 text-sm',
isUnmatchedMov && 'text-rose-300',
)}
>
<span className="truncate" title={file.name}>
{file.name}
</span>
<div className="flex shrink-0 items-center gap-3">
{isUnmatchedMov ? <span className="text-[11px]"></span> : null}
<span className="text-text-tertiary text-xs">{formatBytes(file.size)}</span>
</div>
</li>
)
})}
</m.ul>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={dismiss}
className="text-text-secondary hover:text-text"
>
</Button>
<Button type="button" variant="primary" size="sm" onClick={handleConfirm} disabled={hasUnmatchedMov}>
</Button>
</div>
</div>
</PhotoUploadStoreProvider>
)
}
PhotoUploadConfirmModal.contentClassName = 'w-[min(420px,90vw)] p-6'
PhotoUploadConfirmModal.contentClassName = 'w-[min(520px,92vw)] p-6'

View File

@@ -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<HTMLInputElement>) => {
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 (
<div className="space-y-3">
<LinearBorderPanel
className={clsxm(
'bg-background px-3 py-2 transition-opacity duration-200',
disabled && 'pointer-events-none opacity-60',
)}
>
<div className="flex flex-wrap gap-1.5">
{value.map((tag) => (
<button
key={tag}
type="button"
onClick={() => handleRemove(tag)}
className="bg-accent/10 text-accent hover:bg-accent/20 flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px]"
>
{tag}
<span className="text-[10px] opacity-80">×</span>
</button>
))}
<div className="min-w-[160px] flex-1">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="border-none px-1 py-0 text-xs focus:ring-0"
disabled={disabled}
/>
</div>
</div>
</LinearBorderPanel>
{(filteredOptions.length > 0 || showCreateOption) && !disabled ? (
<LinearBorderPanel className="bg-background-tertiary/70 px-3 py-2">
<p className="text-text-tertiary mb-1 text-[11px]"></p>
<div className="flex flex-wrap gap-1">
{filteredOptions.map((option) => (
<Button
key={option.value}
type="button"
size="xs"
variant="ghost"
className="border-border/40 text-text-secondary hover:text-text rounded-full border px-2 py-0.5 text-[11px]"
onClick={() => handleAddTag(option.label)}
>
{option.label}
</Button>
))}
{showCreateOption ? (
<Button
type="button"
size="xs"
variant="ghost"
className="border-dashed border-border/50 text-accent rounded-full border px-2 py-0.5 text-[11px]"
onClick={() => handleAddTag(query)}
>
{query.trim()}
</Button>
) : null}
</div>
</LinearBorderPanel>
) : null}
</div>
)
}

View File

@@ -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 (
<LinearBorderPanel className="bg-background/40 px-3 py-4 text-center text-xs text-text-tertiary">
...
</LinearBorderPanel>
)
}
return (
<div className="space-y-3">
<div className="space-y-2">
{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 (
<LinearBorderPanel key={stage} className="bg-fill/10 px-3 py-2">
<div className="flex items-center justify-between text-xs">
<span className="text-text font-medium">{config.label}</span>
<span className="text-text-tertiary">
{stageState.status === 'completed'
? '已完成'
: stageState.total === 0
? '无需处理'
: `${stageState.processed} / ${stageState.total}`}
</span>
</div>
<div className="bg-fill/20 mt-2 h-1.5 rounded-full">
<div className="bg-accent h-full rounded-full" style={{ width: `${ratio * 100}%` }} />
</div>
<p className="text-text-tertiary mt-1 text-[11px]">{config.description}</p>
</LinearBorderPanel>
)
})}
</div>
<div className="grid grid-cols-2 gap-2">
{SUMMARY_FIELDS.map((field) => (
<LinearBorderPanel key={field.key} className="bg-background/40 px-3 py-2 text-xs">
<p className="text-text-tertiary uppercase tracking-wide">{field.label}</p>
<p className="text-text mt-1 text-lg font-semibold">{state.summary[field.key]}</p>
</LinearBorderPanel>
))}
</div>
{state.latestLog ? (
<LinearBorderPanel className="bg-background/50 px-3 py-2 text-[11px] text-text-tertiary">
<span className="font-medium text-text"></span>
<span className="ml-1">{state.latestLog.message}</span>
</LinearBorderPanel>
) : null}
</div>
)
}

View File

@@ -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 (
<div>
<div className="flex items-center justify-between text-xs text-text-tertiary">
<span></span>
<span>{Math.round(overallProgress * 100)}%</span>
</div>
<div className="bg-fill/20 mt-2 h-2 rounded-full">
<m.div
className="bg-accent h-full rounded-full"
initial={{ width: 0 }}
animate={{ width: `${overallProgress * 100}%` }}
transition={Spring.presets.smooth}
/>
</div>
<LinearBorderPanel className="bg-background/60 mt-4 max-h-60 overflow-auto">
<m.ul
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="divide-fill-tertiary/30"
>
{entries.map((entry) => (
<li
key={`${entry.name}-${entry.index}`}
className="text-text-secondary flex flex-col gap-2 px-4 py-2 text-sm"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<span className="truncate" title={entry.name}>
{entry.name}
</span>
<p className="text-text-tertiary text-[11px]">{formatBytes(entry.size)}</p>
</div>
<span className={`${FILE_STATUS_CLASS[entry.status]} text-[11px] font-medium`}>
{FILE_STATUS_LABEL[entry.status]}
</span>
</div>
<div className="bg-fill/20 h-1.5 rounded-full">
<div
className={
entry.status === 'done'
? 'bg-emerald-400 h-full rounded-full'
: entry.status === 'error'
? 'bg-rose-400 h-full rounded-full'
: entry.status === 'processing'
? 'bg-amber-300 h-full rounded-full'
: 'bg-accent h-full rounded-full'
}
style={{ width: `${Math.min(100, entry.progress * 100)}%` }}
/>
</div>
</li>
))}
</m.ul>
</LinearBorderPanel>
</div>
)
}

View File

@@ -0,0 +1,68 @@
import type { PhotoSyncProgressStage, PhotoSyncResultSummary } from '../../../types'
import type { WorkflowPhase } from './types'
export const WORKFLOW_STEP_LABEL: Record<Exclude<WorkflowPhase, 'error'>, string> = {
review: '校验文件',
uploading: '上传中',
processing: '服务器处理',
completed: '完成',
}
export const DISPLAY_STEPS: Array<Exclude<WorkflowPhase, 'error'>> = ['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

View File

@@ -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 (
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-text text-lg font-semibold"></h2>
<p className="text-text-tertiary text-sm"></p>
</div>
<UploadFileList entries={uploadEntries} overallProgress={progress} />
<div className="flex items-center justify-end">
<Button type="button" variant="primary" size="sm" onClick={closeModal}>
</Button>
</div>
</div>
)
}

View File

@@ -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 (
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-text text-lg font-semibold"></h2>
<p className="text-text-tertiary text-sm"></p>
</div>
<LinearBorderPanel className="border border-rose-400/40 bg-rose-500/5 px-3 py-2 text-xs text-rose-200">
{errorMessage}
</LinearBorderPanel>
<UploadFileList entries={uploadEntries} overallProgress={progress} />
<ProcessingPanel state={processingState} />
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={closeModal}
className="text-text-secondary hover:text-text"
>
</Button>
<Button type="button" variant="primary" size="sm" onClick={reset}>
</Button>
</div>
</div>
)
}

View File

@@ -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 (
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-text text-lg font-semibold"></h2>
<p className="text-text-tertiary text-sm"></p>
</div>
<UploadFileList entries={uploadEntries} overallProgress={progress} />
<ProcessingPanel state={processingState} />
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={abortCurrent}
className="text-rose-300 hover:text-rose-200"
>
</Button>
<Button type="button" variant="primary" size="sm" disabled isLoading>
...
</Button>
</div>
</div>
)
}

View File

@@ -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 (
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-text text-lg font-semibold"></h2>
<p className="text-text-tertiary text-sm">
{filesCount} {formatBytes(totalSize)}
</p>
</div>
{hasUnmatched ? (
<LinearBorderPanel className="border border-rose-400/40 bg-rose-500/5 px-3 py-2 text-xs text-rose-300">
<p> MOV </p>
<ul className="mt-1 space-y-1">
{unmatchedMovFiles.map((file) => (
<li key={`${file.name}-${file.lastModified}`}>{file.name}</li>
))}
</ul>
</LinearBorderPanel>
) : hasMovFile ? (
<p className="text-text-tertiary text-xs"> MOV Live Photo </p>
) : null}
<AutoSelect
options={tagOptions}
value={selectedTags}
onChange={setSelectedTags}
placeholder="输入后按 Enter 添加,或从建议中选择"
/>
<UploadFileList entries={uploadEntries} overallProgress={progress} />
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={closeModal}
className="text-text-secondary hover:text-text"
>
</Button>
<Button type="button" variant="primary" size="sm" onClick={() => void beginUpload()} disabled={hasUnmatched}>
</Button>
</div>
</div>
)
}

View File

@@ -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 (
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-text text-lg font-semibold"></h2>
<p className="text-text-tertiary text-sm"></p>
</div>
<UploadFileList entries={uploadEntries} overallProgress={progress} />
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={abortCurrent}
className="text-rose-300 hover:text-rose-200"
>
</Button>
<Button type="button" variant="primary" size="sm" disabled isLoading>
...
</Button>
</div>
</div>
)
}

View File

@@ -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<WorkflowPhase, () => 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 <StepComponent />
}

View File

@@ -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<void>
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<void>
onClose: () => void
}
export type PhotoUploadStore = StoreApi<PhotoUploadStoreState>
const PhotoUploadStoreContext = createContext<PhotoUploadStore | null>(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<PhotoUploadStoreState>((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<PhotoUploadRequestOptions['onUploadProgress']> = (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 <PhotoUploadStoreContext value={store}>{children}</PhotoUploadStoreContext>
}
export function usePhotoUploadStore<U>(selector: (state: PhotoUploadStoreState) => U) {
const store = use(PhotoUploadStoreContext)
if (!store) {
throw new Error('usePhotoUploadStore must be used within PhotoUploadStoreProvider')
}
return useStore(store, selector)
}

View File

@@ -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<PhotoSyncProgressStage, ProcessingStageState>
completed: boolean
latestLog?: ProcessingLatestLog
error?: string
}

View File

@@ -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<PhotoSyncProgressStage, ProcessingStageState> {
return STAGE_ORDER.reduce<Record<PhotoSyncProgressStage, ProcessingStageState>>(
(acc, stage) => {
const total = totals[stage]
acc[stage] = {
status: total === 0 ? 'completed' : 'pending',
processed: 0,
total,
}
return acc
},
{} as Record<PhotoSyncProgressStage, ProcessingStageState>,
)
}
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<number, File> & { 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)
}

View File

@@ -0,0 +1,7 @@
import type { PhotoUploadProgressSnapshot } from '../../api'
export type PhotoUploadRequestOptions = {
signal?: AbortSignal
onUploadProgress?: (snapshot: PhotoUploadProgressSnapshot) => void
directory?: string | null
}

View File

@@ -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({

3
pnpm-lock.yaml generated
View File

@@ -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