mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
feat(data-sync): enhance error handling in data synchronization process
- Added an 'errors' field to the DataSyncResultSummary and updated related types to track synchronization errors. - Modified the DataSyncService to categorize certain failures as errors instead of conflicts. - Updated UI components to display error counts in synchronization summaries and progress panels, improving user feedback during data sync operations. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -322,6 +322,7 @@ export class DataSyncService {
|
||||
deleted: 0,
|
||||
conflicts: 0,
|
||||
skipped: 0,
|
||||
errors: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,9 +379,9 @@ export class DataSyncService {
|
||||
})
|
||||
|
||||
if (!result?.item) {
|
||||
summary.conflicts += 1
|
||||
summary.errors += 1
|
||||
const action: DataSyncAction = {
|
||||
type: 'conflict',
|
||||
type: 'error',
|
||||
storageKey: storageObject.key,
|
||||
photoId: null,
|
||||
applied: false,
|
||||
|
||||
@@ -6,7 +6,7 @@ export enum ConflictResolutionStrategy {
|
||||
PREFER_DATABASE = 'prefer-database',
|
||||
}
|
||||
|
||||
export type DataSyncActionType = 'insert' | 'update' | 'delete' | 'conflict' | 'noop'
|
||||
export type DataSyncActionType = 'insert' | 'update' | 'delete' | 'conflict' | 'noop' | 'error'
|
||||
|
||||
export interface SyncObjectSnapshot {
|
||||
size: number | null
|
||||
@@ -47,6 +47,7 @@ export interface DataSyncResultSummary {
|
||||
deleted: number
|
||||
conflicts: number
|
||||
skipped: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
export interface DataSyncResult {
|
||||
|
||||
@@ -8,7 +8,7 @@ type DeleteFromStorageOptionProps = {
|
||||
|
||||
export function DeleteFromStorageOption({ defaultChecked = false, disabled, onChange }: DeleteFromStorageOptionProps) {
|
||||
return (
|
||||
<label className="flex w-full items-start gap-3 rounded-xl border border-border/50 bg-background-secondary/40 px-3 py-2 text-left text-text">
|
||||
<label className="flex w-full items-start gap-3 my-2 text-left text-text">
|
||||
<Checkbox
|
||||
size="md"
|
||||
defaultChecked={defaultChecked}
|
||||
|
||||
@@ -47,9 +47,9 @@ export function PhotoSyncActions({ onCompleted, onProgress, onError }: PhotoSync
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
onCompleted(data, { dryRun: variables.dryRun ?? false })
|
||||
const { inserted, updated, conflicts } = data.summary
|
||||
const { inserted, updated, conflicts, errors } = data.summary
|
||||
toast.success(variables.dryRun ? '同步预览完成' : '照片同步完成', {
|
||||
description: `新增 ${inserted} · 更新 ${updated} · 冲突 ${conflicts}`,
|
||||
description: `新增 ${inserted} · 更新 ${updated} · 冲突 ${conflicts} · 错误 ${errors}`,
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
@@ -51,6 +51,7 @@ const SUMMARY_FIELDS: Array<{
|
||||
{ key: 'inserted', label: '新增' },
|
||||
{ key: 'updated', label: '更新' },
|
||||
{ key: 'conflicts', label: '冲突' },
|
||||
{ key: 'errors', label: '错误' },
|
||||
]
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
@@ -160,7 +161,7 @@ export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-3">
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{summaryItems.map((item) => (
|
||||
<div key={item.label} className="bg-background-secondary/60 border-border/20 rounded-lg border p-4">
|
||||
<p className="text-text-tertiary text-xs tracking-wide uppercase">{item.label}</p>
|
||||
|
||||
@@ -78,6 +78,11 @@ export function PhotoSyncResultPanel({
|
||||
value: result.summary.conflicts,
|
||||
tone: result.summary.conflicts > 0 ? ('warning' as const) : ('muted' as const),
|
||||
},
|
||||
{
|
||||
label: '错误条目',
|
||||
value: result.summary.errors,
|
||||
tone: result.summary.errors > 0 ? ('warning' as const) : ('muted' as const),
|
||||
},
|
||||
{
|
||||
label: '跳过条目',
|
||||
value: result.summary.skipped,
|
||||
@@ -115,6 +120,7 @@ export function PhotoSyncResultPanel({
|
||||
update: 0,
|
||||
delete: 0,
|
||||
conflict: 0,
|
||||
error: 0,
|
||||
noop: 0,
|
||||
}
|
||||
|
||||
@@ -385,7 +391,7 @@ export function PhotoSyncResultPanel({
|
||||
delay: index * 0.03,
|
||||
}}
|
||||
>
|
||||
<div className="border-border/20 bg-fill/10 relative overflow-hidden rounded-lg">
|
||||
<div className="border-border/20 mt-4 bg-fill/10 relative overflow-hidden rounded-lg">
|
||||
<BorderOverlay />
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
|
||||
@@ -27,5 +27,6 @@ export const PHOTO_ACTION_TYPE_CONFIG: Record<PhotoSyncAction['type'], { label:
|
||||
update: { label: '更新', badgeClass: 'bg-sky-500/10 text-sky-400' },
|
||||
delete: { label: '删除', badgeClass: 'bg-rose-500/10 text-rose-400' },
|
||||
conflict: { label: '冲突', badgeClass: 'bg-amber-500/10 text-amber-400' },
|
||||
error: { label: '错误', badgeClass: 'bg-rose-500/20 text-rose-200' },
|
||||
noop: { label: '跳过', badgeClass: 'bg-slate-500/10 text-slate-400' },
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PhotoManifestItem } from '@afilmory/builder'
|
||||
|
||||
export type PhotoSyncActionType = 'insert' | 'update' | 'delete' | 'conflict' | 'noop'
|
||||
export type PhotoSyncActionType = 'insert' | 'update' | 'delete' | 'conflict' | 'noop' | 'error'
|
||||
|
||||
export type PhotoSyncResolution = 'prefer-storage' | 'prefer-database' | undefined
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface PhotoSyncResultSummary {
|
||||
deleted: number
|
||||
conflicts: number
|
||||
skipped: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
export interface PhotoSyncResult {
|
||||
|
||||
@@ -81,6 +81,38 @@ export function encodeS3Key(key: string): string {
|
||||
.join('/')
|
||||
}
|
||||
|
||||
function canonicalizePath(pathname: string): string {
|
||||
if (!pathname || pathname === '/') {
|
||||
return '/'
|
||||
}
|
||||
|
||||
const segments = pathname.split('/').map((segment) => encodeRfc3986(safeDecodeURIComponent(segment)))
|
||||
let canonical = segments.join('/')
|
||||
|
||||
if (!canonical.startsWith('/')) {
|
||||
canonical = `/${canonical}`
|
||||
}
|
||||
|
||||
if (pathname.endsWith('/') && !canonical.endsWith('/')) {
|
||||
canonical = `${canonical}/`
|
||||
}
|
||||
|
||||
return canonical || '/'
|
||||
}
|
||||
|
||||
function safeDecodeURIComponent(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function encodeRfc3986(value: string): string {
|
||||
// eslint-disable-next-line unicorn/prefer-code-point
|
||||
return encodeURIComponent(value).replaceAll(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
|
||||
}
|
||||
|
||||
const EMPTY_HASH = crypto.createHash('sha256').update('').digest('hex')
|
||||
|
||||
class SigV4Signer {
|
||||
@@ -142,7 +174,7 @@ class SigV4Signer {
|
||||
}
|
||||
|
||||
private buildCanonicalRequest(method: string, url: URL, headers: Headers, payloadHash: string): string {
|
||||
const canonicalUri = encodeURI(url.pathname).replaceAll('%2F', '/')
|
||||
const canonicalUri = canonicalizePath(url.pathname)
|
||||
const canonicalQuery = buildCanonicalQuery(url.searchParams)
|
||||
const canonicalHeaders = buildCanonicalHeaders(headers)
|
||||
const signedHeaders = this.getSignedHeaders(headers)
|
||||
|
||||
@@ -192,6 +192,7 @@ export class S3StorageProvider implements StorageProvider {
|
||||
if (!response.ok || !response.body) {
|
||||
const bodyText = await response.text().catch(() => '')
|
||||
logger.s3.error(`S3 响应异常:${key} (status ${response.status}) ${formatS3ErrorBody(bodyText)}`)
|
||||
logger.s3.error(bodyText)
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user