From e8f873a69697b4edc3b76be45a90b52e95bb051f Mon Sep 17 00:00:00 2001 From: WorldSansha Date: Mon, 26 Oct 2026 11:59:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BA=E4=B8=93=E8=BE=91=E6=AD=8C?= =?UTF-8?q?=E6=9B=B2=E5=88=97=E8=A1=A8=E6=B7=BB=E5=8A=A0=E5=88=86=E7=A2=9F?= =?UTF-8?q?=E6=A0=8F=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/types/player.ts | 2 + src/components/list/SongList.spec.ts | 246 +++++++++++++++++++++++++ src/components/list/SongList.vue | 176 +++++++++++++----- src/components/ui/SVirtualList.spec.ts | 59 ++++++ src/components/ui/SVirtualList.vue | 62 +++++-- src/i18n/locales/en-US.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/utils/format/netease.spec.ts | 27 +++ src/utils/format/netease.ts | 2 + 9 files changed, 508 insertions(+), 68 deletions(-) create mode 100644 src/components/list/SongList.spec.ts create mode 100644 src/components/ui/SVirtualList.spec.ts diff --git a/shared/types/player.ts b/shared/types/player.ts index 93a93aff2..775db8d72 100644 --- a/shared/types/player.ts +++ b/shared/types/player.ts @@ -128,6 +128,8 @@ export interface Track { artists: Artist[]; /** 专辑 */ album?: Album; + /** 碟号或碟片名称 */ + disc?: string; /** 曲目编号 */ track?: number; /** 时长(毫秒) */ diff --git a/src/components/list/SongList.spec.ts b/src/components/list/SongList.spec.ts new file mode 100644 index 000000000..00a7ed401 --- /dev/null +++ b/src/components/list/SongList.spec.ts @@ -0,0 +1,246 @@ +import { mount } from "@vue/test-utils"; +import { createI18n } from "vue-i18n"; +import { createMemoryHistory, createRouter } from "vue-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ref } from "vue"; +import type { Track } from "@shared/types/player"; +import zhCN from "@/i18n/locales/zh-CN.json"; + +const state = vi.hoisted(() => ({ + playFrom: vi.fn(), + scrollToIndex: vi.fn(), + media: { track: null as Track | null }, +})); +vi.mock("@/core/player", () => ({ playFrom: state.playFrom })); +vi.mock("@/stores/media", () => ({ useMediaStore: () => state.media })); +vi.mock("@/stores/status", () => ({ useStatusStore: () => ({ isPlaying: false }) })); +vi.mock("@/stores/settings", () => ({ + useSettingsStore: () => ({ + appearance: { songListStyle: "compact" }, + player: { songListPlayBehavior: "all" }, + preset: {}, + system: { download: { enabled: false } }, + }), +})); +vi.mock("@/stores/download", () => ({ useDownloadStore: () => ({ isDownloaded: () => false }) })); +vi.mock("@/stores/library", () => ({ useLibraryStore: () => ({}) })); +vi.mock("@/stores/playlist", () => ({ usePlaylistStore: () => ({}) })); +vi.mock("@/stores/user", () => ({ useUserStore: () => ({}) })); +vi.mock("@/composables/useTrackMenu", () => ({ + useTrackMenu: () => ({ items: [], handleSelect: vi.fn() }), +})); +vi.mock("@/composables/useDownload", () => ({ + useDownload: () => ({ enqueue: vi.fn(), enqueueMany: vi.fn() }), +})); +vi.mock("@/composables/useFavorite", () => ({ + useFavorite: () => ({ isSupported: () => false, isLiked: () => false }), +})); +vi.mock("@/composables/useFloatingPlayerBar", () => ({ + useFloatingPlayerBar: () => ({ isFloatingBar: ref(false), PLAYER_BAR_GAP: 112 }), +})); +vi.mock("@/composables/usePlaylistPicker", () => ({ + usePlaylistPicker: () => ({ + open: ref(false), + tracks: ref([]), + mode: ref("add"), + openPicker: vi.fn(), + }), +})); +vi.mock("@/utils/navigate", () => ({ navigateToAlbum: vi.fn(), navigateToArtist: vi.fn() })); +import SongList from "./SongList.vue"; + +const tracks: Track[] = [ + { + id: "a", + source: "netease", + title: "少年与海", + artists: [], + duration: 1000, + disc: "1 致天堂的信", + track: 1, + }, + { + id: "b", + source: "netease", + title: "受难曲", + artists: [], + duration: 1000, + disc: "1 致天堂的信", + track: 7, + }, + { + id: "c", + source: "netease", + title: "GLORIA", + artists: [], + duration: 1000, + disc: "2 天堂的回信", + track: 8, + }, + { + id: "d", + source: "netease", + title: "老人与海", + artists: [], + duration: 1000, + disc: "2 天堂的回信", + track: 9, + }, +]; + +/** + * 挂载歌曲列表,替换滚动布局和外部服务,保留分组、排序、播放及多选逻辑 + * @param props - 当前场景的列表参数 + * @returns 可交互的列表包装器 + */ +const mountList = (props: Partial["$props"]> = {}) => + mount(SongList, { + props: { + items: tracks, + collectionType: "album", + source: "netease", + enableSort: true, + ...props, + }, + global: { + plugins: [ + createI18n({ legacy: false, locale: "zh-CN", messages: { "zh-CN": zhCN } }), + createRouter({ + history: createMemoryHistory(), + routes: [{ path: "/", component: { template: "
" } }], + }), + ], + stubs: { + SContextMenu: { template: "
" }, + SVirtualList: { + props: ["items"], + setup: (_, { expose }) => { + expose({ scrollToIndex: state.scrollToIndex }); + }, + template: + '
', + }, + SButton: { template: '' }, + SCheckbox: { + props: ["checked"], + template: + '', + }, + SPopover: { template: '
' }, + SRadioGroup: { + props: ["value"], + template: + '', + }, + SRadio: { + props: ["value", "label"], + template: '', + }, + SDialog: true, + PlaylistPickerDialog: true, + TagEditorDialog: true, + SImg: true, + STag: true, + SIconSwap: true, + SDivider: true, + SLoading: true, + ...Object.fromEntries( + [ + "Disc3", + "Music", + "Pause", + "Play", + "Cloud", + "Check", + "ListEnd", + "ListPlus", + "ListMinus", + "Download", + "CloudOff", + "Trash2", + "X", + "ArrowLeftRight", + "ArrowUpDown", + "ArrowUpAz", + "ArrowUp", + "Locate", + "SearchX", + ].map((name) => ["IconLucide" + name, true]), + ), + IconMaterialSymbolsFavoriteRounded: true, + IconMaterialSymbolsFavoriteOutlineRounded: true, + }, + }, + }); + +beforeEach(() => { + vi.clearAllMocks(); + state.media.track = null; +}); + +describe("专辑分碟列表", () => { + it("展示完整碟名和数量,第二碟双击与播放按钮仍使用歌曲索引", async () => { + const wrapper = mountList(); + expect(wrapper.findAll("[data-disc-header]").map((header) => header.text())).toEqual([ + "Disc 1 致天堂的信共 2 首", + "Disc 2 天堂的回信共 2 首", + ]); + const row = wrapper.findAll("[data-song-item]")[2]; + await row.trigger("dblclick"); + expect(state.playFrom).toHaveBeenLastCalledWith(tracks, { startIndex: 2, context: undefined }); + await row.get(".w-8").trigger("click"); + expect(state.playFrom).toHaveBeenLastCalledWith(tracks, { startIndex: 2, context: undefined }); + expect(row.get(".w-8").text()).toContain("8"); + wrapper.unmount(); + }); + + it("搜索只保留有结果的碟片,播放使用过滤后的队列", async () => { + const wrapper = mountList({ searchQuery: "GLORIA" }); + expect(wrapper.findAll("[data-disc-header]")).toHaveLength(1); + expect(wrapper.get("[data-disc-header]").text()).toContain("2 天堂的回信"); + await wrapper.get("[data-song-item]").trigger("dblclick"); + expect(state.playFrom).toHaveBeenLastCalledWith([tracks[2]], { + startIndex: 0, + context: undefined, + }); + await wrapper.setProps({ searchQuery: "不存在" }); + expect(wrapper.findAll("[data-disc-header]")).toHaveLength(0); + wrapper.unmount(); + }); + + it("单碟专辑和歌单保持普通列表,自定义排序取消分碟", async () => { + const wrapper = mountList({ items: tracks.slice(0, 2) }); + expect(wrapper.findAll("[data-disc-header]")).toHaveLength(0); + await wrapper.setProps({ items: tracks, collectionType: "playlist" }); + expect(wrapper.findAll("[data-disc-header]")).toHaveLength(0); + await wrapper.setProps({ + collectionType: "album", + items: tracks.map((track, index) => ({ + ...track, + title: ["Zebra", "Bravo", "Alpha", "Delta"][index], + })), + }); + await wrapper.findAll("select")[0].setValue("title"); + expect(wrapper.findAll("[data-disc-header]")).toHaveLength(0); + await wrapper.findAll("[data-song-item]")[0].trigger("dblclick"); + expect(state.playFrom.mock.lastCall?.[0][0].id).toBe("c"); + wrapper.unmount(); + }); + + it("定位当前歌曲包含标题偏移,批量全选不包含碟片标题", async () => { + state.media.track = tracks[2]; + const wrapper = mountList(); + await wrapper.get("icon-lucide-locate-stub").element.parentElement?.click(); + expect(state.scrollToIndex).toHaveBeenCalledWith(4); + wrapper.vm.enterBatch(); + await wrapper.vm.$nextTick(); + await wrapper.findAll('input[type="checkbox"]')[0].setValue(true); + expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(5); + expect( + wrapper + .findAll('input[type="checkbox"]') + .every((input) => (input.element as HTMLInputElement).checked), + ).toBe(true); + wrapper.unmount(); + }); +}); diff --git a/src/components/list/SongList.vue b/src/components/list/SongList.vue index c4c14b1cb..3c297b631 100644 --- a/src/components/list/SongList.vue +++ b/src/components/list/SongList.vue @@ -202,9 +202,27 @@ const filteredItems = computed(() => { }); }); +/** 默认专辑顺序下按碟片分组,搜索仍保留原专辑的分碟结构 */ +const groupByDisc = computed( + () => + props.collectionType === "album" && + (!props.enableSort || sortField.value === "none") && + new Set(props.items.map((track) => track.disc ?? "1")).size > 1, +); + /** 排序后列表 */ const sortedItems = computed(() => { - if (!props.enableSort || sortField.value === "none") return filteredItems.value; + if (!props.enableSort || sortField.value === "none") { + if (!groupByDisc.value) return filteredItems.value; + const discs = new Map(); + for (const track of filteredItems.value) { + const disc = track.disc ?? "1"; + const tracks = discs.get(disc); + if (tracks) tracks.push(track); + else discs.set(disc, [track]); + } + return [...discs.values()].flat(); + } const result = [...filteredItems.value]; const field = sortField.value; const direction = sortOrder.value === "asc" ? 1 : -1; @@ -234,10 +252,34 @@ const sortedItems = computed(() => { return result; }); -/** 当前播放歌曲在过滤后列表中的索引 */ +type SongListRow = + | { kind: "disc"; key: string; disc: string; count: number } + | { kind: "track"; key: string; track: Track; index: number }; + +/** 分碟标题参与虚拟滚动,歌曲索引始终对应实际播放队列 */ +const listRows = computed(() => { + const rows: SongListRow[] = []; + let header: Extract | undefined; + sortedItems.value.forEach((track, index) => { + if (groupByDisc.value) { + const disc = track.disc ?? "1"; + if (header?.disc !== disc) { + header = { kind: "disc", key: `disc:${disc}`, disc, count: 0 }; + rows.push(header); + } + header.count++; + } + rows.push({ kind: "track", key: `track:${track.id}`, track, index }); + }); + return rows; +}); + +/** 当前播放歌曲在虚拟列表中的索引 */ const playingIndex = computed(() => { if (!playingId.value) return -1; - return sortedItems.value.findIndex((track) => track.id === playingId.value); + return listRows.value.findIndex( + (row) => row.kind === "track" && row.track.id === playingId.value, + ); }); /** 虚拟列表引用 */ @@ -273,6 +315,14 @@ const { deleteConfirmOpen, deleteDialogTitle, deleteDialogContent } = batch; /** 是否使用紧凑列表样式 */ const isCompactList = computed(() => settings.appearance.songListStyle === "compact"); +/** + * 为分碟标题和歌曲提供精确行高 + * @param row - 虚拟列表行 + * @returns 行高,单位为像素 + */ +const getRowHeight = (row: SongListRow): number => + row.kind === "disc" ? 40 : isCompactList.value ? 56 : 88; + /** * 计算歌曲行样式 * @param item - 歌曲数据 @@ -417,10 +467,11 @@ defineExpose({ -