Merge remote-tracking branch 'origin/v3' into v3

This commit is contained in:
jxxghp
2026-09-06 10:48:18 +08:00
3 changed files with 360 additions and 34 deletions
-3
View File
@@ -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
}
+135 -25
View File
@@ -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' },
})
})