mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +08:00
Merge remote-tracking branch 'origin/v3' into v3
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ const aiRedoIds = ref<number[]>([])
|
||||
// AI整理进度
|
||||
const aiRedoProgressActive = ref(false)
|
||||
const aiRedoProgressText = ref(t('transferHistory.actions.aiRedoPending'))
|
||||
const aiRedoProgressSSE = ref<any>(null)
|
||||
const aiRedoProgressSSE = ref<ReturnType<typeof useProgressSSE> | null>(null)
|
||||
const aiRedoProgressHistoryIds = ref<number[]>([])
|
||||
let aiRedoProgressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
@@ -88,6 +88,13 @@ const redoTargetStorage = ref<string>()
|
||||
// 已选中的数据
|
||||
const selected = ref<TransferHistory[]>([])
|
||||
|
||||
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<number, { source: boolean; destination: boolean }>()
|
||||
|
||||
@@ -256,7 +263,7 @@ const pageRange = [
|
||||
const pageRangeValues = pageRange.map(item => item.value)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<TransferHistory[]>([])
|
||||
const dataList = ref<TransferHistoryDisplayItem[]>([])
|
||||
|
||||
// 移动端历史记录列表,独立于桌面分页表格。
|
||||
const mobileDataList = ref<TransferHistory[]>([])
|
||||
@@ -298,11 +305,13 @@ const totalItems = ref(0)
|
||||
|
||||
// 是否要分组
|
||||
const group = ref<boolean>(route.query.grouped === 'true')
|
||||
// 区分默认平铺与用户显式选择平铺,避免搜索/翻页提前关闭后续专辑自动分组。
|
||||
const groupPreferenceExplicit = ref(route.query.grouped !== undefined)
|
||||
|
||||
// 分组条件
|
||||
const groupBy = ref<any>([
|
||||
const groupBy = ref<Array<{ key: string }>>([
|
||||
{
|
||||
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<string, Set<string>>()
|
||||
for (const item of items) {
|
||||
if (!item.history_group_is_music_album) continue
|
||||
const tracks = tracksByAlbum.get(item.history_group_key) || new Set<string>()
|
||||
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<TransferHistoryDisplayItem>).history_group_key || item.title || ''
|
||||
acc[key] = (acc[key] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
)
|
||||
})
|
||||
|
||||
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(() => {
|
||||
</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" />
|
||||
<VBtn
|
||||
:icon="group ? 'mdi-format-list-bulleted' : 'mdi-format-list-group'"
|
||||
@click="toggleHistoryGrouping"
|
||||
/>
|
||||
</VBtnGroup>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -1706,11 +1810,17 @@ onUnmounted(() => {
|
||||
@click="toggleGroup(item)"
|
||||
/>
|
||||
<VCheckbox
|
||||
:model-value="selectedCountsGroupedByTitle[item.value] == item.items.length"
|
||||
:indeterminate="selectedCountsGroupedByTitle[item.value] < item.items.length"
|
||||
:model-value="selectedCountsGroupedByKey[item.value] == item.items.length"
|
||||
:indeterminate="
|
||||
selectedCountsGroupedByKey[item.value] > 0 &&
|
||||
selectedCountsGroupedByKey[item.value] < item.items.length
|
||||
"
|
||||
@update:modelValue="checked => toggleGroupSelection(checked, item.items)"
|
||||
/>
|
||||
{{ item.value }}
|
||||
<span>{{ getHistoryGroupLabel(item.items) }}</span>
|
||||
<VChip v-if="item.items.length > 1 && isMusicAlbumGroup(item.items)" size="x-small" variant="tonal">
|
||||
{{ t('music.trackCount', { count: item.items.length }) }}
|
||||
</VChip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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('<div class="transfer-history-mobile-titlebar__actions">'),
|
||||
@@ -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' },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user