fix(history): expose deletion recovery states (#735)

This commit is contained in:
InfinityPacer
2026-08-31 06:33:13 +08:00
committed by GitHub
parent 57127dd295
commit b512aee657
6 changed files with 355 additions and 23 deletions
+17
View File
@@ -304,6 +304,23 @@ export interface TransferHistory {
src_fileitem?: FileItem
}
/** 整理历史删除的单个文件步骤状态。 */
export type TransferHistoryDeleteStepStatus = 'not_requested' | 'deleted' | 'already_missing' | 'failed'
/** 后端返回的整理历史删除分项结果,供失败重试和用户反馈复用。 */
export interface TransferHistoryDeleteResult {
source: {
status: TransferHistoryDeleteStepStatus
message?: string
}
destination: {
status: TransferHistoryDeleteStepStatus
message?: string
}
history: 'deleted' | 'retained' | 'not_found'
message?: string
}
// 下载历史记录
export interface DownloadHistory {
// ID
+14
View File
@@ -4077,6 +4077,7 @@ export default {
deleteFailed: 'Failed to delete: {message}',
deleteRequestFailed: 'Failed to delete: Request failed',
batchDeleteFailed: 'Failed to delete {failed} of {total} records',
batchDeleteFailedDetail: '{summary} ({details})',
deleting: 'Deleting {name}...',
deleteRecordOnly: 'Delete Record Only',
deleteSourceOnly: 'Delete Record and Source File',
@@ -4095,6 +4096,19 @@ export default {
failed: 'Failed',
unknown: 'Unknown',
},
statusFilter: {
label: 'Status filter',
all: 'All',
success: 'Success',
failed: 'Failed',
},
deleteStep: {
source: 'Source file',
destination: 'Media library file',
record: 'Transfer record',
completed: 'Completed',
failed: 'Failed',
},
noData: 'No Data',
loading: 'Loading...',
pageSize: 'Items Per Page',
+14
View File
@@ -4008,6 +4008,7 @@ export default {
deleteFailed: '删除失败:{message}',
deleteRequestFailed: '删除失败:请求异常',
batchDeleteFailed: '删除失败:{failed}/{total}',
batchDeleteFailedDetail: '{summary}{details}',
deleting: '正在删除 {name}...',
deleteRecordOnly: '仅删除转移记录',
deleteSourceOnly: '删除转移记录和源文件',
@@ -4026,6 +4027,19 @@ export default {
failed: '失败',
unknown: '未知',
},
statusFilter: {
label: '状态筛选',
all: '全部',
success: '成功',
failed: '失败',
},
deleteStep: {
source: '源文件',
destination: '媒体库文件',
record: '整理记录',
completed: '已完成',
failed: '失败',
},
noData: '没有数据',
loading: '加载中...',
pageSize: '每页条数',
+14
View File
@@ -4005,6 +4005,7 @@ export default {
deleteFailed: '刪除失敗:{message}',
deleteRequestFailed: '刪除失敗:請求異常',
batchDeleteFailed: '刪除失敗:{failed}/{total}',
batchDeleteFailedDetail: '{summary}{details}',
deleting: '正在刪除 {name}...',
deleteRecordOnly: '僅刪除轉移記錄',
deleteSourceOnly: '刪除轉移記錄和源文件',
@@ -4023,6 +4024,19 @@ export default {
failed: '失敗',
unknown: '未知',
},
statusFilter: {
label: '狀態篩選',
all: '全部',
success: '成功',
failed: '失敗',
},
deleteStep: {
source: '來源檔案',
destination: '媒體庫檔案',
record: '整理記錄',
completed: '已完成',
failed: '失敗',
},
noData: '沒有數據',
loading: '加載中...',
pageSize: '每頁條數',
+210 -17
View File
@@ -1,8 +1,13 @@
<script setup lang="ts">
import { debounce } from 'lodash-es'
import { useToast } from 'vue-toastification'
import api from '@/api'
import type { StorageConf, TransferHistory } from '@/api/types'
import api, { isApiBusinessFailure } from '@/api'
import type {
StorageConf,
TransferHistory,
TransferHistoryDeleteResult,
TransferHistoryDeleteStepStatus,
} from '@/api/types'
import ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue'
import TransferQueueDialog from '@/components/dialog/TransferQueueDialog.vue'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
@@ -82,6 +87,20 @@ const redoTargetStorage = ref<string>()
//
const selected = ref<TransferHistory[]>([])
//
const completedDeleteSteps = new Map<number, { source: boolean; destination: boolean }>()
type TransferHistoryStatusFilter = 'all' | 'success' | 'failed'
// title
const statusFilter = ref<TransferHistoryStatusFilter>(getRouteStatusFilter(route.query.status))
const statusFilterItems = computed(() => [
{ title: t('transferHistory.statusFilter.all'), value: 'all' },
{ title: t('transferHistory.status.success'), value: 'success' },
{ title: t('transferHistory.status.failed'), value: 'failed' },
])
//
const mobileBatchMode = ref(false)
@@ -421,6 +440,19 @@ watch([() => search.value, () => isComposing.value], () => {
debouncedReloadSearchPage()
})
//
watch(
() => statusFilter.value,
() => {
if (syncingRouteQuery) return
if (isMobile.value) {
void reloadMobileSearchPage()
return
}
void reloadPage(true)
},
)
// 便
watch(
() => group.value,
@@ -468,6 +500,7 @@ async function fetchData(page = currentPage.value, count = itemsPerPage.value, o
page,
count,
title: search.value ?? '',
...(statusFilter.value === 'all' ? {} : { status: statusFilter.value === 'success' }),
},
})
if (requestSeed !== fetchDataRequestSeed) return
@@ -493,9 +526,98 @@ async function fetchData(page = currentPage.value, count = itemsPerPage.value, o
}
}
const completedDeleteStatuses: TransferHistoryDeleteStepStatus[] = ['deleted', 'already_missing']
function isCompletedDeleteStatus(status: TransferHistoryDeleteStepStatus) {
return completedDeleteStatuses.includes(status)
}
function parseDeleteResult(value: unknown): TransferHistoryDeleteResult | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const record = value as Record<string, unknown>
const parseStep = (step: unknown) => {
if (!step || typeof step !== 'object' || Array.isArray(step)) return undefined
const stepRecord = step as Record<string, unknown>
const statuses: TransferHistoryDeleteStepStatus[] = ['not_requested', 'deleted', 'already_missing', 'failed']
if (!statuses.includes(stepRecord.status as TransferHistoryDeleteStepStatus)) return undefined
return {
status: stepRecord.status as TransferHistoryDeleteStepStatus,
message: typeof stepRecord.message === 'string' ? stepRecord.message : undefined,
}
}
const source = parseStep(record.source)
const destination = parseStep(record.destination)
const history = record.history
if (!source || !destination || (history !== 'deleted' && history !== 'retained' && history !== 'not_found')) {
return undefined
}
return {
source,
destination,
history,
message: typeof record.message === 'string' ? record.message : undefined,
}
}
function rememberDeleteResult(item: TransferHistory, result: TransferHistoryDeleteResult) {
if (result.history === 'deleted' || result.history === 'not_found') {
completedDeleteSteps.delete(item.id)
return
}
const previous = completedDeleteSteps.get(item.id) ?? { source: false, destination: false }
completedDeleteSteps.set(item.id, {
source: previous.source || isCompletedDeleteStatus(result.source.status),
destination: previous.destination || isCompletedDeleteStatus(result.destination.status),
})
}
function getDeleteFlags(item: TransferHistory, deleteSrc: boolean, deleteDest: boolean) {
const completed = completedDeleteSteps.get(item.id)
return {
deleteSrc: deleteSrc && !completed?.source,
deleteDest: deleteDest && !completed?.destination,
}
}
function formatDeleteStepSummary(result: TransferHistoryDeleteResult) {
const completed: string[] = []
const failed: string[] = []
const addStep = (label: string, status: TransferHistoryDeleteStepStatus) => {
if (status === 'not_requested') return
if (isCompletedDeleteStatus(status)) completed.push(label)
if (status === 'failed') failed.push(label)
}
addStep(t('transferHistory.deleteStep.source'), result.source.status)
addStep(t('transferHistory.deleteStep.destination'), result.destination.status)
if (result.history === 'deleted') completed.push(t('transferHistory.deleteStep.record'))
if (result.history === 'retained') failed.push(t('transferHistory.deleteStep.record'))
return { completed: completed.join(', '), failed: failed.join(', ') }
}
function getDeleteResultFromError(error: unknown): TransferHistoryDeleteResult | undefined {
if (!isApiBusinessFailure(error)) return undefined
const payload = error.payload
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return undefined
return parseDeleteResult((payload as Record<string, unknown>).data)
}
function notifyDeleteResult(result: TransferHistoryDeleteResult, notifyError: boolean) {
if (!notifyError) return
const summary = formatDeleteStepSummary(result)
if (!summary.failed) return
const detail = [
result.message,
summary.completed ? `${t('transferHistory.deleteStep.completed')}: ${summary.completed}` : '',
`${t('transferHistory.deleteStep.failed')}: ${summary.failed}`,
]
.filter(Boolean)
.join('')
$toast.error(t('transferHistory.deleteFailed', { message: detail }))
}
//
function updateSearchHintList(list: TransferHistory[]) {
searchHintList.value = ['失败', '成功', ...new Set(list.map((item: TransferHistory) => item.title || ''))].filter(
searchHintList.value = [...new Set(list.map((item: TransferHistory) => item.title || ''))].filter(
(title): title is string => title !== '',
)
}
@@ -527,6 +649,7 @@ function syncMobileSearchFromRouteQuery() {
syncingRouteQuery = true
try {
search.value = getRouteQueryString(route.query.search)
statusFilter.value = getRouteStatusFilter(route.query.status)
} finally {
void nextTick(() => {
syncingRouteQuery = false
@@ -555,6 +678,7 @@ async function loadMobileHistory({ done }: { done: (status: 'ok' | 'empty' | 'er
page: mobileCurrentPage.value,
count: mobilePageSize,
title: search.value ?? '',
...(statusFilter.value === 'all' ? {} : { status: statusFilter.value === 'success' }),
},
})
if (requestSeed !== mobileFetchDataRequestSeed) {
@@ -610,11 +734,18 @@ function getRouteQueryString(value: unknown): string | null {
return typeof value === 'string' && value !== '' ? value : null
}
// success/failed退
function getRouteStatusFilter(value: unknown): TransferHistoryStatusFilter {
const raw = Array.isArray(value) ? value.find(item => typeof item === 'string') : value
return raw === 'success' || raw === 'failed' ? raw : 'all'
}
//
async function syncStateFromRouteQuery() {
syncingRouteQuery = true
try {
search.value = getRouteQueryString(route.query.search)
statusFilter.value = getRouteStatusFilter(route.query.status)
itemsPerPage.value = ensurePageSize(route.query.itemsPerPage, 50)
currentPage.value = Math.max(1, ensureNumber(route.query.currentPage, 1))
group.value = route.query.grouped === 'true'
@@ -688,20 +819,37 @@ async function removeHistory(item: TransferHistory) {
}
// API
async function remove(item: TransferHistory, deleteSrc: boolean, deleteDest: boolean, notifyError = true) {
async function remove(
item: TransferHistory,
deleteSrc: boolean,
deleteDest: boolean,
notifyError = true,
): Promise<TransferHistoryDeleteResult | undefined> {
try {
// API
await api.delete<null>(`history/transfer?deletesrc=${deleteSrc}&deletedest=${deleteDest}`, {
data: item,
feedback: 'silent',
})
return true
const result = await api.delete<TransferHistoryDeleteResult>(
`history/transfer?deletesrc=${deleteSrc}&deletedest=${deleteDest}`,
{
data: item,
feedback: 'silent',
},
)
const parsed = parseDeleteResult(result)
if (!parsed) throw new Error('删除响应缺少分项结果')
rememberDeleteResult(item, parsed)
notifyDeleteResult(parsed, notifyError)
return parsed
} catch (error) {
const businessResult = getDeleteResultFromError(error)
if (businessResult) {
rememberDeleteResult(item, businessResult)
notifyDeleteResult(businessResult, notifyError)
return businessResult
}
console.error(error)
if (notifyError) {
$toast.error(t('transferHistory.deleteRequestFailed'))
}
return false
return undefined
}
}
@@ -712,7 +860,8 @@ async function removeSingle(deleteSrc: boolean, deleteDest: boolean) {
if (!currentHistory.value) return
//
await remove(currentHistory.value, deleteSrc, deleteDest)
const flags = getDeleteFlags(currentHistory.value, deleteSrc, deleteDest)
await remove(currentHistory.value, flags.deleteSrc, flags.deleteDest)
//
await refreshDataAfterOperation()
}
@@ -729,6 +878,7 @@ async function removeBatch(deleteSrc: boolean, deleteDest: boolean) {
//
let handled = 0
const failedItems: TransferHistory[] = []
const failedDetails: string[] = []
//
openProgressDialog()
// removeHistory
@@ -737,9 +887,15 @@ async function removeBatch(deleteSrc: boolean, deleteDest: boolean) {
const seasonEpisode = `${item.seasons || ''}${item.episodes || ''}`
const name = [item.title, seasonEpisode].filter(Boolean).join(' ')
progressText.value = t('transferHistory.deleting', { name })
const success = await remove(item, deleteSrc, deleteDest, false)
if (!success) {
const flags = getDeleteFlags(item, deleteSrc, deleteDest)
const result = await remove(item, flags.deleteSrc, flags.deleteDest, false)
if (!result || result.history !== 'deleted') {
failedItems.push(item)
if (result) {
const summary = formatDeleteStepSummary(result)
const detail = [summary.completed, summary.failed].filter(Boolean).join(' / ')
if (detail) failedDetails.push(`${name}: ${detail}`)
}
}
//
handled++
@@ -754,7 +910,12 @@ async function removeBatch(deleteSrc: boolean, deleteDest: boolean) {
//
closeProgressDialog()
if (failedItems.length > 0) {
$toast.error(t('transferHistory.batchDeleteFailed', { failed: failedItems.length, total }))
const summary = t('transferHistory.batchDeleteFailed', { failed: failedItems.length, total })
$toast.error(
failedDetails.length > 0
? t('transferHistory.batchDeleteFailedDetail', { summary, details: failedDetails.join('') })
: summary,
)
}
//
await refreshDataAfterOperation(failedItems)
@@ -1017,6 +1178,9 @@ function createHistoryUrl(resetPage = false, page = resetPage ? 1 : currentPage.
if (search.value) {
query.search = search.value
}
if (statusFilter.value !== 'all') {
query.status = statusFilter.value
}
if (itemsPerPage.value) {
query.itemsPerPage = String(itemsPerPage.value)
}
@@ -1419,7 +1583,7 @@ onUnmounted(() => {
<VCardItem>
<VCardTitle>
<VRow>
<VCol cols="8" md="6" class="flex">
<VCol cols="8" md="4" class="flex">
<VCombobox
key="search_navbar"
:model-value="search"
@@ -1441,7 +1605,20 @@ onUnmounted(() => {
clearable
/>
</VCol>
<VCol cols="4" md="6" class="text-end">
<VCol cols="4" md="4">
<VSelect
v-model="statusFilter"
:items="statusFilterItems"
item-title="title"
item-value="value"
density="compact"
variant="solo-filled"
hide-details
flat
:label="t('transferHistory.statusFilter.label')"
/>
</VCol>
<VCol cols="4" md="4" class="text-end">
<VBtnGroup variant="outlined" divided rounded>
<VBtn :icon="group ? 'mdi-format-list-bulleted' : 'mdi-format-list-group'" @click="group = !group" />
</VBtnGroup>
@@ -1745,6 +1922,18 @@ onUnmounted(() => {
clearable
/>
<VSelect
v-model="statusFilter"
:items="statusFilterItems"
item-title="title"
item-value="value"
density="comfortable"
variant="outlined"
hide-details
class="transfer-history-mobile-status"
:label="t('transferHistory.statusFilter.label')"
/>
<VInfiniteScroll
:key="mobileInfiniteKey"
mode="intersect"
@@ -2042,6 +2231,10 @@ onUnmounted(() => {
min-inline-size: 0;
}
.transfer-history-mobile-status {
margin-block: 0.75rem;
}
.transfer-history-mobile-search :deep(.v-field) {
background: var(--transfer-history-mobile-search-bg);
min-block-size: 3.75rem;
@@ -32,6 +32,8 @@ vi.mock('@/api', () => ({
get: (...args: unknown[]) => mocks.apiGet(...args),
post: (...args: unknown[]) => mocks.apiPost(...args),
}),
isApiBusinessFailure: (error: unknown) =>
Boolean(error && typeof error === 'object' && (error as { businessFailure?: unknown }).businessFailure === true),
}))
vi.mock('vue-toastification', () => ({
@@ -298,6 +300,21 @@ function historyResponse(list: TransferHistory[], total = list.length) {
return { data: { list, total }, success: true }
}
function deleteResultResponse(
overrides: Partial<{ history: 'deleted' | 'retained' | 'not_found'; source: string; destination: string }> = {},
) {
return {
data: {
source: { status: overrides.source ?? 'not_requested' },
destination: { status: overrides.destination ?? 'not_requested' },
history: overrides.history ?? 'deleted',
message: '',
},
message: '',
success: true,
}
}
function storageResponse() {
return { data: { value: [] }, success: true }
}
@@ -380,7 +397,7 @@ describe('TransferHistoryView', () => {
mocks.desktop = true
mocks.dynamicButtonConfig = undefined
mocks.progressCallback = undefined
mocks.apiDelete.mockResolvedValue({ success: true })
mocks.apiDelete.mockResolvedValue(deleteResultResponse())
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
return Promise.resolve(historyResponse([]))
@@ -408,6 +425,19 @@ describe('TransferHistoryView', () => {
expect(requests).toEqual([{ count: 50, page: 1, title: '科幻' }])
})
it('sends status as an explicit query while preserving the title search', async () => {
const requests: Array<Record<string, unknown>> = []
mocks.apiGet.mockImplementation((path: string, config?: { params?: Record<string, unknown> }) => {
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
requests.push(config?.params ?? {})
return Promise.resolve(historyResponse([]))
})
await renderHistory('/history?search=失败&status=failed')
await waitFor(() => expect(requests).toEqual([{ count: 50, page: 1, status: false, title: '失败' }]))
})
it('prevents an older desktop request from replacing a newer route search', async () => {
const oldRequest = createDeferred<ReturnType<typeof historyResponse>>()
const newRequest = createDeferred<ReturnType<typeof historyResponse>>()
@@ -663,8 +693,17 @@ describe('TransferHistoryView', () => {
return Promise.resolve(historyResponse(histories))
})
mocks.apiDelete
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({ message: '记录被占用', success: false })
.mockResolvedValueOnce(deleteResultResponse())
.mockResolvedValueOnce({
data: {
source: { status: 'not_requested' },
destination: { status: 'failed', message: '目标文件被占用' },
history: 'retained',
message: '记录被占用',
},
message: '记录被占用',
success: false,
})
.mockRejectedValueOnce(new Error('delete unavailable'))
await renderHistory()
@@ -709,6 +748,38 @@ describe('TransferHistoryView', () => {
expect(mocks.toastError).toHaveBeenCalledWith('Failed to delete: Request failed')
})
it('retries only the unfinished file step after a partial deletion', async () => {
const item = createHistory(1, '部分删除')
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
return Promise.resolve(historyResponse([item]))
})
mocks.apiDelete
.mockResolvedValueOnce({
data: {
source: { status: 'deleted' },
destination: { status: 'failed', message: '媒体库暂不可用' },
history: 'retained',
message: '媒体库暂不可用',
},
message: '媒体库暂不可用',
success: false,
})
.mockResolvedValueOnce(deleteResultResponse({ source: 'not_requested', destination: 'deleted' }))
await renderHistory()
expect(await screen.findByText('部分删除')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '删除' }))
await getDialogCall().events.delete(true, true)
await flushPromises()
await fireEvent.click(screen.getByRole('button', { name: '删除' }))
await getDialogCall(1).events.delete(true, true)
await flushPromises()
expect(mocks.apiDelete.mock.calls[0]?.[0]).toBe('history/transfer?deletesrc=true&deletedest=true')
expect(mocks.apiDelete.mock.calls[1]?.[0]).toBe('history/transfer?deletesrc=false&deletedest=true')
})
it('releases delete-dialog ownership when either close contract fires', async () => {
const item = createHistory(1, '删除弹窗生命周期')
mocks.apiGet.mockImplementation((path: string) => {
@@ -759,8 +830,17 @@ describe('TransferHistoryView', () => {
return Promise.resolve(historyResponse(historyCalls === 1 ? histories : histories.slice(1)))
})
mocks.apiDelete
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({ message: 'occupied', success: false })
.mockResolvedValueOnce(deleteResultResponse())
.mockResolvedValueOnce({
data: {
source: { status: 'not_requested' },
destination: { status: 'failed', message: 'occupied' },
history: 'retained',
message: 'occupied',
},
message: 'occupied',
success: false,
})
.mockRejectedValueOnce(new Error('delete unavailable'))
await renderHistory()
@@ -777,7 +857,7 @@ describe('TransferHistoryView', () => {
expect(getDynamicMenuItems()?.find(item => item.titleKey === 'transferHistory.selectedCount')?.titleParams).toEqual(
{ count: 0, total: 0 },
)
expect(mocks.toastError).toHaveBeenCalledWith('刪除失敗:2/3')
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('刪除失敗:2/3'))
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
expect(await screen.findByText('保留甲')).toBeInTheDocument()