diff --git a/app/components/storage/SiteSyncCard.vue b/app/components/storage/SiteSyncCard.vue index 08dd711..99889d5 100644 --- a/app/components/storage/SiteSyncCard.vue +++ b/app/components/storage/SiteSyncCard.vue @@ -60,8 +60,12 @@ const isPull = computed(() => direction.value === 'pull') const canAbandon = computed(() => !props.busy && Boolean(props.resumableJob) - && (isPull.value || Boolean(token.value.trim())) ) +const abandonConfirm = computed(() => ( + isPull.value || token.value.trim() + ? t('storage.backup.siteSyncAbandonConfirm') + : t('storage.backup.siteSyncAbandonWithoutTokenConfirm') +)) const directionSubtitle = computed(() => ( isPull.value ? t('storage.backup.siteSyncPullSubtitle') @@ -110,7 +114,7 @@ function resumeSync() { function abandonSync() { if (!canAbandon.value || !props.resumableJob) return - if (!window.confirm(t('storage.backup.siteSyncAbandonConfirm'))) return + if (!window.confirm(abandonConfirm.value)) return emit('sync', { targetBaseUrl: targetBaseUrl.value.trim(), token: token.value.trim(), diff --git a/app/composables/useBackup.ts b/app/composables/useBackup.ts index 6a286a5..8a271a7 100644 --- a/app/composables/useBackup.ts +++ b/app/composables/useBackup.ts @@ -268,11 +268,17 @@ export function useBackup() { ) await refreshJobsFirstPage() startPolling() + const cleanupPending = Boolean( + body.abandonJobId + && data.job.payload.cleanupPending === true + ) toast.add({ - title: body.abandonJobId - ? t('storage.backup.siteSyncAbandoned') - : t('storage.backup.siteSyncStarted'), - color: 'success' + title: cleanupPending + ? t('storage.backup.siteSyncAbandonedPendingCleanup') + : body.abandonJobId + ? t('storage.backup.siteSyncAbandoned') + : t('storage.backup.siteSyncStarted'), + color: cleanupPending ? 'warning' : 'success' }) return data.job } catch (error: unknown) { diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 5e12c42..65d5e4d 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -695,7 +695,9 @@ "siteSyncResume": "Resume last sync", "siteSyncAbandon": "Abandon and clean up", "siteSyncAbandonConfirm": "Abandon this sync and delete partial objects created by it that are not yet referenced by a media record?", + "siteSyncAbandonWithoutTokenConfirm": "No target token is provided. Abandon the task now? Partial objects will be marked for later automatic cleanup on the target.", "siteSyncAbandoned": "Sync abandoned and partial objects cleaned up", + "siteSyncAbandonedPendingCleanup": "Sync abandoned; partial objects still need cleanup", "siteSyncScanProgress": "Media scan progress", "siteSyncFileProgress": "Synced file count", "siteSyncStarted": "Site-to-site sync started", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index debd04e..a50d2f3 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -695,7 +695,9 @@ "siteSyncResume": "继续上次同步", "siteSyncAbandon": "放弃并清理残留", "siteSyncAbandonConfirm": "确定放弃这次同步吗?将删除本次同步新建但尚未归入媒体记录的残留对象。", + "siteSyncAbandonWithoutTokenConfirm": "未填写目标站 Token。确定只放弃任务吗?目标站残留将标记为待清理,之后可由目标站自动清理。", "siteSyncAbandoned": "同步已放弃,残留对象已清理", + "siteSyncAbandonedPendingCleanup": "同步已放弃,残留待清理", "siteSyncScanProgress": "媒体扫描进度", "siteSyncFileProgress": "已同步文件数量", "siteSyncStarted": "两站点同步任务已开始", diff --git a/server/utils/site-sync/abandon.ts b/server/utils/site-sync/abandon.ts index 9a9ab38..8469521 100644 --- a/server/utils/site-sync/abandon.ts +++ b/server/utils/site-sync/abandon.ts @@ -25,29 +25,41 @@ export async function abandonSiteSyncJob( const targetBaseUrl = typeof job.payload.targetBaseUrl === 'string' ? job.payload.targetBaseUrl.replace(/\/+$/, '') : '' - let cleanup: CleanupResult + let cleanup: CleanupResult | null = null + let cleanupError: string | null = null if (direction === 'push') { - if (!targetBaseUrl || !token) { - throw new Error('放弃推送任务并清理目标站残留时,需要目标站 Token') - } - cleanup = await fetchSiteSyncJson( - `${targetBaseUrl}/api/site-sync/cleanup`, - token, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jobId }) + if (targetBaseUrl && token) { + try { + cleanup = await fetchSiteSyncJson( + `${targetBaseUrl}/api/site-sync/cleanup`, + token, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jobId }) + } + ) + } catch (error) { + cleanupError = error instanceof Error ? error.message : '目标站残留清理失败' } - ) + } } else { - cleanup = await cleanupSiteSyncJobArtifacts(jobId) + try { + cleanup = await cleanupSiteSyncJobArtifacts(jobId) + } catch (error) { + cleanupError = error instanceof Error ? error.message : '本地残留清理失败' + } } - if (cleanup.failed > 0) { - throw new Error(`同步残留清理失败:${cleanup.failed} 个对象`) - } + const cleanupPending = Boolean( + cleanupError + || !cleanup + || cleanup.failed > 0 + ) - const message = `已放弃同步 · 清理 ${cleanup.deleted} 个残留对象` + const message = cleanupPending + ? `已放弃同步 · 残留待清理${cleanupError ? `:${cleanupError}` : ''}` + : `已放弃同步 · 清理 ${cleanup!.deleted} 个残留对象` updateBackupJob(jobId, { status: 'failed', message, @@ -58,6 +70,8 @@ export async function abandonSiteSyncJob( resumable: false, abandoned: true, cancelled: false, + cleanupPending, + cleanupError, failedKeys: [], cleanup } diff --git a/server/utils/site-sync/runner.ts b/server/utils/site-sync/runner.ts index 287db4a..05bcedf 100644 --- a/server/utils/site-sync/runner.ts +++ b/server/utils/site-sync/runner.ts @@ -1,6 +1,8 @@ import { basename } from 'node:path' +import { File as BufferFile } from 'node:buffer' import { Agent, + FormData as UndiciFormData, fetch as undiciFetch } from 'undici' import type { Readable } from 'node:stream' @@ -399,7 +401,7 @@ async function uploadObject( getJobAbortSignal(jobId) ) throwIfJobCancellationRequested(jobId) - const form = new FormData() + const form = new UndiciFormData() const isPrimary = key === row.key form.append('jobId', jobId) form.append('mediaKey', row.key) @@ -412,8 +414,11 @@ async function uploadObject( form.append('contentType', contentType) form.append( 'file', - new Blob([new Uint8Array(bytes)], { type: contentType }), - basename(key) + new BufferFile( + [new Uint8Array(bytes)], + basename(key), + { type: contentType } + ) ) const response = await undiciFetch( @@ -424,7 +429,7 @@ async function uploadObject( headers: { 'Auth-Token': token }, signal: getJobAbortSignal(jobId), dispatcher: agents.upload - } as Parameters[1] + } ) if (!response.ok) throw await responseError(response) await response.json().catch(() => null)