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( (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(normalizedInitialTab) const [result, setResult] = useState(null) const [lastWasDryRun, setLastWasDryRun] = useState(null) const [selectedIds, setSelectedIds] = useState([]) const [resolvingConflictId, setResolvingConflictId] = useState(null) const [syncProgress, setSyncProgress] = useState(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 = break } case 'sync': { let progressPanel: ReactNode | null = null if (syncProgress) { progressPanel = } let conflictsPanel: ReactNode | null = null if (showConflictsPanel) { conflictsPanel = ( ) } tabContent = ( <> {progressPanel}
{conflictsPanel}
) break } case 'library': { tabContent = ( ) break } default: { tabContent = null } } return (
handleTabChange(id as PhotoPageTab)} items={[ { id: 'sync', label: '同步结果' }, { id: 'library', label: '图库管理' }, { id: 'storage', label: '素材存储' }, ]} /> {tabContent}
) }