mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { PhotoUploadProgressSnapshot } from '../../api'
|
||||
|
||||
export type PhotoUploadRequestOptions = {
|
||||
signal?: AbortSignal
|
||||
onUploadProgress?: (snapshot: PhotoUploadProgressSnapshot) => void
|
||||
directory?: string | null
|
||||
}
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user