mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
- Eliminated checks for `typeof window !== 'undefined'` in various components and utility functions, simplifying the codebase. - Updated logic to directly access `window` properties, assuming the code runs in a browser environment. - Improved readability and maintainability by streamlining conditional checks related to window availability. Signed-off-by: Innei <tukon479@gmail.com>
512 lines
15 KiB
TypeScript
512 lines
15 KiB
TypeScript
import type { ReactNode } from 'react'
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { useSearchParams } from 'react-router'
|
|
import { toast } from 'sonner'
|
|
|
|
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
|
import { PageTabs } from '~/components/navigation/PageTabs'
|
|
import { getRequestErrorMessage } from '~/lib/errors'
|
|
import { StorageProvidersManager } from '~/modules/storage-providers'
|
|
|
|
import { getPhotoStorageUrl } from '../api'
|
|
import {
|
|
useDeletePhotoAssetsMutation,
|
|
usePhotoAssetListQuery,
|
|
usePhotoAssetSummaryQuery,
|
|
usePhotoSyncConflictsQuery,
|
|
useResolvePhotoSyncConflictMutation,
|
|
useUploadPhotoAssetsMutation,
|
|
} from '../hooks'
|
|
import type {
|
|
PhotoAssetListItem,
|
|
PhotoSyncConflict,
|
|
PhotoSyncProgressEvent,
|
|
PhotoSyncProgressStage,
|
|
PhotoSyncProgressState,
|
|
PhotoSyncResolution,
|
|
PhotoSyncResult,
|
|
} from '../types'
|
|
import { PhotoLibraryGrid } from './library/PhotoLibraryGrid'
|
|
import { PhotoPageActions } from './PhotoPageActions'
|
|
import { PhotoSyncConflictsPanel } from './sync/PhotoSyncConflictsPanel'
|
|
import { PhotoSyncProgressPanel } from './sync/PhotoSyncProgressPanel'
|
|
import { PhotoSyncResultPanel } from './sync/PhotoSyncResultPanel'
|
|
|
|
export type PhotoPageTab = 'sync' | 'library' | 'storage'
|
|
|
|
const BATCH_RESOLVING_ID = '__batch__'
|
|
|
|
const STAGE_ORDER: PhotoSyncProgressStage[] = [
|
|
'missing-in-db',
|
|
'orphan-in-db',
|
|
'metadata-conflicts',
|
|
'status-reconciliation',
|
|
]
|
|
|
|
const MAX_SYNC_LOGS = 200
|
|
|
|
function createInitialStages(totals: PhotoSyncProgressState['totals']): PhotoSyncProgressState['stages'] {
|
|
return STAGE_ORDER.reduce<PhotoSyncProgressState['stages']>(
|
|
(acc, stage) => {
|
|
const total = totals[stage]
|
|
acc[stage] = {
|
|
status: total === 0 ? 'completed' : 'pending',
|
|
processed: 0,
|
|
total,
|
|
}
|
|
return acc
|
|
},
|
|
{} as PhotoSyncProgressState['stages'],
|
|
)
|
|
}
|
|
|
|
export function PhotoPage() {
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const initialTabParam = searchParams.get('tab')
|
|
const normalizedInitialTab: PhotoPageTab =
|
|
initialTabParam === 'library' || initialTabParam === 'storage' ? (initialTabParam as PhotoPageTab) : 'sync'
|
|
const [activeTab, setActiveTab] = useState<PhotoPageTab>(normalizedInitialTab)
|
|
const [result, setResult] = useState<PhotoSyncResult | null>(null)
|
|
const [lastWasDryRun, setLastWasDryRun] = useState<boolean | null>(null)
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
|
const [resolvingConflictId, setResolvingConflictId] = useState<string | null>(null)
|
|
const [syncProgress, setSyncProgress] = useState<PhotoSyncProgressState | null>(null)
|
|
|
|
useEffect(() => {
|
|
setActiveTab(normalizedInitialTab)
|
|
}, [normalizedInitialTab])
|
|
|
|
const summaryQuery = usePhotoAssetSummaryQuery()
|
|
const listQuery = usePhotoAssetListQuery({ enabled: activeTab === 'library' })
|
|
const deleteMutation = useDeletePhotoAssetsMutation()
|
|
const uploadMutation = useUploadPhotoAssetsMutation()
|
|
const conflictsQuery = usePhotoSyncConflictsQuery({
|
|
enabled: activeTab === 'sync',
|
|
})
|
|
const resolveConflictMutation = useResolvePhotoSyncConflictMutation()
|
|
|
|
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
|
const isListLoading = listQuery.isLoading || listQuery.isFetching
|
|
const libraryAssetCount = listQuery.data?.length ?? 0
|
|
|
|
const handleToggleSelect = (id: string) => {
|
|
setSelectedIds((prev) => {
|
|
if (prev.includes(id)) {
|
|
return prev.filter((item) => item !== id)
|
|
}
|
|
return [...prev, id]
|
|
})
|
|
}
|
|
|
|
const handleClearSelection = () => {
|
|
setSelectedIds([])
|
|
}
|
|
|
|
const handleProgressEvent = useCallback(
|
|
(event: PhotoSyncProgressEvent) => {
|
|
if (event.type === 'start') {
|
|
const { summary, totals, options } = event.payload
|
|
setSyncProgress({
|
|
dryRun: options.dryRun,
|
|
summary,
|
|
totals,
|
|
stages: createInitialStages(totals),
|
|
startedAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
logs: [],
|
|
lastAction: undefined,
|
|
error: undefined,
|
|
})
|
|
setLastWasDryRun(options.dryRun)
|
|
return
|
|
}
|
|
|
|
if (event.type === 'complete') {
|
|
setSyncProgress(null)
|
|
return
|
|
}
|
|
|
|
if (event.type === 'error') {
|
|
setSyncProgress((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
error: event.payload.message,
|
|
updatedAt: Date.now(),
|
|
}
|
|
: prev,
|
|
)
|
|
return
|
|
}
|
|
|
|
if (event.type === 'log') {
|
|
setSyncProgress((prev) => {
|
|
if (!prev) {
|
|
return prev
|
|
}
|
|
|
|
const parsedTimestamp = Date.parse(event.payload.timestamp)
|
|
const entry = {
|
|
id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
timestamp: Number.isNaN(parsedTimestamp) ? Date.now() : parsedTimestamp,
|
|
level: event.payload.level,
|
|
message: event.payload.message,
|
|
stage: event.payload.stage ?? null,
|
|
storageKey: event.payload.storageKey ?? undefined,
|
|
details: event.payload.details ?? null,
|
|
}
|
|
|
|
const nextLogs = prev.logs.length >= MAX_SYNC_LOGS ? [...prev.logs.slice(1), entry] : [...prev.logs, entry]
|
|
|
|
return {
|
|
...prev,
|
|
logs: nextLogs,
|
|
updatedAt: Date.now(),
|
|
}
|
|
})
|
|
return
|
|
}
|
|
|
|
if (event.type === 'stage') {
|
|
setSyncProgress((prev) => {
|
|
if (!prev) {
|
|
return prev
|
|
}
|
|
|
|
const { stage, status, processed, total, summary } = event.payload
|
|
const nextStages = {
|
|
...prev.stages,
|
|
[stage]: {
|
|
status: status === 'complete' ? 'completed' : total === 0 ? 'completed' : 'running',
|
|
processed,
|
|
total,
|
|
},
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
summary,
|
|
stages: nextStages,
|
|
updatedAt: Date.now(),
|
|
}
|
|
})
|
|
return
|
|
}
|
|
|
|
if (event.type === 'action') {
|
|
setSyncProgress((prev) => {
|
|
if (!prev) {
|
|
return prev
|
|
}
|
|
|
|
const { stage, index, total, action, summary } = event.payload
|
|
const nextStages = {
|
|
...prev.stages,
|
|
[stage]: {
|
|
status: total === 0 ? 'completed' : 'running',
|
|
processed: index,
|
|
total,
|
|
},
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
summary,
|
|
stages: nextStages,
|
|
lastAction: {
|
|
stage,
|
|
index,
|
|
total,
|
|
action,
|
|
},
|
|
updatedAt: Date.now(),
|
|
}
|
|
})
|
|
}
|
|
},
|
|
[setResult, setLastWasDryRun],
|
|
)
|
|
|
|
const handleSyncError = useCallback((error: Error) => {
|
|
setSyncProgress((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
error: error.message,
|
|
updatedAt: Date.now(),
|
|
}
|
|
: prev,
|
|
)
|
|
}, [])
|
|
|
|
const handleDeleteAssets = useCallback(
|
|
async (ids: string[]) => {
|
|
if (ids.length === 0) return
|
|
try {
|
|
await deleteMutation.mutateAsync(ids)
|
|
toast.success(`已删除 ${ids.length} 个资源`)
|
|
setSelectedIds((prev) => prev.filter((item) => !ids.includes(item)))
|
|
void listQuery.refetch()
|
|
} catch (error) {
|
|
const message = getRequestErrorMessage(error, '删除失败,请稍后重试。')
|
|
toast.error('删除失败', { description: message })
|
|
}
|
|
},
|
|
[deleteMutation, listQuery, setSelectedIds],
|
|
)
|
|
|
|
const handleUploadAssets = useCallback(
|
|
async (files: FileList) => {
|
|
const fileArray = Array.from(files)
|
|
if (fileArray.length === 0) return
|
|
try {
|
|
await uploadMutation.mutateAsync(fileArray)
|
|
toast.success(`成功上传 ${fileArray.length} 张图片`)
|
|
void listQuery.refetch()
|
|
} catch (error) {
|
|
const message = getRequestErrorMessage(error, '上传失败,请稍后重试。')
|
|
toast.error('上传失败', { description: message })
|
|
}
|
|
},
|
|
[listQuery, uploadMutation],
|
|
)
|
|
|
|
const handleSyncCompleted = useCallback(
|
|
(data: PhotoSyncResult, context: { dryRun: boolean }) => {
|
|
setResult(data)
|
|
setLastWasDryRun(context.dryRun)
|
|
setSyncProgress(null)
|
|
|
|
void summaryQuery.refetch()
|
|
void listQuery.refetch()
|
|
},
|
|
[listQuery, summaryQuery],
|
|
)
|
|
|
|
const handleDeleteSelected = useCallback(() => {
|
|
void handleDeleteAssets(selectedIds)
|
|
}, [handleDeleteAssets, selectedIds])
|
|
|
|
const handleSelectAll = useCallback(() => {
|
|
if (!listQuery.data || listQuery.data.length === 0) {
|
|
return
|
|
}
|
|
|
|
setSelectedIds(listQuery.data.map((asset) => asset.id))
|
|
}, [listQuery.data])
|
|
|
|
const handleDeleteSingle = useCallback(
|
|
(asset: PhotoAssetListItem) => {
|
|
void handleDeleteAssets([asset.id])
|
|
},
|
|
[handleDeleteAssets],
|
|
)
|
|
|
|
const handleResolveConflict = useCallback(
|
|
async (conflict: PhotoSyncConflict, strategy: PhotoSyncResolution) => {
|
|
if (!strategy) {
|
|
return
|
|
}
|
|
setResolvingConflictId(conflict.id)
|
|
try {
|
|
const action = await resolveConflictMutation.mutateAsync({
|
|
id: conflict.id,
|
|
strategy,
|
|
})
|
|
toast.success('冲突已处理', {
|
|
description:
|
|
action.reason ??
|
|
(strategy === 'prefer-storage' ? '已以存储数据覆盖数据库记录。' : '已保留数据库记录并忽略存储差异。'),
|
|
})
|
|
void conflictsQuery.refetch()
|
|
void summaryQuery.refetch()
|
|
void listQuery.refetch()
|
|
} catch (error) {
|
|
const message = getRequestErrorMessage(error, '处理冲突失败,请稍后重试。')
|
|
toast.error('处理冲突失败', { description: message })
|
|
} finally {
|
|
setResolvingConflictId(null)
|
|
}
|
|
},
|
|
[conflictsQuery, listQuery, resolveConflictMutation, summaryQuery],
|
|
)
|
|
|
|
const handleResolveConflictsBatch = useCallback(
|
|
async (conflicts: PhotoSyncConflict[], strategy: PhotoSyncResolution) => {
|
|
if (!strategy || conflicts.length === 0) {
|
|
toast.info('请选择至少一个冲突条目')
|
|
return
|
|
}
|
|
|
|
setResolvingConflictId(BATCH_RESOLVING_ID)
|
|
let processed = 0
|
|
const errors: string[] = []
|
|
|
|
try {
|
|
for (const conflict of conflicts) {
|
|
try {
|
|
await resolveConflictMutation.mutateAsync({
|
|
id: conflict.id,
|
|
strategy,
|
|
})
|
|
processed += 1
|
|
} catch (error) {
|
|
errors.push(getRequestErrorMessage(error, '处理冲突失败,请稍后重试。'))
|
|
}
|
|
}
|
|
} finally {
|
|
setResolvingConflictId(null)
|
|
}
|
|
|
|
if (processed > 0) {
|
|
toast.success(`${strategy === 'prefer-storage' ? '以存储为准' : '以数据库为准'}处理 ${processed} 个冲突`)
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
toast.error('部分冲突处理失败', {
|
|
description: errors[0],
|
|
})
|
|
}
|
|
|
|
if (processed > 0 || errors.length > 0) {
|
|
void conflictsQuery.refetch()
|
|
void summaryQuery.refetch()
|
|
void listQuery.refetch()
|
|
}
|
|
},
|
|
[conflictsQuery, resolveConflictMutation, summaryQuery, listQuery],
|
|
)
|
|
|
|
const handleOpenAsset = async (asset: PhotoAssetListItem) => {
|
|
const manifest = asset.manifest?.data
|
|
const candidate = manifest?.originalUrl ?? manifest?.thumbnailUrl ?? asset.publicUrl
|
|
if (candidate) {
|
|
window.open(candidate, '_blank', 'noopener,noreferrer')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const url = await getPhotoStorageUrl(asset.storageKey)
|
|
window.open(url, '_blank', 'noopener,noreferrer')
|
|
} catch (error) {
|
|
const message = getRequestErrorMessage(error, '无法获取原图链接')
|
|
toast.error('打开失败', { description: message })
|
|
}
|
|
}
|
|
|
|
const handleTabChange = (tab: PhotoPageTab) => {
|
|
setActiveTab(tab)
|
|
const next = new URLSearchParams(searchParams.toString())
|
|
if (tab === 'sync') {
|
|
next.delete('tab')
|
|
} else {
|
|
next.set('tab', tab)
|
|
}
|
|
setSearchParams(next, { replace: true })
|
|
|
|
if (tab !== 'library') {
|
|
setSelectedIds([])
|
|
}
|
|
}
|
|
|
|
const showConflictsPanel =
|
|
conflictsQuery.isLoading || conflictsQuery.isFetching || (conflictsQuery.data?.length ?? 0) > 0
|
|
|
|
let tabContent: ReactNode | null = null
|
|
|
|
switch (activeTab) {
|
|
case 'storage': {
|
|
tabContent = <StorageProvidersManager />
|
|
break
|
|
}
|
|
case 'sync': {
|
|
let progressPanel: ReactNode | null = null
|
|
if (syncProgress) {
|
|
progressPanel = <PhotoSyncProgressPanel progress={syncProgress} />
|
|
}
|
|
|
|
let conflictsPanel: ReactNode | null = null
|
|
if (showConflictsPanel) {
|
|
conflictsPanel = (
|
|
<PhotoSyncConflictsPanel
|
|
conflicts={conflictsQuery.data}
|
|
isLoading={conflictsQuery.isLoading || conflictsQuery.isFetching}
|
|
resolvingId={resolvingConflictId}
|
|
isBatchResolving={resolvingConflictId === BATCH_RESOLVING_ID}
|
|
onResolve={handleResolveConflict}
|
|
onResolveBatch={handleResolveConflictsBatch}
|
|
onRequestStorageUrl={getPhotoStorageUrl}
|
|
/>
|
|
)
|
|
}
|
|
|
|
tabContent = (
|
|
<>
|
|
{progressPanel}
|
|
<div className="space-y-6">
|
|
{conflictsPanel}
|
|
<PhotoSyncResultPanel
|
|
result={result}
|
|
lastWasDryRun={lastWasDryRun}
|
|
baselineSummary={summaryQuery.data}
|
|
isSummaryLoading={summaryQuery.isLoading}
|
|
onRequestStorageUrl={getPhotoStorageUrl}
|
|
/>
|
|
</div>
|
|
</>
|
|
)
|
|
break
|
|
}
|
|
case 'library': {
|
|
tabContent = (
|
|
<PhotoLibraryGrid
|
|
assets={listQuery.data}
|
|
isLoading={isListLoading}
|
|
selectedIds={selectedSet}
|
|
onToggleSelect={handleToggleSelect}
|
|
onOpenAsset={handleOpenAsset}
|
|
onDeleteAsset={handleDeleteSingle}
|
|
isDeleting={deleteMutation.isPending}
|
|
/>
|
|
)
|
|
break
|
|
}
|
|
default: {
|
|
tabContent = null
|
|
}
|
|
}
|
|
|
|
return (
|
|
<MainPageLayout title="照片库" description="在此同步和管理服务器中的照片资产。">
|
|
<PhotoPageActions
|
|
activeTab={activeTab}
|
|
selectionCount={selectedIds.length}
|
|
libraryTotalCount={libraryAssetCount}
|
|
isUploading={uploadMutation.isPending}
|
|
isDeleting={deleteMutation.isPending}
|
|
onUpload={handleUploadAssets}
|
|
onDeleteSelected={handleDeleteSelected}
|
|
onClearSelection={handleClearSelection}
|
|
onSelectAll={handleSelectAll}
|
|
onSyncCompleted={handleSyncCompleted}
|
|
onSyncProgress={handleProgressEvent}
|
|
onSyncError={handleSyncError}
|
|
/>
|
|
|
|
<div className="space-y-6">
|
|
<PageTabs
|
|
activeId={activeTab}
|
|
onSelect={(id) => handleTabChange(id as PhotoPageTab)}
|
|
items={[
|
|
{ id: 'sync', label: '同步结果' },
|
|
{ id: 'library', label: '图库管理' },
|
|
{ id: 'storage', label: '素材存储' },
|
|
]}
|
|
/>
|
|
|
|
{tabContent}
|
|
</div>
|
|
</MainPageLayout>
|
|
)
|
|
}
|