mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat(photo): enhance photo asset normalization and logging
- Updated photo asset service to correctly use regex in normalization functions, ensuring proper path formatting. - Introduced builder log relay functionality in the data sync controller to improve logging during synchronization tasks. - Added error handling improvements across various components, utilizing a centralized error message function for consistency. - Enhanced photo page actions and library action bar with new select all functionality for better user experience. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -814,7 +814,7 @@ export class PhotoAssetService {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalized = trimmed.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/, '')
|
||||
const normalized = trimmed.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '')
|
||||
return normalized.length > 0 ? normalized : null
|
||||
}
|
||||
|
||||
@@ -824,7 +824,7 @@ export class PhotoAssetService {
|
||||
if (!segment) {
|
||||
continue
|
||||
}
|
||||
filtered.push(segment.replaceAll(/^\/+|\/+$/, ''))
|
||||
filtered.push(segment.replaceAll(/^\/+|\/+$/g, ''))
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { format as utilFormat } from 'node:util'
|
||||
|
||||
import type { LogMessage } from '@afilmory/builder/logger/index.js'
|
||||
import { setLogListener } from '@afilmory/builder/logger/index.js'
|
||||
|
||||
import type { DataSyncLogLevel, DataSyncProgressEmitter } from './data-sync.types'
|
||||
|
||||
const LEVEL_MAP: Record<string, DataSyncLogLevel> = {
|
||||
log: 'info',
|
||||
info: 'info',
|
||||
start: 'info',
|
||||
success: 'success',
|
||||
warn: 'warn',
|
||||
error: 'error',
|
||||
fatal: 'error',
|
||||
debug: 'info',
|
||||
trace: 'info',
|
||||
}
|
||||
|
||||
export async function runWithBuilderLogRelay<T>(
|
||||
emitter: DataSyncProgressEmitter | undefined,
|
||||
task: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!emitter) {
|
||||
return await task()
|
||||
}
|
||||
|
||||
const listener = (message: LogMessage): void => {
|
||||
forwardBuilderLog(emitter, message)
|
||||
}
|
||||
|
||||
setLogListener(listener, { forwardToConsole: true })
|
||||
|
||||
try {
|
||||
return await task()
|
||||
} finally {
|
||||
setLogListener(null, { forwardToConsole: true })
|
||||
}
|
||||
}
|
||||
|
||||
function forwardBuilderLog(emitter: DataSyncProgressEmitter, message: LogMessage): void {
|
||||
const formatted = formatBuilderMessage(message)
|
||||
if (!formatted) {
|
||||
return
|
||||
}
|
||||
|
||||
const level = LEVEL_MAP[message.level] ?? 'info'
|
||||
|
||||
try {
|
||||
void emitter({
|
||||
type: 'log',
|
||||
payload: {
|
||||
level,
|
||||
message: formatted,
|
||||
timestamp: message.timestamp.toISOString(),
|
||||
stage: null,
|
||||
storageKey: undefined,
|
||||
details: {
|
||||
source: 'builder',
|
||||
tag: message.tag,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Relay should never break builder logging
|
||||
}
|
||||
}
|
||||
|
||||
function formatBuilderMessage(message: LogMessage): string {
|
||||
const prefix = message.tag ? `[${message.tag}] ` : ''
|
||||
|
||||
if (!message.args?.length) {
|
||||
return prefix.trim()
|
||||
}
|
||||
|
||||
try {
|
||||
return `${prefix}${utilFormat(...message.args)}`.trim()
|
||||
} catch {
|
||||
const fallback = message.args[0] ? String(message.args[0]) : ''
|
||||
return `${prefix}${fallback}`.trim()
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Body, ContextParam, Controller, createLogger, Get, Param, Post } from '
|
||||
import { Roles } from 'core/guards/roles.decorator'
|
||||
import type { Context } from 'hono'
|
||||
|
||||
import { runWithBuilderLogRelay } from './builder-log-relay'
|
||||
import type { ResolveConflictInput, RunDataSyncInput } from './data-sync.dto'
|
||||
import { ResolveConflictDto, RunDataSyncDto } from './data-sync.dto'
|
||||
import { DataSyncService } from './data-sync.service'
|
||||
@@ -97,13 +98,15 @@ export class DataSyncController {
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
await this.dataSyncService.runSync(
|
||||
{
|
||||
builderConfig: payload.builderConfig as BuilderConfig | undefined,
|
||||
storageConfig: payload.storageConfig as StorageConfig | undefined,
|
||||
dryRun: payload.dryRun ?? false,
|
||||
},
|
||||
progressHandler,
|
||||
await runWithBuilderLogRelay(progressHandler, () =>
|
||||
this.dataSyncService.runSync(
|
||||
{
|
||||
builderConfig: payload.builderConfig as BuilderConfig | undefined,
|
||||
storageConfig: payload.storageConfig as StorageConfig | undefined,
|
||||
dryRun: payload.dryRun ?? false,
|
||||
},
|
||||
progressHandler,
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
@@ -22,7 +22,7 @@ export function PageTabs({ items, activeId, onSelect, className }: PageTabsProps
|
||||
const renderTabContent = (selected: boolean, label: ReactNode) => (
|
||||
<span
|
||||
className={clsxm(
|
||||
'inline-flex items-center rounded-lg px-3 py-1.5 text-xs font-medium transition-all',
|
||||
'inline-flex items-center shape-squircle px-3 py-1.5 text-xs font-medium transition-all',
|
||||
selected ? 'bg-accent/15 text-accent' : 'bg-fill/10 text-text-secondary hover:bg-fill/20 hover:text-text',
|
||||
)}
|
||||
>
|
||||
@@ -59,7 +59,7 @@ export function PageTabs({ items, activeId, onSelect, className }: PageTabsProps
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={item.disabled}
|
||||
className="focus-visible:outline-none"
|
||||
className="focus-visible:outline-none shape-squircle"
|
||||
>
|
||||
{renderTabContent(selected, item.label)}
|
||||
</button>
|
||||
|
||||
72
be/apps/dashboard/src/lib/errors.ts
Normal file
72
be/apps/dashboard/src/lib/errors.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { FetchError } from 'ofetch'
|
||||
|
||||
type FetchErrorWithPayload = FetchError<unknown> & {
|
||||
response?: {
|
||||
_data?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
function toMessage(value: unknown): string | null {
|
||||
if (value == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
if (value instanceof Error) {
|
||||
return toMessage(value.message)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const message = toMessage(entry)
|
||||
if (message) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>
|
||||
const candidates: unknown[] = [record.message, record.error, record.detail, record.description, record.reason]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const message = toMessage(candidate)
|
||||
if (message) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getRequestErrorMessage(error: unknown, fallback = '请求失败,请稍后重试。'): string {
|
||||
if (error instanceof FetchError) {
|
||||
const payload = (error as FetchErrorWithPayload).data ?? (error as FetchErrorWithPayload).response?._data
|
||||
const payloadMessage = toMessage(payload)
|
||||
if (payloadMessage) {
|
||||
return payloadMessage
|
||||
}
|
||||
|
||||
const errorMessage = toMessage(error.message)
|
||||
if (errorMessage) {
|
||||
return errorMessage
|
||||
}
|
||||
}
|
||||
|
||||
const genericMessage = toMessage(error)
|
||||
if (genericMessage) {
|
||||
return genericMessage
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/GlassPanel'
|
||||
import { getRequestErrorMessage } from '~/lib/errors'
|
||||
|
||||
import type { SocialAccountRecord } from '../api/socialAccounts'
|
||||
import {
|
||||
@@ -32,10 +33,10 @@ export function SocialConnectionSettings() {
|
||||
const hasError = providersQuery.isError || accountsQuery.isError
|
||||
const errorMessage = useMemo(() => {
|
||||
if (providersQuery.isError && providersQuery.error) {
|
||||
return providersQuery.error instanceof Error ? providersQuery.error.message : '无法加载可用的 OAuth Provider'
|
||||
return getRequestErrorMessage(providersQuery.error, '无法加载可用的 OAuth Provider')
|
||||
}
|
||||
if (accountsQuery.isError && accountsQuery.error) {
|
||||
return accountsQuery.error instanceof Error ? accountsQuery.error.message : '无法查询绑定状态'
|
||||
return getRequestErrorMessage(accountsQuery.error, '无法查询绑定状态')
|
||||
}
|
||||
return null
|
||||
}, [accountsQuery.error, accountsQuery.isError, providersQuery.error, providersQuery.isError])
|
||||
@@ -68,7 +69,7 @@ export function SocialConnectionSettings() {
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`无法开启 ${providerName} 绑定`, {
|
||||
description: error instanceof Error ? error.message : '请稍后再试',
|
||||
description: getRequestErrorMessage(error, '请稍后再试'),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -82,7 +83,7 @@ export function SocialConnectionSettings() {
|
||||
toast.success(`已解除与 ${providerName} 的绑定`)
|
||||
} catch (error) {
|
||||
toast.error('解绑失败', {
|
||||
description: error instanceof Error ? error.message : '请稍后再试',
|
||||
description: getRequestErrorMessage(error, '请稍后再试'),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ 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'
|
||||
@@ -43,6 +44,7 @@ const STAGE_ORDER: PhotoSyncProgressStage[] = [
|
||||
]
|
||||
|
||||
const MAX_SYNC_LOGS = 200
|
||||
const PHOTO_SYNC_RESULT_STORAGE_KEY = 'photo-sync:last-result'
|
||||
|
||||
function createInitialStages(totals: PhotoSyncProgressState['totals']): PhotoSyncProgressState['stages'] {
|
||||
return STAGE_ORDER.reduce<PhotoSyncProgressState['stages']>(
|
||||
@@ -71,6 +73,32 @@ export function PhotoPage() {
|
||||
const [resolvingConflictId, setResolvingConflictId] = useState<string | null>(null)
|
||||
const [syncProgress, setSyncProgress] = useState<PhotoSyncProgressState | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const restoreStoredResult = () => {
|
||||
try {
|
||||
const cached = window.sessionStorage.getItem(PHOTO_SYNC_RESULT_STORAGE_KEY)
|
||||
if (!cached) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(cached) as { result?: PhotoSyncResult; lastWasDryRun?: boolean | null }
|
||||
if (parsed?.result) {
|
||||
setResult(parsed.result)
|
||||
setLastWasDryRun(parsed.lastWasDryRun ?? null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore cached photo sync result', error)
|
||||
window.sessionStorage.removeItem(PHOTO_SYNC_RESULT_STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
restoreStoredResult()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(normalizedInitialTab)
|
||||
}, [normalizedInitialTab])
|
||||
@@ -86,6 +114,7 @@ export function PhotoPage() {
|
||||
|
||||
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) => {
|
||||
@@ -115,7 +144,6 @@ export function PhotoPage() {
|
||||
lastAction: undefined,
|
||||
error: undefined,
|
||||
})
|
||||
setResult(null)
|
||||
setLastWasDryRun(options.dryRun)
|
||||
return
|
||||
}
|
||||
@@ -247,7 +275,7 @@ export function PhotoPage() {
|
||||
setSelectedIds((prev) => prev.filter((item) => !ids.includes(item)))
|
||||
void listQuery.refetch()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '删除失败,请稍后重试。'
|
||||
const message = getRequestErrorMessage(error, '删除失败,请稍后重试。')
|
||||
toast.error('删除失败', { description: message })
|
||||
}
|
||||
},
|
||||
@@ -263,7 +291,7 @@ export function PhotoPage() {
|
||||
toast.success(`成功上传 ${fileArray.length} 张图片`)
|
||||
void listQuery.refetch()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '上传失败,请稍后重试。'
|
||||
const message = getRequestErrorMessage(error, '上传失败,请稍后重试。')
|
||||
toast.error('上传失败', { description: message })
|
||||
}
|
||||
},
|
||||
@@ -275,6 +303,16 @@ export function PhotoPage() {
|
||||
setResult(data)
|
||||
setLastWasDryRun(context.dryRun)
|
||||
setSyncProgress(null)
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
PHOTO_SYNC_RESULT_STORAGE_KEY,
|
||||
JSON.stringify({ result: data, lastWasDryRun: context.dryRun }),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to persist photo sync result snapshot', error)
|
||||
}
|
||||
}
|
||||
void summaryQuery.refetch()
|
||||
void listQuery.refetch()
|
||||
},
|
||||
@@ -285,6 +323,14 @@ export function PhotoPage() {
|
||||
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])
|
||||
@@ -312,7 +358,7 @@ export function PhotoPage() {
|
||||
void summaryQuery.refetch()
|
||||
void listQuery.refetch()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '处理冲突失败,请稍后重试。'
|
||||
const message = getRequestErrorMessage(error, '处理冲突失败,请稍后重试。')
|
||||
toast.error('处理冲突失败', { description: message })
|
||||
} finally {
|
||||
setResolvingConflictId(null)
|
||||
@@ -341,7 +387,7 @@ export function PhotoPage() {
|
||||
})
|
||||
processed += 1
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error))
|
||||
errors.push(getRequestErrorMessage(error, '处理冲突失败,请稍后重试。'))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -379,7 +425,7 @@ export function PhotoPage() {
|
||||
const url = await getPhotoStorageUrl(asset.storageKey)
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '无法获取原图链接'
|
||||
const message = getRequestErrorMessage(error, '无法获取原图链接')
|
||||
toast.error('打开失败', { description: message })
|
||||
}
|
||||
}
|
||||
@@ -471,11 +517,13 @@ export function PhotoPage() {
|
||||
<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}
|
||||
|
||||
@@ -10,11 +10,13 @@ import { PhotoSyncActions } from './sync/PhotoSyncActions'
|
||||
type PhotoPageActionsProps = {
|
||||
activeTab: PhotoPageTab
|
||||
selectionCount: number
|
||||
libraryTotalCount: number
|
||||
isUploading: boolean
|
||||
isDeleting: boolean
|
||||
onUpload: (files: FileList) => void | Promise<void>
|
||||
onDeleteSelected: () => void
|
||||
onClearSelection: () => void
|
||||
onSelectAll: () => void
|
||||
onSyncCompleted: (result: PhotoSyncResult, context: { dryRun: boolean }) => void
|
||||
onSyncProgress: (event: PhotoSyncProgressEvent) => void
|
||||
onSyncError: (error: Error) => void
|
||||
@@ -23,11 +25,13 @@ type PhotoPageActionsProps = {
|
||||
export function PhotoPageActions({
|
||||
activeTab,
|
||||
selectionCount,
|
||||
libraryTotalCount,
|
||||
isUploading,
|
||||
isDeleting,
|
||||
onUpload,
|
||||
onDeleteSelected,
|
||||
onClearSelection,
|
||||
onSelectAll,
|
||||
onSyncCompleted,
|
||||
onSyncProgress,
|
||||
onSyncError,
|
||||
@@ -49,11 +53,13 @@ export function PhotoPageActions({
|
||||
actionContent = (
|
||||
<PhotoLibraryActionBar
|
||||
selectionCount={selectionCount}
|
||||
totalCount={libraryTotalCount}
|
||||
isUploading={isUploading}
|
||||
isDeleting={isDeleting}
|
||||
onUpload={onUpload}
|
||||
onDeleteSelected={onDeleteSelected}
|
||||
onClearSelection={onClearSelection}
|
||||
onSelectAll={onSelectAll}
|
||||
/>
|
||||
)
|
||||
break
|
||||
|
||||
@@ -8,22 +8,29 @@ import { PhotoUploadConfirmModal } from './PhotoUploadConfirmModal'
|
||||
|
||||
type PhotoLibraryActionBarProps = {
|
||||
selectionCount: number
|
||||
totalCount: number
|
||||
isUploading: boolean
|
||||
isDeleting: boolean
|
||||
onUpload: (files: FileList) => void | Promise<void>
|
||||
onDeleteSelected: () => void
|
||||
onClearSelection: () => void
|
||||
onSelectAll: () => void
|
||||
}
|
||||
|
||||
export function PhotoLibraryActionBar({
|
||||
selectionCount,
|
||||
totalCount,
|
||||
isUploading,
|
||||
isDeleting,
|
||||
onUpload,
|
||||
onDeleteSelected,
|
||||
onClearSelection,
|
||||
onSelectAll,
|
||||
}: PhotoLibraryActionBarProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const hasSelection = selectionCount > 0
|
||||
const hasAssets = totalCount > 0
|
||||
const canSelectAll = hasAssets && selectionCount < totalCount
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
@@ -48,32 +55,39 @@ export function PhotoLibraryActionBar({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept="image/*,.heic,.HEIC,.heif,.HEIF,.hif,.HIF,.mov,.MOV"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isUploading}
|
||||
onClick={handleUploadClick}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<DynamicIcon name="upload" className="h-3.5 w-3.5" />
|
||||
上传文件
|
||||
</Button>
|
||||
<div className="flex w-full relative flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept="image/*,.heic,.HEIC,.heif,.HEIF,.hif,.HIF,.mov,.MOV"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isUploading}
|
||||
onClick={handleUploadClick}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<DynamicIcon name="upload" className="h-3.5 w-3.5" />
|
||||
上传文件
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectionCount > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-h-10 absolute right-0 translate-y-20 items-center justify-end gap-2">
|
||||
<div
|
||||
className={clsxm(
|
||||
'flex items-center gap-2 transition-opacity duration-200',
|
||||
hasSelection ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsxm(
|
||||
'inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium',
|
||||
'inline-flex items-center shape-squircle whitespace-nowrap px-2.5 py-1 text-xs font-medium',
|
||||
'bg-accent/10 text-accent',
|
||||
)}
|
||||
>
|
||||
@@ -95,7 +109,18 @@ export function PhotoLibraryActionBar({
|
||||
清除选择
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canSelectAll}
|
||||
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" />
|
||||
{hasAssets ? (canSelectAll ? '全选' : '已全选') : '全选'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useMainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
import { getRequestErrorMessage } from '~/lib/errors'
|
||||
|
||||
import { runPhotoSync } from '../../api'
|
||||
import type { PhotoSyncProgressEvent, PhotoSyncResult, RunPhotoSyncPayload } from '../../types'
|
||||
@@ -54,7 +55,7 @@ export function PhotoSyncActions({ onCompleted, onProgress, onError }: PhotoSync
|
||||
onError: (error) => {
|
||||
const normalizedError = error instanceof Error ? error : new Error('照片同步失败,请稍后重试。')
|
||||
|
||||
const { message } = normalizedError
|
||||
const message = getRequestErrorMessage(error, normalizedError.message)
|
||||
toast.error('同步失败', { description: message })
|
||||
onError?.(normalizedError)
|
||||
},
|
||||
|
||||
@@ -4,6 +4,8 @@ import { m } from 'motion/react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { getRequestErrorMessage } from '~/lib/errors'
|
||||
|
||||
import { getConflictTypeLabel, PHOTO_CONFLICT_TYPE_CONFIG } from '../../constants'
|
||||
import type { PhotoSyncConflict, PhotoSyncResolution, PhotoSyncSnapshot } from '../../types'
|
||||
import { BorderOverlay, MetadataSnapshot } from './PhotoSyncResultPanel'
|
||||
@@ -117,7 +119,7 @@ export function PhotoSyncConflictsPanel({
|
||||
const url = await onRequestStorageUrl(storageKey)
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const message = getRequestErrorMessage(error, '无法打开存储对象')
|
||||
toast.error('无法打开存储对象', { description: message })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,3 +252,8 @@ body {
|
||||
|
||||
@source inline('i-simple-icons-github');
|
||||
@source inline('i-simple-icons-google');
|
||||
|
||||
.shape-squircle {
|
||||
corner-shape: squircle;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ interface TuiState {
|
||||
logs: string[]
|
||||
}
|
||||
|
||||
const MAX_LOG_LINES = 15
|
||||
const MAX_LOG_LINES = 40
|
||||
|
||||
export class BuilderTUI {
|
||||
private readonly stream: NodeJS.WriteStream
|
||||
|
||||
@@ -13,6 +13,101 @@ import type { ProgressCallback, S3Config, StorageObject, StorageProvider, Storag
|
||||
// 将 AWS S3 对象转换为通用存储对象
|
||||
const xmlParser = new XMLParser({ ignoreAttributes: false })
|
||||
|
||||
const MAX_ERROR_SNIPPET_LENGTH = 300
|
||||
|
||||
const pickStringField = (source: Record<string, unknown>, keys: string[]): string | undefined => {
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function formatS3ErrorBody(body?: string | null): string {
|
||||
if (!body) {
|
||||
return '响应为空'
|
||||
}
|
||||
|
||||
const trimmed = body.trim()
|
||||
if (!trimmed) {
|
||||
return '响应为空'
|
||||
}
|
||||
|
||||
const pickCodeAndMessage = (payload: Record<string, unknown>): string | null => {
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
|
||||
const code = pickStringField(payload, ['Code', 'code', 'ErrorCode'])
|
||||
const message = pickStringField(payload, ['Message', 'message', 'ErrorMessage'])
|
||||
const requestId = pickStringField(payload, ['RequestId', 'requestId'])
|
||||
const hostId = pickStringField(payload, ['HostId', 'hostId'])
|
||||
|
||||
if (code || message) {
|
||||
const parts: string[] = []
|
||||
if (code) parts.push(`[${code}]`)
|
||||
if (message) parts.push(message)
|
||||
|
||||
const extraDetails: string[] = []
|
||||
if (requestId) extraDetails.push(`RequestId=${requestId}`)
|
||||
if (hostId) extraDetails.push(`HostId=${hostId}`)
|
||||
if (extraDetails.length > 0) {
|
||||
parts.push(`(${extraDetails.join(', ')})`)
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const tryJson = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const direct = pickCodeAndMessage(parsed as Record<string, unknown>)
|
||||
if (direct) return direct
|
||||
if ('error' in parsed && typeof parsed.error === 'object' && parsed.error) {
|
||||
const nested = pickCodeAndMessage(parsed.error as Record<string, unknown>)
|
||||
if (nested) return nested
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const tryXml = () => {
|
||||
try {
|
||||
const parsed = xmlParser.parse(trimmed)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const errorNode =
|
||||
(parsed.Error as Record<string, unknown> | undefined) ??
|
||||
(parsed.ErrorResponse as Record<string, unknown> | undefined) ??
|
||||
(parsed as Record<string, unknown>)
|
||||
|
||||
const formatted = pickCodeAndMessage(errorNode)
|
||||
if (formatted) return formatted
|
||||
}
|
||||
} catch {
|
||||
// ignore XML parse errors
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const formatted = tryJson() ?? tryXml()
|
||||
if (formatted) {
|
||||
return formatted
|
||||
}
|
||||
|
||||
if (trimmed.length > MAX_ERROR_SNIPPET_LENGTH) {
|
||||
return `${trimmed.slice(0, MAX_ERROR_SNIPPET_LENGTH)}…`
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export class S3StorageProvider implements StorageProvider {
|
||||
private config: S3Config
|
||||
private client: SimpleS3Client
|
||||
@@ -96,7 +191,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}) ${bodyText}`)
|
||||
logger.s3.error(`S3 响应异常:${key} (status ${response.status}) ${formatS3ErrorBody(bodyText)}`)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -248,7 +343,7 @@ export class S3StorageProvider implements StorageProvider {
|
||||
const response = await this.client.fetch(url.toString(), { method: 'GET' })
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
throw new Error(`列出 S3 对象失败 (status ${response.status}): ${text}`)
|
||||
throw new Error(`列出 S3 对象失败 (status ${response.status}): ${formatS3ErrorBody(text)}`)
|
||||
}
|
||||
const parsed = xmlParser.parse(text)
|
||||
const contents = parsed?.ListBucketResult?.Contents ?? []
|
||||
@@ -274,7 +369,7 @@ export class S3StorageProvider implements StorageProvider {
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
throw new Error(`删除 S3 对象失败:${key} (status ${response.status}) ${text}`)
|
||||
throw new Error(`删除 S3 对象失败:${key} (status ${response.status}) ${formatS3ErrorBody(text)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +385,7 @@ export class S3StorageProvider implements StorageProvider {
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
throw new Error(`上传 S3 对象失败:${key} (status ${response.status}) ${text}`)
|
||||
throw new Error(`上传 S3 对象失败:${key} (status ${response.status}) ${formatS3ErrorBody(text)}`)
|
||||
}
|
||||
|
||||
const lastModified = new Date()
|
||||
|
||||
@@ -11,6 +11,7 @@ const buttonVariants = tv({
|
||||
base: [
|
||||
'relative inline-flex items-center justify-center whitespace-nowrap rounded text-center font-medium transition-all duration-100 ease-in-out',
|
||||
'disabled:pointer-events-none',
|
||||
'shape-squircle',
|
||||
focusRing,
|
||||
],
|
||||
variants: {
|
||||
|
||||
Reference in New Issue
Block a user