From eaf6b5427c8e8f286ba2e995ef8bc79199f86429 Mon Sep 17 00:00:00 2001
From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com>
Date: Fri, 31 Jul 2026 16:39:48 +0800
Subject: [PATCH] test(history): cover transfer history workflows (#613)
---
src/locales/en-US.ts | 4 +
src/locales/zh-CN.ts | 4 +
src/locales/zh-TW.ts | 4 +
src/views/reorganize/TransferHistoryView.vue | 98 ++-
.../__tests__/TransferHistoryView.spec.ts | 824 ++++++++++++++++++
vite.config.ts | 7 +
6 files changed, 915 insertions(+), 26 deletions(-)
create mode 100644 src/views/reorganize/__tests__/TransferHistoryView.spec.ts
diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts
index 46d3b1ca..5625b346 100644
--- a/src/locales/en-US.ts
+++ b/src/locales/en-US.ts
@@ -3660,6 +3660,10 @@ export default {
listMode: 'List Mode',
deleteConfirm: 'Confirm delete {title} {seasons}{episodes}?',
deleteConfirmBatch: 'Confirm delete {count} records?',
+ deleteFailed: 'Failed to delete: {message}',
+ deleteRequestFailed: 'Failed to delete: Request failed',
+ batchDeleteFailed: 'Failed to delete {failed} of {total} records',
+ deleting: 'Deleting {name}...',
deleteRecordOnly: 'Delete Record Only',
deleteSourceOnly: 'Delete Record and Source File',
deleteDestOnly: 'Delete Record and Media Library File',
diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts
index 3ff19913..5fed9c42 100644
--- a/src/locales/zh-CN.ts
+++ b/src/locales/zh-CN.ts
@@ -3598,6 +3598,10 @@ export default {
listMode: '列表模式',
deleteConfirm: '确认删除 {title} {seasons}{episodes} ?',
deleteConfirmBatch: '确认删除 {count} 条记录 ?',
+ deleteFailed: '删除失败:{message}',
+ deleteRequestFailed: '删除失败:请求异常',
+ batchDeleteFailed: '删除失败:{failed}/{total}',
+ deleting: '正在删除 {name}...',
deleteRecordOnly: '仅删除转移记录',
deleteSourceOnly: '删除转移记录和源文件',
deleteDestOnly: '删除转移记录和媒体库文件',
diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts
index 825d256d..56bbfd06 100644
--- a/src/locales/zh-TW.ts
+++ b/src/locales/zh-TW.ts
@@ -3595,6 +3595,10 @@ export default {
listMode: '列表模式',
deleteConfirm: '確認刪除 {title} {seasons}{episodes}?',
deleteConfirmBatch: '確認刪除 {count} 條記錄?',
+ deleteFailed: '刪除失敗:{message}',
+ deleteRequestFailed: '刪除失敗:請求異常',
+ batchDeleteFailed: '刪除失敗:{failed}/{total}',
+ deleting: '正在刪除 {name}...',
deleteRecordOnly: '僅刪除轉移記錄',
deleteSourceOnly: '刪除轉移記錄和源文件',
deleteDestOnly: '刪除轉移記錄和媒體庫文件',
diff --git a/src/views/reorganize/TransferHistoryView.vue b/src/views/reorganize/TransferHistoryView.vue
index 791487cd..c3175b8d 100644
--- a/src/views/reorganize/TransferHistoryView.vue
+++ b/src/views/reorganize/TransferHistoryView.vue
@@ -6,8 +6,7 @@ import type { StorageConf, TransferHistory } from '@/api/types'
import ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue'
import TransferQueueDialog from '@/components/dialog/TransferQueueDialog.vue'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
-import { useRoute } from 'vue-router'
-import router from '@/router'
+import { useRoute, useRouter } from 'vue-router'
import { useDisplay } from 'vuetify'
import { formatFileSize } from '@/@core/utils/formatters'
import { useI18n } from 'vue-i18n'
@@ -48,6 +47,7 @@ const $toast = useToast()
// 路由
const route = useRoute()
+const router = useRouter()
const userStore = useUserStore()
const canManage = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'manage'),
@@ -55,6 +55,7 @@ const canManage = computed(() =>
let syncingRouteQuery = false
let fetchDataRequestSeed = 0
let mobileFetchDataRequestSeed = 0
+let componentUnmounted = false
// 组合式输入法状态
const isComposing = ref(false)
@@ -396,9 +397,9 @@ const debouncedReloadSearchPage = debounce(() => {
void reloadPage(true)
}, 1000)
-// 延迟刷新移动端无限列表,输入完成后再从第一页重新加载。
+// 延迟同步移动端搜索参数,路由监听会按新查询重置无限列表。
const debouncedReloadMobileSearchPage = debounce(() => {
- resetMobileHistory()
+ void reloadMobileSearchPage()
}, 600)
// 切换页签
@@ -499,8 +500,15 @@ function updateSearchHintList(list: TransferHistory[]) {
)
}
+interface MobileHistoryResetOptions {
+ /** 是否保持批量模式,通常用于失败项重试。 */
+ preserveBatchMode?: boolean
+ /** 重置分页时仍需保持选中的记录。 */
+ selectedItems?: TransferHistory[]
+}
+
// 重置移动端无限列表,让 VInfiniteScroll 从第一页重新触发加载。
-function resetMobileHistory() {
+function resetMobileHistory(options: MobileHistoryResetOptions = {}) {
mobileFetchDataRequestSeed++
mobileDataList.value = []
mobileCurrentPage.value = 1
@@ -509,8 +517,8 @@ function resetMobileHistory() {
isRefreshed.value = false
totalItems.value = 0
mobileExpandedPathIds.value = []
- selected.value = []
- mobileBatchMode.value = false
+ selected.value = options.selectedItems ?? []
+ mobileBatchMode.value = Boolean(options.preserveBatchMode && selected.value.length > 0)
mobileInfiniteKey.value++
}
@@ -584,6 +592,10 @@ function appendMobileHistory(list: TransferHistory[], total: number) {
const newItems = list.filter(item => !existingIds.has(item.id))
mobileDataList.value = [...mobileDataList.value, ...newItems]
+ if (selected.value.length > 0) {
+ const refreshedItems = new Map(mobileDataList.value.map(item => [item.id, item]))
+ selected.value = selected.value.map(item => refreshedItems.get(item.id) ?? item)
+ }
mobileCurrentPage.value++
mobileHasMore.value = mobileDataList.value.length < total && list.length >= mobilePageSize
updateSearchHintList(mobileDataList.value)
@@ -619,9 +631,12 @@ async function refreshDataFromRouteQuery(options: { silent?: boolean } = {}) {
}
// 操作完成后刷新列表;如果当前页被删空,则跳回最后一个有效页。
-async function refreshDataAfterOperation() {
+async function refreshDataAfterOperation(mobileSelection: TransferHistory[] = []) {
if (isMobile.value) {
- resetMobileHistory()
+ resetMobileHistory({
+ preserveBatchMode: mobileSelection.length > 0,
+ selectedItems: mobileSelection,
+ })
return
}
@@ -632,7 +647,6 @@ async function refreshDataAfterOperation() {
if (currentPage.value <= lastAvailablePage) return
await router.replace(createHistoryUrl(false, lastAvailablePage))
- await refreshDataFromRouteQuery()
}
// 根据 type 返回不同的图标
@@ -666,7 +680,7 @@ async function removeHistory(item: TransferHistory) {
}
// 调用API删除记录
-async function remove(item: TransferHistory, deleteSrc: boolean, deleteDest: boolean) {
+async function remove(item: TransferHistory, deleteSrc: boolean, deleteDest: boolean, notifyError = true) {
try {
// 调用删除API
const result: {
@@ -675,9 +689,19 @@ async function remove(item: TransferHistory, deleteSrc: boolean, deleteDest: boo
data: item,
})
- if (!result.success) $toast.error(`删除失败: ${result.message}`)
+ if (!result.success) {
+ if (notifyError) {
+ $toast.error(t('transferHistory.deleteFailed', { message: result.message || '' }))
+ }
+ return false
+ }
+ return true
} catch (error) {
console.error(error)
+ if (notifyError) {
+ $toast.error(t('transferHistory.deleteRequestFailed'))
+ }
+ return false
}
}
@@ -704,27 +728,36 @@ async function removeBatch(deleteSrc: boolean, deleteDest: boolean) {
// 已处理条数
let handled = 0
+ const failedItems: TransferHistory[] = []
// 显示进度条
openProgressDialog()
// 循环调用removeHistory
for (const item of selected.value) {
// 开始删除
- progressText.value = `正在删除 ${item.title} ${item.seasons}${item.episodes} ...`
- await remove(item, deleteSrc, deleteDest)
+ 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) {
+ failedItems.push(item)
+ }
// 删除完成
handled++
progressValue.value = (handled / total) * 100
progressDialogController?.updateProps({ text: progressText.value, value: progressValue.value })
}
- // 清空选中项
- selected.value = []
- if (isMobile.value) {
+ // 失败项保持选中,方便用户修正条件后重试。
+ selected.value = failedItems
+ if (isMobile.value && failedItems.length === 0) {
mobileBatchMode.value = false
}
// 隐藏进度条
closeProgressDialog()
+ if (failedItems.length > 0) {
+ $toast.error(t('transferHistory.batchDeleteFailed', { failed: failedItems.length, total }))
+ }
// 重新获取数据
- await refreshDataAfterOperation()
+ await refreshDataAfterOperation(failedItems)
}
// 响应删除操作
@@ -865,6 +898,7 @@ async function triggerAiRedo(item: TransferHistory) {
let progressStarted = false
try {
const result: { [key: string]: any } = await api.post(`history/transfer/${item.id}/ai-redo`)
+ if (componentUnmounted) return
const progressKey = result.data?.progress_key
@@ -876,7 +910,9 @@ async function triggerAiRedo(item: TransferHistory) {
progressStarted = true
} catch (error) {
console.error(error)
- $toast.error(t('transferHistory.aiRedoFailed'))
+ if (!componentUnmounted) {
+ $toast.error(t('transferHistory.aiRedoFailed'))
+ }
} finally {
if (!progressStarted) {
aiRedoIds.value = aiRedoIds.value.filter(id => id !== item.id)
@@ -901,6 +937,7 @@ async function triggerBatchAiRedo() {
const result: { [key: string]: any } = await api.post('history/transfer/ai-redo', {
history_ids: historyIds,
})
+ if (componentUnmounted) return
const progressKey = result.data?.progress_key
const acceptedIds = (result.data?.history_ids as number[] | undefined) ?? historyIds
@@ -917,7 +954,9 @@ async function triggerBatchAiRedo() {
progressStarted = true
} catch (error) {
console.error(error)
- $toast.error(t('transferHistory.aiRedoFailed'))
+ if (!componentUnmounted) {
+ $toast.error(t('transferHistory.aiRedoFailed'))
+ }
} finally {
if (!progressStarted) {
aiRedoIds.value = aiRedoIds.value.filter(id => !historyIds.includes(id))
@@ -993,6 +1032,11 @@ async function reloadPage(resetPage = false) {
await router.push(createHistoryUrl(resetPage))
}
+// 移动端搜索同样以 URL 为持久事实源,刷新和断点切换后可恢复同一查询。
+async function reloadMobileSearchPage() {
+ await router.push(createHistoryUrl(true))
+}
+
// 确保值为number类型
function ensureNumber(value: any, defaultValue: number = 0) {
value = Number(value)
@@ -1354,6 +1398,7 @@ onActivated(() => {
})
onUnmounted(() => {
+ componentUnmounted = true
debouncedReloadPage.cancel()
debouncedReloadSearchPage.cancel()
debouncedReloadMobileSearchPage.cancel()
@@ -1704,14 +1749,15 @@ onUnmounted(() => {
-
+
-
+
diff --git a/src/views/reorganize/__tests__/TransferHistoryView.spec.ts b/src/views/reorganize/__tests__/TransferHistoryView.spec.ts
new file mode 100644
index 00000000..58349a41
--- /dev/null
+++ b/src/views/reorganize/__tests__/TransferHistoryView.spec.ts
@@ -0,0 +1,824 @@
+import type { TransferHistory } from '@/api/types'
+import i18n from '@/plugins/i18n'
+import TransferHistoryView from '@/views/reorganize/TransferHistoryView.vue'
+import { fireEvent, screen, waitFor } from '@testing-library/vue'
+import { renderWithProviders } from '@tests/support/render'
+import { flushPromises } from '@vue/test-utils'
+import { computed, defineComponent, h, nextTick, ref, unref, type PropType } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ apiDelete: vi.fn(),
+ apiGet: vi.fn(),
+ apiPost: vi.fn(),
+ appMode: false,
+ desktop: true,
+ dynamicButtonConfig: undefined as Record | undefined,
+ openSharedDialog: vi.fn(),
+ progressCallback: undefined as ((event: MessageEvent) => unknown) | undefined,
+ progressStart: vi.fn(),
+ progressStop: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('@/api', () => ({
+ default: {
+ delete: (...args: unknown[]) => mocks.apiDelete(...args),
+ get: (...args: unknown[]) => mocks.apiGet(...args),
+ post: (...args: unknown[]) => mocks.apiPost(...args),
+ },
+}))
+
+vi.mock('vue-toastification', () => ({
+ useToast: () => ({ error: mocks.toastError }),
+}))
+
+vi.mock('vuetify', async importOriginal => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useDisplay: () => ({
+ mdAndUp: computed(() => mocks.desktop),
+ smAndDown: computed(() => !mocks.desktop),
+ }),
+ }
+})
+
+vi.mock('@/composables/usePWA', () => ({
+ usePWA: () => ({ appMode: computed(() => mocks.appMode) }),
+}))
+
+vi.mock('@/composables/useAvailableHeight', () => ({
+ useAvailableHeight: () => ({ availableHeight: ref(600) }),
+}))
+
+vi.mock('@/composables/useBackground', () => ({
+ useBackground: () => ({
+ useProgressSSE: (_url: string, callback: (event: MessageEvent) => unknown) => {
+ mocks.progressCallback = callback
+ return {
+ start: mocks.progressStart,
+ stop: mocks.progressStop,
+ }
+ },
+ }),
+}))
+
+vi.mock('@/composables/useSharedDialog', () => ({
+ openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
+}))
+
+vi.mock('@/composables/useDynamicButton', () => ({
+ useDynamicButton: (config: Record) => {
+ mocks.dynamicButtonConfig = config
+ },
+}))
+
+const HistoryTableStub = defineComponent({
+ name: 'VDataTableVirtual',
+ props: {
+ headers: {
+ type: Array as PropType<
+ Array<{
+ key: string
+ sortRaw?: (left: TransferHistory, right: TransferHistory) => number
+ }>
+ >,
+ default: () => [],
+ },
+ items: {
+ type: Array as PropType,
+ default: () => [],
+ },
+ modelValue: {
+ type: Array as PropType,
+ default: () => [],
+ },
+ },
+ emits: ['update:modelValue'],
+ setup(props, { emit, slots }) {
+ return () => {
+ const sortResults =
+ props.items.length >= 2
+ ? Object.fromEntries(
+ props.headers
+ .filter(header => header.sortRaw)
+ .map(header => [header.key, header.sortRaw?.(props.items[0], props.items[1])]),
+ )
+ : {}
+
+ return h('section', { 'aria-label': '整理历史桌面列表' }, [
+ h('output', { 'aria-label': '整理历史排序结果' }, JSON.stringify(sortResults)),
+ ...props.items.map(item =>
+ h('article', { 'data-history-id': item.id }, [h('span', item.title), slots['item.actions']?.({ item })]),
+ ),
+ h(
+ 'button',
+ {
+ onClick: () => emit('update:modelValue', props.items),
+ type: 'button',
+ },
+ '选择当前页',
+ ),
+ ])
+ }
+ },
+})
+
+type InfiniteStatus = 'empty' | 'error' | 'ok'
+
+const InfiniteScrollStub = defineComponent({
+ name: 'VInfiniteScroll',
+ emits: ['load'],
+ setup(_props, { emit, slots }) {
+ const status = ref('idle')
+ function load() {
+ emit('load', {
+ done(nextStatus: InfiniteStatus) {
+ status.value = nextStatus
+ },
+ })
+ }
+ return () =>
+ h('section', { 'aria-label': '整理历史无限列表' }, [
+ h('output', { 'aria-label': '整理历史无限列表状态' }, status.value),
+ slots.default?.(),
+ h('button', { onClick: load, type: 'button' }, '加载下一页'),
+ ])
+ },
+})
+
+const ProgressiveGridStub = defineComponent({
+ name: 'ProgressiveCardGrid',
+ props: {
+ getItemKey: {
+ type: Function as PropType<(item: TransferHistory) => number>,
+ required: true,
+ },
+ items: {
+ type: Array as PropType,
+ default: () => [],
+ },
+ },
+ setup(props, { slots }) {
+ return () => {
+ const candidates =
+ props.items.length <= 7 ? props.items : [...props.items.slice(0, 3), props.items[24], ...props.items.slice(-3)]
+ const renderedItems = [...new Map(candidates.map(item => [item.id, item])).values()]
+
+ return h(
+ 'section',
+ { 'aria-label': '整理历史移动列表' },
+ renderedItems.flatMap(item => [
+ h('output', { 'data-mobile-key': props.getItemKey(item) }, String(props.getItemKey(item))),
+ ...(slots.default?.({ item }) ?? []),
+ ]),
+ )
+ }
+ },
+})
+
+const SearchStub = defineComponent({
+ name: 'VCombobox',
+ props: {
+ modelValue: {
+ type: String,
+ default: '',
+ },
+ },
+ emits: ['update:modelValue'],
+ setup(props, { attrs, emit }) {
+ return () =>
+ h('input', {
+ ...attrs,
+ value: props.modelValue ?? '',
+ onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
+ })
+ },
+})
+
+const PassthroughStub = defineComponent({
+ inheritAttrs: false,
+ setup(_props, { attrs, slots }) {
+ return () => h('div', attrs, slots.default?.())
+ },
+})
+
+const EmptyStub = defineComponent({
+ inheritAttrs: false,
+ setup() {
+ return () => h('div')
+ },
+})
+
+const ListItemStub = defineComponent({
+ name: 'VListItem',
+ inheritAttrs: false,
+ props: {
+ disabled: Boolean,
+ },
+ emits: ['click'],
+ setup(props, { emit, slots }) {
+ return () =>
+ h(
+ 'button',
+ {
+ disabled: props.disabled,
+ onClick: () => emit('click'),
+ type: 'button',
+ },
+ [slots.prepend?.(), slots.default?.()],
+ )
+ },
+})
+
+const ListItemTitleStub = defineComponent({
+ name: 'VListItemTitle',
+ inheritAttrs: false,
+ setup(_props, { attrs, slots }) {
+ return () => h('span', attrs, slots.default?.())
+ },
+})
+
+function createHistory(id: number, title: string, overrides: Partial = {}): TransferHistory {
+ return {
+ id,
+ title,
+ type: '电影',
+ status: true,
+ src: `/downloads/${title}.mkv`,
+ dest: `/media/${title}.mkv`,
+ src_storage: 'downloads',
+ dest_storage: 'library',
+ mode: 'link',
+ ...overrides,
+ } as TransferHistory
+}
+
+function historyResponse(list: TransferHistory[], total = list.length) {
+ return { data: { list, total }, success: true }
+}
+
+function storageResponse() {
+ return { data: { value: [] }, success: true }
+}
+
+function createDeferred() {
+ let resolve!: (value: T) => void
+ let reject!: (reason?: unknown) => void
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise
+ reject = rejectPromise
+ })
+ return { promise, reject, resolve }
+}
+
+async function renderHistory(initialRoute = '/history') {
+ return renderWithProviders(TransferHistoryView, {
+ global: {
+ stubs: {
+ ProgressiveCardGrid: ProgressiveGridStub,
+ VCombobox: SearchStub,
+ VDataTableVirtual: HistoryTableStub,
+ VInfiniteScroll: InfiniteScrollStub,
+ IconBtn: PassthroughStub,
+ VList: PassthroughStub,
+ VListItem: ListItemStub,
+ VListItemTitle: ListItemTitleStub,
+ VMenu: PassthroughStub,
+ VPageContentTitle: true,
+ VPagination: EmptyStub,
+ VSelect: EmptyStub,
+ },
+ },
+ initialRoute,
+ initialState: {
+ globalSettings: {
+ data: {
+ AI_AGENT_ENABLE: true,
+ GLOBAL_IMAGE_CACHE: false,
+ },
+ },
+ user: {
+ permissions: ['manage'],
+ superUser: true,
+ },
+ },
+ })
+}
+
+function getDynamicMenuItems() {
+ const menuItems = mocks.dynamicButtonConfig?.menuItems
+ return unref(menuItems) as
+ | Array<{
+ action: () => unknown
+ titleParams?: Record
+ titleKey: string
+ }>
+ | undefined
+}
+
+function runDynamicAction(titleKey: string) {
+ const item = getDynamicMenuItems()?.find(menu => menu.titleKey === titleKey)
+ if (!item) throw new Error(`未注册动态按钮操作: ${titleKey}`)
+ return item.action()
+}
+
+function getDialogCall(index = 0) {
+ const [component, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
+ { __name?: string; name?: string },
+ Record,
+ Record unknown>,
+ Record,
+ ]
+ return { component, events, options, props }
+}
+
+describe('TransferHistoryView', () => {
+ beforeEach(() => {
+ mocks.appMode = true
+ mocks.desktop = true
+ mocks.dynamicButtonConfig = undefined
+ mocks.progressCallback = undefined
+ mocks.apiDelete.mockResolvedValue({ success: true })
+ mocks.apiGet.mockImplementation((path: string) => {
+ if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
+ return Promise.resolve(historyResponse([]))
+ })
+ mocks.apiPost.mockResolvedValue({ data: { history_ids: [1], progress_key: 'progress-1' }, success: true })
+ mocks.openSharedDialog.mockImplementation(() => ({
+ close: vi.fn(),
+ id: 1,
+ updateProps: vi.fn(),
+ }))
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ it('uses the desktop URL query as the request source and falls back from invalid pagination values', async () => {
+ const requests: Array> = []
+ mocks.apiGet.mockImplementation((path: string, config?: { params?: Record }) => {
+ if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
+ requests.push(config?.params ?? {})
+ return Promise.resolve(historyResponse([createHistory(1, '桌面结果')], 51))
+ })
+
+ await renderHistory('/history?search=%E7%A7%91%E5%B9%BB&itemsPerPage=30¤tPage=-8&grouped=true')
+
+ expect(await screen.findByText('桌面结果')).toBeInTheDocument()
+ expect(requests).toEqual([{ count: 50, page: 1, title: '科幻' }])
+ })
+
+ it('prevents an older desktop request from replacing a newer route search', async () => {
+ const oldRequest = createDeferred>()
+ const newRequest = createDeferred>()
+ let historyCalls = 0
+ mocks.apiGet.mockImplementation((path: string) => {
+ if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
+ historyCalls += 1
+ return historyCalls === 1 ? oldRequest.promise : newRequest.promise
+ })
+
+ const { router } = await renderHistory('/history?search=old')
+ await router.push('/history?search=new')
+ newRequest.resolve(historyResponse([createHistory(2, '新结果')]))
+
+ expect(await screen.findByText('新结果')).toBeInTheDocument()
+ oldRequest.resolve(historyResponse([createHistory(1, '旧结果')]))
+ await flushPromises()
+ expect(screen.queryByText('旧结果')).not.toBeInTheDocument()
+ })
+
+ it('exposes title, episode, and source-size ordering through desktop table headers', async () => {
+ const histories = [
+ createHistory(1, '同名剧集', {
+ episodes: 'E02',
+ seasons: 'S01',
+ src_fileitem: { size: 100 } as TransferHistory['src_fileitem'],
+ type: '电视剧',
+ }),
+ createHistory(2, '同名剧集', {
+ episodes: 'E01',
+ seasons: 'S02',
+ src_fileitem: { size: 200 } as TransferHistory['src_fileitem'],
+ type: '电视剧',
+ }),
+ ]
+ mocks.apiGet.mockImplementation((path: string) => {
+ if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
+ return Promise.resolve(historyResponse(histories))
+ })
+
+ await renderHistory()
+
+ expect(await screen.findAllByText('同名剧集')).toHaveLength(2)
+ const sortResults = JSON.parse(screen.getByRole('status', { name: '整理历史排序结果' }).textContent || '{}')
+ expect(sortResults).toEqual({ size: -100, title: -1 })
+ })
+
+ it('persists desktop search through the same route-backed reload contract', async () => {
+ vi.useFakeTimers()
+ const { router } = await renderHistory('/history')
+ await fireEvent.update(screen.getByLabelText('搜索(支持 * ? 通配符)'), 'desktop-query')
+ await vi.advanceTimersByTimeAsync(1000)
+ await flushPromises()
+
+ expect(router.currentRoute.value.query).toMatchObject({
+ currentPage: '1',
+ itemsPerPage: '50',
+ search: 'desktop-query',
+ })
+ })
+
+ it('loads mobile pages with deduplication and reports empty when the last page is exhausted', async () => {
+ mocks.desktop = false
+ const firstPage = Array.from({ length: 25 }, (_, index) => createHistory(index + 1, `记录 ${index + 1}`))
+ const secondPage = [
+ createHistory(25, '重复记录'),
+ ...Array.from({ length: 4 }, (_, index) => createHistory(26 + index, `追加 ${index + 1}`)),
+ ]
+ let historyCalls = 0
+ mocks.apiGet.mockImplementation((path: string) => {
+ if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
+ historyCalls += 1
+ return Promise.resolve(historyResponse(historyCalls === 1 ? firstPage : secondPage, 29))
+ })
+
+ await renderHistory('/history?search=%E7%A7%BB%E5%8A%A8')
+
+ await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
+ expect(await screen.findByText('记录 25')).toBeInTheDocument()
+ await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
+ expect(await screen.findByText('追加 4')).toBeInTheDocument()
+ expect(document.querySelectorAll('[data-mobile-key="25"]')).toHaveLength(1)
+ expect(screen.getByRole('status', { name: '整理历史无限列表状态' })).toHaveTextContent('empty')
+ })
+
+ it('keeps the mobile infinite list retryable after a request error', async () => {
+ mocks.desktop = false
+ let historyCalls = 0
+ mocks.apiGet.mockImplementation((path: string) => {
+ if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
+ historyCalls += 1
+ if (historyCalls === 1) return Promise.reject(new Error('temporary failure'))
+ return Promise.resolve(historyResponse([createHistory(1, '重试结果')]))
+ })
+
+ await renderHistory()
+
+ await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
+ await waitFor(() => expect(screen.getByRole('status', { name: '整理历史无限列表状态' })).toHaveTextContent('error'))
+ await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
+ expect(await screen.findByText('重试结果')).toBeInTheDocument()
+ })
+
+ it('formats mobile cards and keeps path and batch selection interactions reversible', async () => {
+ mocks.desktop = false
+ const item = createHistory(1, '移动剧集', {
+ category: '动画',
+ date: '2025-01-02 03:04:00',
+ episodes: 'E03',
+ errmsg: '目标路径不可用',
+ image: '/poster.jpg',
+ seasons: 'S01',
+ status: false,
+ type: '电视剧',
+ year: '2025',
+ })
+ mocks.apiGet.mockImplementation((path: string) => {
+ if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
+ return Promise.resolve(historyResponse([item]))
+ })
+
+ const { container } = await renderHistory()
+ await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
+
+ expect(await screen.findByText('移动剧集 S01E03')).toBeInTheDocument()
+ expect(screen.getByText('动画 / 2025')).toBeInTheDocument()
+ expect(screen.getByText('目标路径不可用')).toBeInTheDocument()
+ const pathButton = container.querySelector('.transfer-history-mobile-record__paths') as HTMLButtonElement
+ await fireEvent.click(pathButton)
+ expect(pathButton).toHaveClass('transfer-history-mobile-record__paths--expanded')
+ await fireEvent.click(pathButton)
+ expect(pathButton).not.toHaveClass('transfer-history-mobile-record__paths--expanded')
+
+ await fireEvent.click(screen.getByRole('button', { name: '批量选择' }))
+ const record = container.querySelector('.transfer-history-mobile-record') as HTMLElement
+ await fireEvent.click(record)
+ expect(record).toHaveClass('transfer-history-mobile-record--selected')
+ runDynamicAction('transferHistory.actions.deselectAll')
+ await nextTick()
+ expect(record).not.toHaveClass('transfer-history-mobile-record--selected')
+ runDynamicAction('transferHistory.actions.exitBatchMode')
+ await nextTick()
+ expect(screen.getByRole('button', { name: '批量选择' })).toBeInTheDocument()
+ })
+
+ it('prevents a mobile request invalidated by a route reset from appending stale records', async () => {
+ mocks.desktop = false
+ const oldRequest = createDeferred>()
+ const newRequest = createDeferred>()
+ let historyCalls = 0
+ mocks.apiGet.mockImplementation((path: string) => {
+ if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
+ historyCalls += 1
+ return historyCalls === 1 ? oldRequest.promise : newRequest.promise
+ })
+
+ const { router } = await renderHistory('/history?search=old')
+ await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
+ await router.push('/history?search=new')
+ await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
+ newRequest.resolve(historyResponse([createHistory(2, '移动新结果')]))
+
+ expect(await screen.findByText('移动新结果')).toBeInTheDocument()
+ oldRequest.resolve(historyResponse([createHistory(1, '移动旧结果')]))
+ await flushPromises()
+ expect(screen.queryByText('移动旧结果')).not.toBeInTheDocument()
+ })
+
+ it('persists mobile search in the URL before resetting the infinite list', async () => {
+ vi.useFakeTimers()
+ mocks.desktop = false
+ const { router } = await renderHistory('/history?search=old')
+ await flushPromises()
+
+ await fireEvent.update(screen.getByLabelText('搜索(支持 * ? 通配符)'), 'new')
+ await vi.advanceTimersByTimeAsync(600)
+
+ expect(router.currentRoute.value).toMatchObject({
+ path: '/history',
+ query: { currentPage: '1', itemsPerPage: '50', search: 'new' },
+ })
+ })
+
+ it('summarizes batch deletion failures, retains failed selections, and never renders undefined progress text', async () => {
+ const histories = [createHistory(1, '成功项'), createHistory(2, '业务失败项'), createHistory(3, '异常失败项')]
+ mocks.apiGet.mockImplementation((path: string) => {
+ if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
+ return Promise.resolve(historyResponse(histories))
+ })
+ mocks.apiDelete
+ .mockResolvedValueOnce({ success: true })
+ .mockResolvedValueOnce({ message: '记录被占用', success: false })
+ .mockRejectedValueOnce(new Error('delete unavailable'))
+
+ await renderHistory()
+ expect(await screen.findByText('成功项')).toBeInTheDocument()
+ await fireEvent.click(screen.getByRole('button', { name: '选择当前页' }))
+ await nextTick()
+ runDynamicAction('transferHistory.actions.batchDelete')
+
+ const deleteDialog = getDialogCall()
+ await deleteDialog.events.delete(false, false)
+ await flushPromises()
+
+ const progressDialog = getDialogCall(1)
+ const controller = mocks.openSharedDialog.mock.results[1]?.value as { updateProps: ReturnType }
+ expect(progressDialog.component.__name || progressDialog.component.name).toContain('ProgressDialog')
+ expect(controller.updateProps).toHaveBeenCalled()
+ expect(
+ controller.updateProps.mock.calls
+ .flatMap(call => Object.values(call[0]))
+ .every(value => {
+ return typeof value !== 'string' || !value.includes('undefined')
+ }),
+ ).toBe(true)
+ expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('2'))
+ expect(getDynamicMenuItems()?.some(item => item.titleKey === 'transferHistory.actions.batchDelete')).toBe(true)
+ })
+
+ it('shows the existing toast-style feedback when a single deletion request throws', 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.mockRejectedValueOnce(new Error('delete unavailable'))
+
+ await renderHistory()
+ expect(await screen.findByText('异常删除')).toBeInTheDocument()
+ i18n.global.locale.value = 'en-US'
+ await fireEvent.click(screen.getByRole('button', { name: '删除' }))
+ await getDialogCall().events.delete(false, false)
+
+ expect(mocks.toastError).toHaveBeenCalledWith('Failed to delete: Request failed')
+ })
+
+ it('releases delete-dialog ownership when either close contract fires', 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]))
+ })
+
+ await renderHistory()
+ expect(await screen.findByText('删除弹窗生命周期')).toBeInTheDocument()
+ await fireEvent.click(screen.getByRole('button', { name: '删除' }))
+ const firstController = mocks.openSharedDialog.mock.results[0]?.value as { close: ReturnType }
+ getDialogCall().events['update:modelValue'](false)
+ await fireEvent.click(screen.getByRole('button', { name: '删除' }))
+ expect(firstController.close).not.toHaveBeenCalled()
+
+ getDialogCall(1).events.close()
+ await fireEvent.click(screen.getByRole('button', { name: '删除' }))
+ const secondController = mocks.openSharedDialog.mock.results[1]?.value as { close: ReturnType }
+ expect(secondController.close).not.toHaveBeenCalled()
+ })
+
+ it('moves back to the last available page after deleting the only record on the current page', async () => {
+ const item = createHistory(26, '第二页唯一记录')
+ const requestedPages: number[] = []
+ mocks.apiGet.mockImplementation((path: string, config?: { params?: { page?: number } }) => {
+ if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
+ const page = config?.params?.page ?? 1
+ requestedPages.push(page)
+ if (requestedPages.length === 1) return Promise.resolve(historyResponse([item], 26))
+ return Promise.resolve(historyResponse([], 25))
+ })
+
+ const { router } = await renderHistory('/history?itemsPerPage=25¤tPage=2')
+ expect(await screen.findByText('第二页唯一记录')).toBeInTheDocument()
+ await fireEvent.click(screen.getByRole('button', { name: '删除' }))
+ await getDialogCall().events.delete(false, false)
+ await waitFor(() => expect(router.currentRoute.value.query.currentPage).toBe('1'))
+ await waitFor(() => expect(requestedPages).toEqual([2, 2, 1]))
+ })
+
+ it('keeps failed mobile batch identities selected after resetting and reloading the list', async () => {
+ mocks.desktop = false
+ const histories = [createHistory(1, '删除成功'), createHistory(2, '保留甲'), createHistory(3, '保留乙')]
+ let historyCalls = 0
+ mocks.apiGet.mockImplementation((path: string) => {
+ if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
+ historyCalls += 1
+ return Promise.resolve(historyResponse(historyCalls === 1 ? histories : histories.slice(1)))
+ })
+ mocks.apiDelete
+ .mockResolvedValueOnce({ success: true })
+ .mockResolvedValueOnce({ message: 'occupied', success: false })
+ .mockRejectedValueOnce(new Error('delete unavailable'))
+
+ await renderHistory()
+ await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
+ expect(await screen.findByText('删除成功')).toBeInTheDocument()
+ await fireEvent.click(screen.getByRole('button', { name: '批量选择' }))
+ runDynamicAction('transferHistory.actions.selectAll')
+ await nextTick()
+ runDynamicAction('transferHistory.actions.batchDelete')
+ i18n.global.locale.value = 'zh-TW'
+ await getDialogCall().events.delete(false, false)
+
+ expect(screen.getByRole('button', { name: '退出批量選擇' })).toBeInTheDocument()
+ expect(getDynamicMenuItems()?.find(item => item.titleKey === 'transferHistory.selectedCount')?.titleParams).toEqual(
+ { count: 0, total: 0 },
+ )
+ expect(mocks.toastError).toHaveBeenCalledWith('刪除失敗:2/3')
+
+ await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
+ expect(await screen.findByText('保留甲')).toBeInTheDocument()
+ expect(getDynamicMenuItems()?.find(item => item.titleKey === 'transferHistory.selectedCount')?.titleParams).toEqual(
+ { count: 2, total: 2 },
+ )
+ })
+
+ it('opens the transfer queue as an isolated shared-dialog boundary', async () => {
+ await renderHistory()
+ const onClick = mocks.dynamicButtonConfig?.onClick as (() => unknown) | undefined
+ onClick?.()
+
+ const dialog = getDialogCall()
+ expect(dialog.component.__name || dialog.component.name).toContain('TransferQueueDialog')
+ expect(dialog.options).toEqual({ closeOn: ['close'] })
+ })
+
+ it('opens the reorganize boundary with selected ids and refreshes after done', async () => {
+ const histories = [createHistory(10, '重整甲'), createHistory(11, '重整乙')]
+ let historyCalls = 0
+ mocks.apiGet.mockImplementation((path: string) => {
+ if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
+ historyCalls += 1
+ return Promise.resolve(historyResponse(histories))
+ })
+
+ await renderHistory()
+ expect(await screen.findByText('重整甲')).toBeInTheDocument()
+ await fireEvent.click(screen.getByRole('button', { name: '选择当前页' }))
+ await nextTick()
+ runDynamicAction('transferHistory.actions.batchRedo')
+
+ const dialog = getDialogCall()
+ expect(dialog.props).toMatchObject({ logids: [10, 11] })
+ expect(dialog.options).toEqual({ closeOn: ['close', 'done'] })
+ await dialog.events.done()
+ expect(historyCalls).toBeGreaterThan(1)
+ expect(getDynamicMenuItems()).toBeUndefined()
+ })
+
+ it('owns the batch AI redo SSE lifecycle through the progress boundary', async () => {
+ const histories = [createHistory(1, 'AI 重整')]
+ let historyCalls = 0
+ mocks.apiGet.mockImplementation((path: string) => {
+ if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
+ historyCalls += 1
+ return Promise.resolve(historyResponse(histories))
+ })
+
+ const { unmount } = await renderHistory()
+ expect(await screen.findByText('AI 重整')).toBeInTheDocument()
+ await fireEvent.click(screen.getByRole('button', { name: '选择当前页' }))
+ await nextTick()
+ runDynamicAction('transferHistory.actions.batchAiRedo')
+ await flushPromises()
+
+ expect(mocks.apiPost).toHaveBeenCalledWith('history/transfer/ai-redo', { history_ids: [1] })
+ expect(mocks.progressStart).toHaveBeenCalledOnce()
+ expect(mocks.progressCallback).toBeTypeOf('function')
+
+ await mocks.progressCallback?.(
+ new MessageEvent('message', {
+ data: JSON.stringify({ enable: true, text: '处理中' }),
+ }),
+ )
+ const progressController = mocks.openSharedDialog.mock.results[0]?.value as {
+ close: ReturnType
+ updateProps: ReturnType
+ }
+ expect(progressController.updateProps).toHaveBeenCalledWith({ text: '处理中' })
+
+ await mocks.progressCallback?.(
+ new MessageEvent('message', {
+ data: JSON.stringify({ data: { error: 'AI 失败', success: false }, enable: false }),
+ }),
+ )
+ expect(mocks.progressStop).toHaveBeenCalledOnce()
+ expect(progressController.close).toHaveBeenCalledOnce()
+ expect(mocks.toastError).toHaveBeenCalledWith('AI 失败')
+ expect(historyCalls).toBeGreaterThan(1)
+
+ unmount()
+ expect(mocks.progressStop).toHaveBeenCalled()
+ })
+
+ it('starts the single AI redo progress boundary with the accepted progress key', async () => {
+ const item = createHistory(7, '单条 AI')
+ mocks.apiGet.mockImplementation((path: string) => {
+ if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
+ return Promise.resolve(historyResponse([item]))
+ })
+ mocks.apiPost.mockResolvedValueOnce({ data: { progress_key: 'single-progress' }, success: true })
+
+ await renderHistory()
+ expect(await screen.findByText('单条 AI')).toBeInTheDocument()
+ await fireEvent.click(screen.getByRole('button', { name: '智能助手整理' }))
+ await flushPromises()
+
+ expect(mocks.apiPost).toHaveBeenCalledWith('history/transfer/7/ai-redo')
+ expect(mocks.progressStart).toHaveBeenCalledOnce()
+ expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
+ })
+
+ it('does not start a single AI redo progress boundary when its POST resolves after unmount', async () => {
+ const item = createHistory(1, '卸载中的单条 AI')
+ const pending = createDeferred<{ data: { progress_key: string }; success: boolean }>()
+ mocks.apiGet.mockImplementation((path: string) => {
+ if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
+ return Promise.resolve(historyResponse([item]))
+ })
+ mocks.apiPost.mockReturnValueOnce(pending.promise)
+
+ const { unmount } = await renderHistory()
+ expect(await screen.findByText('卸载中的单条 AI')).toBeInTheDocument()
+ await fireEvent.click(screen.getByRole('button', { name: '智能助手整理' }))
+ unmount()
+ pending.resolve({ data: { progress_key: 'late-single' }, success: true })
+ await flushPromises()
+
+ expect(mocks.progressStart).not.toHaveBeenCalled()
+ expect(mocks.openSharedDialog).not.toHaveBeenCalled()
+ })
+
+ it('does not start a batch AI redo progress boundary when its POST resolves after unmount', async () => {
+ const item = createHistory(1, '卸载中的批量 AI')
+ const pending = createDeferred<{
+ data: { history_ids: number[]; progress_key: string }
+ success: boolean
+ }>()
+ mocks.apiGet.mockImplementation((path: string) => {
+ if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
+ return Promise.resolve(historyResponse([item]))
+ })
+ mocks.apiPost.mockReturnValueOnce(pending.promise)
+
+ const { unmount } = await renderHistory()
+ expect(await screen.findByText('卸载中的批量 AI')).toBeInTheDocument()
+ await fireEvent.click(screen.getByRole('button', { name: '选择当前页' }))
+ await nextTick()
+ runDynamicAction('transferHistory.actions.batchAiRedo')
+ unmount()
+ pending.resolve({ data: { history_ids: [1], progress_key: 'late-batch' }, success: true })
+ await flushPromises()
+
+ expect(mocks.progressStart).not.toHaveBeenCalled()
+ expect(mocks.openSharedDialog).not.toHaveBeenCalled()
+ })
+})
diff --git a/vite.config.ts b/vite.config.ts
index 6d05eeb3..ed7a5e10 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -334,6 +334,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/components/cards/DownloadingCard.vue',
'src/components/slide/VirtualSlideView.vue',
'src/views/discover/PersonCardSlideView.vue',
+ 'src/views/reorganize/TransferHistoryView.vue',
'src/utils/mediaStatusCache.ts',
'src/utils/searchStream.ts',
'src/views/site/SiteCardListView.vue',
@@ -432,6 +433,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 80,
statements: 80,
},
+ 'src/views/reorganize/TransferHistoryView.vue': {
+ branches: 75,
+ functions: 80,
+ lines: 80,
+ statements: 80,
+ },
'src/components/dialog/SubscribeShareStatisticsDialog.vue': {
branches: 75,
functions: 80,