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:
Innei
2025-11-13 14:52:00 +08:00
parent e8f967a7ea
commit 76a4c251e4
15 changed files with 393 additions and 52 deletions

View File

@@ -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) {

View File

@@ -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()
}
}

View File

@@ -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'

View File

@@ -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>

View 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
}

View File

@@ -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, '请稍后再试'),
})
}
},

View File

@@ -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}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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)
},

View File

@@ -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 })
}
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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: {