diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 3446621a..b542b114 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -746,9 +746,6 @@ } }, "src/views/reorganize/TransferHistoryView.vue": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - }, "vue/no-v-text-v-html-on-component": { "count": 3 } diff --git a/src/views/reorganize/TransferHistoryView.vue b/src/views/reorganize/TransferHistoryView.vue index dd3cb1d4..7e65b56d 100644 --- a/src/views/reorganize/TransferHistoryView.vue +++ b/src/views/reorganize/TransferHistoryView.vue @@ -75,7 +75,7 @@ const aiRedoIds = ref([]) // AI整理进度 const aiRedoProgressActive = ref(false) const aiRedoProgressText = ref(t('transferHistory.actions.aiRedoPending')) -const aiRedoProgressSSE = ref(null) +const aiRedoProgressSSE = ref | null>(null) const aiRedoProgressHistoryIds = ref([]) let aiRedoProgressDialogController: ReturnType | null = null let progressDialogController: ReturnType | null = null @@ -88,6 +88,13 @@ const redoTargetStorage = ref() // 已选中的数据 const selected = ref([]) +interface TransferHistoryDisplayItem extends TransferHistory { + history_group_is_music_album: boolean + history_group_key: string + history_group_label: string + history_group_track_key: string +} + // 当前删除尝试已完成的文件步骤,页面刷新后由后端“已不存在”状态重新确认。 const completedDeleteSteps = new Map() @@ -256,7 +263,7 @@ const pageRange = [ const pageRangeValues = pageRange.map(item => item.value) // 数据列表 -const dataList = ref([]) +const dataList = ref([]) // 移动端历史记录列表,独立于桌面分页表格。 const mobileDataList = ref([]) @@ -298,11 +305,13 @@ const totalItems = ref(0) // 是否要分组 const group = ref(route.query.grouped === 'true') +// 区分默认平铺与用户显式选择平铺,避免搜索/翻页提前关闭后续专辑自动分组。 +const groupPreferenceExplicit = ref(route.query.grouped !== undefined) // 分组条件 -const groupBy = ref([ +const groupBy = ref>([ { - key: 'title', + key: 'history_group_key', }, ]) @@ -516,6 +525,72 @@ watch(isDesktop, desktop => { } }) +// 统一 Windows 与 POSIX 路径后返回父目录;音乐整理后的父目录就是播放器使用的专辑目录。 +function normalizeHistoryPath(path?: string) { + let normalized = (path || '').replaceAll('\\', '/') + while (normalized.endsWith('/')) normalized = normalized.slice(0, -1) + return normalized +} + +function getHistoryParentPath(path?: string) { + const normalized = normalizeHistoryPath(path) + const separator = normalized.lastIndexOf('/') + return separator > 0 ? normalized.slice(0, separator) : '' +} + +// 从完整路径中提取分组标题,保留整理规则生成的专辑名与年份。 +function getHistoryPathName(path: string) { + return path.split('/').filter(Boolean).at(-1) || path +} + +// 音乐按目标专辑目录分组,旧记录或失败记录则回退到源目录;其它媒体保持原有的标题分组。 +function toHistoryDisplayItem(item: TransferHistory): TransferHistoryDisplayItem { + if (item.type === '音乐') { + const candidates = item.status + ? ([ + [item.dest, item.dest_storage], + [item.src, item.src_storage], + ] as const) + : item.src + ? ([[item.src, item.src_storage]] as const) + : ([[item.dest, item.dest_storage]] as const) + for (const [path, storage] of candidates) { + const normalizedPath = normalizeHistoryPath(path) + const albumPath = getHistoryParentPath(normalizedPath) + if (!albumPath) continue + return { + ...item, + history_group_is_music_album: true, + history_group_key: `music:${JSON.stringify([storage || '', albumPath])}`, + history_group_label: getHistoryPathName(albumPath), + history_group_track_key: JSON.stringify([storage || '', normalizedPath]), + } + } + } + + const title = item.title || t('common.unknown') + return { + ...item, + history_group_is_music_album: false, + history_group_key: `title:${JSON.stringify(item.title ?? null)}`, + history_group_label: title, + history_group_track_key: `history:${item.id}`, + } +} + +// 当前页出现同一专辑的多首音乐时,首次访问自动切换到可展开的分组视图。 +function hasMusicAlbumGroup(items: TransferHistoryDisplayItem[]) { + const tracksByAlbum = new Map>() + for (const item of items) { + if (!item.history_group_is_music_album) continue + const tracks = tracksByAlbum.get(item.history_group_key) || new Set() + tracks.add(item.history_group_track_key) + if (tracks.size > 1) return true + tracksByAlbum.set(item.history_group_key, tracks) + } + return false +} + // 获取历史记录数据,keep-alive 重新进入时可静默刷新,避免表格出现重新加载感。 async function fetchData(page = currentPage.value, count = itemsPerPage.value, options: { silent?: boolean } = {}) { const requestSeed = ++fetchDataRequestSeed @@ -538,9 +613,10 @@ async function fetchData(page = currentPage.value, count = itemsPerPage.value, o const list = Array.isArray(result.list) ? result.list : [] isRefreshed.value = true - dataList.value = list + const displayList = list.map(toHistoryDisplayItem) + dataList.value = displayList if (isDesktop.value && selected.value.length > 0) { - const refreshedItems = new Map(list.map(item => [item.id, item])) + const refreshedItems = new Map(displayList.map(item => [item.id, item])) selected.value = selected.value.flatMap(item => { const refreshed = refreshedItems.get(item.id) return refreshed ? [refreshed] : [] @@ -549,6 +625,10 @@ async function fetchData(page = currentPage.value, count = itemsPerPage.value, o totalItems.value = ensureNumber(result.total, 0) updateSearchHintList(list) + if (isDesktop.value && route.query.grouped === undefined && hasMusicAlbumGroup(displayList)) { + group.value = true + } + return { list, total: totalItems.value, @@ -681,12 +761,14 @@ function resetMobileHistory(options: MobileHistoryResetOptions = {}) { mobileInfiniteKey.value++ } -// 移动端只从路由同步搜索词,不接收桌面分页和分组状态。 +// 移动端不启用桌面分组视图,但保留其显式偏好,避免搜索时从地址栏丢失。 function syncMobileSearchFromRouteQuery() { syncingRouteQuery = true try { search.value = getRouteQueryString(route.query.search) statusFilter.value = getRouteStatusFilter(route.query.status) + group.value = route.query.grouped === 'true' + groupPreferenceExplicit.value = route.query.grouped !== undefined } finally { void nextTick(() => { syncingRouteQuery = false @@ -786,6 +868,7 @@ async function syncStateFromRouteQuery() { itemsPerPage.value = ensurePageSize(route.query.itemsPerPage, 50) currentPage.value = Math.max(1, ensureNumber(route.query.currentPage, 1)) group.value = route.query.grouped === 'true' + groupPreferenceExplicit.value = route.query.grouped !== undefined } finally { await nextTick() syncingRouteQuery = false @@ -1227,8 +1310,8 @@ function createHistoryUrl(resetPage = false, page = resetPage ? 1 : currentPage. if (page) { query.currentPage = String(page) } - if (group.value) { - query.grouped = 'true' + if (group.value || groupPreferenceExplicit.value) { + query.grouped = String(group.value) } return { @@ -1247,18 +1330,21 @@ async function reloadMobileSearchPage() { await router.push(createHistoryUrl(true)) } +// 只有工具栏按钮切换才属于显式偏好;数据驱动的自动分组不把默认平铺误记为用户选择。 +function toggleHistoryGrouping() { + groupPreferenceExplicit.value = true + group.value = !group.value +} + // 确保值为number类型 -function ensureNumber(value: any, defaultValue: number = 0) { - value = Number(value) +function ensureNumber(value: unknown, defaultValue: number = 0) { + const numberValue = Number(value) // 如果不是数字 - if (Number.isNaN(value)) { - value = defaultValue - } - return value + return Number.isNaN(numberValue) ? defaultValue : numberValue } // 校验分页条数,避免地址栏参数超出可选范围。 -function ensurePageSize(value: any, defaultValue: number = 50) { +function ensurePageSize(value: unknown, defaultValue: number = 50) { const pageSize = ensureNumber(value, defaultValue) return pageRangeValues.includes(pageSize) ? pageSize : defaultValue } @@ -1437,20 +1523,34 @@ function deselectAllMobileHistory() { updateHistorySelection(mobileDataList.value, false) } -// 按标题分组后的选中数量统计,键为标题,值为对应分组的选中数 -const selectedCountsGroupedByTitle = computed(() => { +// 按展示分组统计选中数量,音乐专辑使用目录身份,其它媒体沿用标题身份。 +const selectedCountsGroupedByKey = computed(() => { return selected.value.reduce( (acc, item) => { - const title = item.title || '' - acc[title] = (acc[title] || 0) + 1 + const key = (item as Partial).history_group_key || item.title || '' + acc[key] = (acc[key] || 0) + 1 return acc }, {} as Record, ) }) +interface TransferHistoryGroupItem { + value: TransferHistoryDisplayItem +} + +// Vuetify 分组项包装了原始记录,标题取第一条记录的展示标签。 +function getHistoryGroupLabel(items: readonly TransferHistoryGroupItem[]) { + return items[0]?.value?.history_group_label || t('common.unknown') +} + +// 曲目数量只属于音乐专辑组,非音乐标题组保持原有展示。 +function isMusicAlbumGroup(items: readonly TransferHistoryGroupItem[]) { + return Boolean(items[0]?.value?.history_group_is_music_album) +} + // 控制分组内所有子项的选中状态 -const toggleGroupSelection = (checked: boolean | null, items: readonly any[]) => { +const toggleGroupSelection = (checked: boolean | null, items: readonly TransferHistoryGroupItem[]) => { const values = items.map(item => item.value) updateHistorySelection(values, checked) } @@ -1598,6 +1698,7 @@ onMounted(() => { if (isDesktop.value) { void refreshDataFromRouteQuery() } else { + syncMobileSearchFromRouteQuery() resetMobileHistory() } }) @@ -1670,7 +1771,10 @@ onUnmounted(() => { - + @@ -1706,11 +1810,17 @@ onUnmounted(() => { @click="toggleGroup(item)" /> - {{ item.value }} + {{ getHistoryGroupLabel(item.items) }} + + {{ t('music.trackCount', { count: item.items.length }) }} + diff --git a/src/views/reorganize/__tests__/TransferHistoryView.spec.ts b/src/views/reorganize/__tests__/TransferHistoryView.spec.ts index eb8913cc..c7d742a4 100644 --- a/src/views/reorganize/__tests__/TransferHistoryView.spec.ts +++ b/src/views/reorganize/__tests__/TransferHistoryView.spec.ts @@ -117,10 +117,17 @@ const HistoryTableStub = defineComponent({ return h('section', { 'aria-label': '整理历史桌面列表' }, [ h('output', { 'aria-label': '整理历史排序结果' }, JSON.stringify(sortResults)), ...props.items.map(item => - h('article', { 'data-history-id': item.id }, [ - item.image ? (slots['item.title']?.({ item }) ?? h('span', item.title)) : h('span', item.title), - slots['item.actions']?.({ item }), - ]), + h( + 'article', + { + 'data-history-group-key': (item as TransferHistory & { history_group_key?: string }).history_group_key, + 'data-history-id': item.id, + }, + [ + item.image ? (slots['item.title']?.({ item }) ?? h('span', item.title)) : h('span', item.title), + slots['item.actions']?.({ item }), + ], + ), ), h( 'button', @@ -446,6 +453,217 @@ describe('TransferHistoryView', () => { await waitFor(() => expect(requests).toEqual([{ count: 50, page: 1, status: false, title: '失败' }])) }) + it('automatically groups multiple music tracks by their organized album directory', async () => { + const tracks = [ + createHistory(1, '女骑士', { + dest: '/media/徐良/情话 (2013)/01 - 女骑士.flac', + src: '/downloads/徐良 情话/01 - 女骑士.flac', + type: '音乐', + }), + createHistory(2, '悲伤的李白', { + dest: '/media/徐良/情话 (2013)/02 - 悲伤的李白.flac', + src: '/downloads/徐良 情话/02 - 悲伤的李白.flac', + type: '音乐', + }), + ] + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse(tracks)) + }) + + const { container, router } = await renderHistory('/history') + + await waitFor(() => expect(router.currentRoute.value.query.grouped).toBe('true')) + const rows = [...container.querySelectorAll('[data-history-group-key]')] + expect(rows).toHaveLength(2) + expect(rows[0]?.dataset.historyGroupKey).toBe(rows[1]?.dataset.historyGroupKey) + expect(rows[0]?.dataset.historyGroupKey).toContain('/media/徐良/情话 (2013)') + }) + + it('groups failed music records by their source album directory and source storage', async () => { + const tracks = [ + createHistory(1, 'Track 1', { + dest: '/media/Artist/Predicted Album A/01.flac', + src: '/downloads/Album Bundle/01.flac', + status: false, + type: '音乐', + }), + createHistory(2, 'Track 2', { + dest: '/media/Artist/Predicted Album B/02.flac', + src: '/downloads/Album Bundle/02.flac', + status: false, + type: '音乐', + }), + ] + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse(tracks)) + }) + + const { container } = await renderHistory('/history') + + const rows = await waitFor(() => { + const items = [...container.querySelectorAll('[data-history-group-key]')] + expect(items).toHaveLength(2) + return items + }) + expect(rows[0]?.dataset.historyGroupKey).toBe('music:["downloads","/downloads/Album Bundle"]') + expect(rows[1]?.dataset.historyGroupKey).toBe(rows[0]?.dataset.historyGroupKey) + }) + + it('preserves the original exact-title grouping identity for non-music records', async () => { + const histories = [ + createHistory(1, 'Shared Title', { type: '电影' }), + createHistory(2, 'Shared Title', { type: '电视剧' }), + createHistory(3, 'shared title', { type: '电影' }), + createHistory(4, '', { type: '电影' }), + createHistory(5, '未知', { type: '电影' }), + ] + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse(histories)) + }) + + const { container } = await renderHistory('/history?grouped=true') + + const rows = await waitFor(() => { + const items = [...container.querySelectorAll('[data-history-group-key]')] + expect(items).toHaveLength(5) + return items + }) + expect(rows.map(row => row.dataset.historyGroupKey)).toEqual([ + 'title:"Shared Title"', + 'title:"Shared Title"', + 'title:"shared title"', + 'title:""', + 'title:"未知"', + ]) + }) + + it('keeps music album and non-music title group namespaces separate', async () => { + const musicKeyAsTitle = 'music:["library","/media/Artist/Album"]' + const histories = [ + createHistory(1, 'Track', { + dest: '/media/Artist/Album/01.flac', + type: '音乐', + }), + createHistory(2, musicKeyAsTitle), + ] + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse(histories)) + }) + + const { container } = await renderHistory('/history?grouped=true') + + const rows = await waitFor(() => { + const items = [...container.querySelectorAll('[data-history-group-key]')] + expect(items).toHaveLength(2) + return items + }) + expect(rows[0]?.dataset.historyGroupKey).toBe(musicKeyAsTitle) + expect(rows[1]?.dataset.historyGroupKey).toBe(`title:${JSON.stringify(musicKeyAsTitle)}`) + }) + + it('does not auto-group non-music titles that resemble music group keys', async () => { + const histories = [createHistory(1, 'music:archive'), createHistory(2, 'music:archive')] + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse(histories)) + }) + + const { router } = await renderHistory('/history') + + await waitFor(() => expect(screen.getAllByText('music:archive')).toHaveLength(2)) + expect(router.currentRoute.value.query.grouped).toBeUndefined() + }) + + it('does not auto-group music records without a shared album directory', async () => { + const histories = [ + createHistory(1, 'Unknown Track', { dest: '', src: '01.flac', type: '音乐' }), + createHistory(2, 'Unknown Track', { dest: '', src: '02.flac', type: '音乐' }), + ] + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse(histories)) + }) + + const { router } = await renderHistory('/history') + + await waitFor(() => expect(screen.getAllByText('Unknown Track')).toHaveLength(2)) + expect(router.currentRoute.value.query.grouped).toBeUndefined() + }) + + it('does not use a planned destination when a failed source path has no directory', async () => { + const histories = [ + createHistory(1, 'Track 1', { + dest: '/media/Artist/Predicted Album/01.flac', + src: '01.flac', + status: false, + type: '音乐', + }), + createHistory(2, 'Track 2', { + dest: '/media/Artist/Predicted Album/02.flac', + src: '02.flac', + status: false, + type: '音乐', + }), + ] + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse(histories)) + }) + + const { router } = await renderHistory('/history') + + await waitFor(() => expect(screen.getByText('Track 1')).toBeInTheDocument()) + expect(router.currentRoute.value.query.grouped).toBeUndefined() + }) + + it('does not count repeated history rows for one file as multiple album tracks', async () => { + const histories = [ + createHistory(1, 'Track', { + dest: '/media/Artist/Album/01.flac', + type: '音乐', + }), + createHistory(2, 'Track retry', { + dest: '/media/Artist/Album/01.flac', + type: '音乐', + }), + ] + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse(histories)) + }) + + const { router } = await renderHistory('/history') + + await waitFor(() => expect(screen.getByText('Track retry')).toBeInTheDocument()) + expect(router.currentRoute.value.query.grouped).toBeUndefined() + }) + + it('respects an explicit flat-view choice for a multi-track music album', async () => { + const tracks = [ + createHistory(1, 'Track 1', { + dest: 'D:\\Music\\Artist\\Album\\01.flac', + type: '音乐', + }), + createHistory(2, 'Track 2', { + dest: 'D:\\Music\\Artist\\Album\\02.flac', + type: '音乐', + }), + ] + mocks.apiGet.mockImplementation((path: string) => { + if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse()) + return Promise.resolve(historyResponse(tracks)) + }) + + const { container, router } = await renderHistory('/history?grouped=false') + + await waitFor(() => expect(container.querySelectorAll('[data-history-group-key]')).toHaveLength(2)) + expect(router.currentRoute.value.query.grouped).toBe('false') + }) + it('joins the desktop status filter to search and moves the mobile filter into the titlebar menu', () => { const mobileTitlebarSource = transferHistorySource.slice( transferHistorySource.indexOf('
'), @@ -627,6 +845,7 @@ describe('TransferHistoryView', () => { itemsPerPage: '50', search: 'desktop-query', }) + expect(router.currentRoute.value.query.grouped).toBeUndefined() }) it('loads mobile pages with deduplication and reports empty when the last page is exhausted', async () => { @@ -770,7 +989,7 @@ describe('TransferHistoryView', () => { 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') + const { router } = await renderHistory('/history?search=old&grouped=false') await flushPromises() await fireEvent.update(screen.getByLabelText('搜索(支持 * ? 通配符)'), 'new') @@ -778,7 +997,7 @@ describe('TransferHistoryView', () => { expect(router.currentRoute.value).toMatchObject({ path: '/history', - query: { currentPage: '1', itemsPerPage: '50', search: 'new' }, + query: { currentPage: '1', grouped: 'false', itemsPerPage: '50', search: 'new' }, }) })