mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +08:00
fix(history): expose deletion recovery states (#735)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '每页条数',
|
||||
|
||||
@@ -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: '每頁條數',
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user