From 2b1ec09620ac167f46fa19cb1fb4fcb0cecde6c6 Mon Sep 17 00:00:00 2001 From: WindDrift <115442729+WindDrift@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:50:38 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=E4=B8=BB=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E6=9C=8D=E5=8A=A1=E6=96=B0=E5=A2=9E=E4=B8=BB?= =?UTF-8?q?=E5=88=9B=E8=AF=B4=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/ipc/comments.ts | 20 +- electron/main/services/comments/cache.spec.ts | 83 ++++++++ electron/main/services/comments/cache.ts | 81 ++++++++ .../main/services/comments/creator.spec.ts | 190 ++++++++++++++++++ electron/main/services/comments/creator.ts | 167 +++++++++++++++ electron/main/services/comments/data.spec.ts | 144 +++++++++++++ electron/main/services/comments/data.ts | 81 ++++++-- electron/main/services/comments/index.ts | 173 ++++++++++------ electron/preload/index.ts | 3 +- shared/types/comment.ts | 15 ++ 10 files changed, 884 insertions(+), 73 deletions(-) create mode 100644 electron/main/services/comments/cache.spec.ts create mode 100644 electron/main/services/comments/cache.ts create mode 100644 electron/main/services/comments/creator.spec.ts create mode 100644 electron/main/services/comments/creator.ts create mode 100644 electron/main/services/comments/data.spec.ts diff --git a/electron/main/ipc/comments.ts b/electron/main/ipc/comments.ts index e4dbdbf0c..8915b0362 100644 --- a/electron/main/ipc/comments.ts +++ b/electron/main/ipc/comments.ts @@ -1,7 +1,13 @@ import { ipcMain } from "electron"; import { getCommentSources, getMusicComments } from "@main/services/comments"; +import { getCreatorComments } from "@main/services/comments/creator"; import { coreLog } from "@main/utils/logger"; -import type { MusicCommentQuery, MusicCommentResponse } from "@shared/types/comment"; +import type { + MusicCommentCreatorQuery, + MusicCommentCreatorResponse, + MusicCommentQuery, + MusicCommentResponse, +} from "@shared/types/comment"; /** 注册评论 IPC */ export const registerCommentsIpc = (): void => { @@ -18,4 +24,16 @@ export const registerCommentsIpc = (): void => { } }, ); + + ipcMain.handle( + "comments:creator", + async (_evt, args: MusicCommentCreatorQuery): Promise => { + try { + return { ok: true, data: await getCreatorComments(args) }; + } catch (err) { + coreLog.warn("[comments] creator failed:", err); + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + ); }; diff --git a/electron/main/services/comments/cache.spec.ts b/electron/main/services/comments/cache.spec.ts new file mode 100644 index 000000000..86527fbff --- /dev/null +++ b/electron/main/services/comments/cache.spec.ts @@ -0,0 +1,83 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BoundedMap, coalesce } from "./cache"; + +describe("评论缓存 BoundedMap", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("负缓存条目同样参与访问提升", () => { + const cache = new BoundedMap(2); + cache.set("a", null); + cache.set("b", "b"); + expect(cache.get("a")).toBeNull(); + cache.set("c", "c"); + expect(cache.get("a")).toBeNull(); + expect(cache.get("b")).toBeUndefined(); + }); + + it("过期条目读取时返回 undefined 并被删除", () => { + const cache = new BoundedMap(2, 1000); + cache.set("a", "a"); + vi.advanceTimersByTime(1001); + expect(cache.get("a")).toBeUndefined(); + }); + + it("包装后能区分缺失与存了 null", () => { + const cache = new BoundedMap(2); + cache.set("a", null); + expect(cache.get("a")).toBeNull(); + expect(cache.get("b")).toBeUndefined(); + }); + + it("超过上限时淘汰最久未使用的条目", () => { + const cache = new BoundedMap(2); + cache.set("a", 1); + cache.set("b", 2); + cache.get("a"); + cache.set("c", 3); + expect(cache.get("b")).toBeUndefined(); + expect(cache.get("a")).toBe(1); + expect(cache.get("c")).toBe(3); + }); +}); + +describe("在途请求合并 coalesce", () => { + it("同 key 并发只调用一次工厂", async () => { + const inflight = new Map>(); + const factory = vi.fn(async () => 1); + const [first, second] = await Promise.all([ + coalesce(inflight, "k", factory), + coalesce(inflight, "k", factory), + ]); + expect(first).toBe(1); + expect(second).toBe(1); + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("成功与失败后都移除在途项", async () => { + const inflight = new Map>(); + await coalesce(inflight, "ok", async () => 1); + expect(inflight.size).toBe(0); + await expect( + coalesce(inflight, "fail", async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + expect(inflight.size).toBe(0); + }); + + it("不同 key 不共享在途请求", async () => { + const inflight = new Map>(); + const factory = vi.fn(async (value: string) => value); + await Promise.all([ + coalesce(inflight, "a", () => factory("a")), + coalesce(inflight, "b", () => factory("b")), + ]); + expect(factory).toHaveBeenCalledTimes(2); + }); +}); diff --git a/electron/main/services/comments/cache.ts b/electron/main/services/comments/cache.ts new file mode 100644 index 000000000..4936cd980 --- /dev/null +++ b/electron/main/services/comments/cache.ts @@ -0,0 +1,81 @@ +/** 单条缓存记录,包装后 get 的 undefined 才能唯一表示「不存在」 */ +interface CacheEntry { + value: V; + /** 过期时间戳(毫秒),0 表示永不过期 */ + expireAt: number; +} + +/** + * 有界 LRU 缓存,可选用 TTL + * 以条目包装存储,负缓存(null / 空数组)同样参与访问提升与淘汰 + */ +export class BoundedMap { + private readonly map = new Map>(); + + /** + * @param limit - 最大条目数,超出时淘汰最久未使用的条目 + * @param ttlMs - 条目存活时长(毫秒),省略表示永不过期 + */ + constructor( + private readonly limit: number, + private readonly ttlMs?: number, + ) {} + + /** + * 读取缓存值,命中时提升为最近使用,过期条目读取时清除 + * @param key - 缓存键 + * @returns 缓存值,不存在或已过期时返回 undefined + */ + get(key: K): V | undefined { + const entry = this.map.get(key); + if (!entry) return undefined; + if (entry.expireAt && entry.expireAt <= Date.now()) { + this.map.delete(key); + return undefined; + } + if (this.map.size > 1) { + this.map.delete(key); + this.map.set(key, entry); + } + return entry.value; + } + + /** + * 写入缓存值并淘汰超限的最旧条目 + * @param key - 缓存键 + * @param value - 缓存值 + */ + set(key: K, value: V): void { + this.map.delete(key); + this.map.set(key, { + value, + expireAt: this.ttlMs ? Date.now() + this.ttlMs : 0, + }); + while (this.map.size > this.limit) { + const oldest = this.map.keys().next().value; + if (oldest === undefined) break; + this.map.delete(oldest); + } + } +} + +/** + * 合并同 key 的并发请求,Promise 落定后自动移除在途项 + * @param inflight - 在途请求表 + * @param key - 去重键 + * @param factory - 实际请求工厂 + * @returns 共享的请求 Promise + */ +export const coalesce = ( + inflight: Map>, + key: K, + factory: () => Promise, +): Promise => { + const pending = inflight.get(key); + if (pending) return pending; + const promise = factory().finally(() => { + inflight.delete(key); + }); + inflight.set(key, promise); + return promise; +}; diff --git a/electron/main/services/comments/creator.spec.ts b/electron/main/services/comments/creator.spec.ts new file mode 100644 index 000000000..f2f764a6d --- /dev/null +++ b/electron/main/services/comments/creator.spec.ts @@ -0,0 +1,190 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MusicCommentItem } from "@shared/types/comment"; +import type { Track } from "@shared/types/player"; + +const state = vi.hoisted(() => ({ + hot: new Map(), + fresh: new Map(), + artists: new Map(), + calls: [] as string[], + meta: { songId: "100", artistIds: ["500"] } as { songId: string; artistIds: string[] } | null, + metaCalls: 0, + creator: null as unknown as (args: { + sourceId: string; + track: Track; + }) => Promise, +})); + +vi.mock("@main/utils/logger", () => ({ coreLog: { warn: vi.fn(), info: vi.fn() } })); +vi.mock("./index", () => ({ + findNeteaseSongMeta: async () => { + state.metaCalls += 1; + return state.meta; + }, +})); +vi.mock("@main/apis/netease", () => ({ + callNetease: async (name: string, params: { id?: string; offset?: number; limit?: number }) => { + const id = String(params.id ?? ""); + state.calls.push(`${name}:${id}`); + if (name === "artists") return state.artists.get(id) ?? { status: 500, body: {} }; + const page = Math.floor((params.offset ?? 0) / (params.limit ?? 20)) + 1; + const table = name === "comment_hot" ? state.hot : state.fresh; + const registered = table.get(page); + if (registered) return registered; + // 未注册的页视为最后一页 + return { + status: 200, + body: + name === "comment_hot" ? { hotComments: [], more: false } : { comments: [], more: false }, + }; + }, +})); + +const track: Track = { + id: "100", + source: "netease", + title: "歌名", + artists: [{ id: "500", name: "歌手" }], + duration: 180000, +}; + +const creatorComment = (id: string, likedCount: number) => ({ + commentId: id, + content: `内容${id}`, + likedCount, + user: { userId: 200, nickname: "主创" }, +}); + +const listenerComment = (id: string) => ({ + commentId: id, + content: `内容${id}`, + user: { userId: 300, nickname: "听众" }, +}); + +/** + * 注册一页评论响应 + * @param type - hot 热评 / new 普通评论 + * @param page - 页码 + * @param comments - 该页原始评论 + * @param more - 该页是否还有下一页 + */ +const setPage = (type: "hot" | "new", page: number, comments: unknown[], more: boolean): void => { + const body = type === "hot" ? { hotComments: comments, more } : { comments, more }; + (type === "hot" ? state.hot : state.fresh).set(page, { status: 200, body }); +}; + +/** + * 注册歌手账号解析响应 + * @param accountId - 网易云账号 id,0 表示未绑定 + */ +const setArtist = (accountId: number): void => { + state.artists.set( + "500", + accountId > 0 + ? { status: 200, body: { artist: { accountId, name: "歌手" } } } + : { status: 200, body: { artist: { accountId } } }, + ); +}; + +const loadCreator = async (): Promise => { + // 每次重新加载模块,避免进程级缓存跨用例串味 + vi.resetModules(); + state.creator = (await import("./creator")).getCreatorComments; +}; + +describe("主创说抓取", () => { + beforeEach(() => { + state.hot.clear(); + state.fresh.clear(); + state.artists.clear(); + state.calls.length = 0; + state.metaCalls = 0; + state.meta = { songId: "100", artistIds: ["500"] }; + setArtist(200); + }); + + it("热评首页命中时只发一次热评请求", async () => { + setPage("hot", 1, [creatorComment("1", 5), listenerComment("2")], true); + await loadCreator(); + const result = await state.creator({ sourceId: "builtin:netease", track }); + expect(result).toHaveLength(1); + expect(state.calls.filter((call) => call.startsWith("comment_hot"))).toHaveLength(1); + expect(state.calls.filter((call) => call.startsWith("comment_music"))).toHaveLength(0); + }); + + it("热评翻页后无命中转普通评论,首个命中页即停", async () => { + setPage("hot", 1, [listenerComment("1")], true); + setPage("hot", 2, [listenerComment("2")], true); + setPage("hot", 3, [listenerComment("3")], false); + setPage("new", 1, [creatorComment("9", 3)], true); + await loadCreator(); + const result = await state.creator({ sourceId: "builtin:netease", track }); + expect(result).toHaveLength(1); + expect(state.calls.filter((call) => call.startsWith("comment_hot"))).toHaveLength(3); + expect(state.calls.filter((call) => call.startsWith("comment_music"))).toHaveLength(1); + }); + + it("more 为假时两个阶段都立即截断", async () => { + setPage("hot", 1, [listenerComment("1")], false); + setPage("new", 1, [listenerComment("2")], false); + await loadCreator(); + const result = await state.creator({ sourceId: "builtin:netease", track }); + expect(result).toEqual([]); + expect(state.calls.filter((call) => call.startsWith("comment_hot"))).toHaveLength(1); + expect(state.calls.filter((call) => call.startsWith("comment_music"))).toHaveLength(1); + }); + + it("非 200 状态直接失败", async () => { + state.hot.set(1, { status: 500, body: {} }); + await loadCreator(); + await expect(state.creator({ sourceId: "builtin:netease", track })).rejects.toThrow( + "netease comment request failed: 500", + ); + }); + + it("歌手解析失败不写负缓存,后续请求仍会重试", async () => { + state.artists.set("500", { status: 500, body: {} }); + await loadCreator(); + expect(await state.creator({ sourceId: "builtin:netease", track })).toEqual([]); + expect(state.calls.filter((call) => call.startsWith("artists"))).toHaveLength(1); + setArtist(200); + setPage("hot", 1, [creatorComment("1", 5)], false); + expect(await state.creator({ sourceId: "builtin:netease", track })).toHaveLength(1); + expect(state.calls.filter((call) => call.startsWith("artists"))).toHaveLength(2); + }); + + it("歌手未绑定账号时直接返回空,不请求评论", async () => { + setArtist(0); + await loadCreator(); + expect(await state.creator({ sourceId: "builtin:netease", track })).toEqual([]); + expect(state.calls.filter((call) => call.startsWith("comment_hot"))).toHaveLength(0); + }); + + it("同曲第二次调用命中缓存,不再请求元信息与评论", async () => { + setPage("hot", 1, [creatorComment("1", 5)], true); + await loadCreator(); + await state.creator({ sourceId: "builtin:netease", track }); + const calls = state.calls.length; + await state.creator({ sourceId: "builtin:netease", track }); + expect(state.calls.length).toBe(calls); + }); + + it("结果按点赞数降序", async () => { + setPage( + "hot", + 1, + [creatorComment("1", 1), creatorComment("2", 30), creatorComment("3", 10)], + false, + ); + await loadCreator(); + const result = await state.creator({ sourceId: "builtin:netease", track }); + expect(result.map((item) => item.id)).toEqual(["2", "3", "1"]); + }); + + it("非网易云源直接返回空且不发请求", async () => { + await loadCreator(); + expect(await state.creator({ sourceId: "builtin:qqmusic", track })).toEqual([]); + expect(state.calls).toHaveLength(0); + }); +}); diff --git a/electron/main/services/comments/creator.ts b/electron/main/services/comments/creator.ts new file mode 100644 index 000000000..9e108aa59 --- /dev/null +++ b/electron/main/services/comments/creator.ts @@ -0,0 +1,167 @@ +import { callNetease } from "@main/apis/netease"; +import { coreLog } from "@main/utils/logger"; +import type { MusicCommentCreatorQuery, MusicCommentItem } from "@shared/types/comment"; +import { BoundedMap, coalesce } from "./cache"; +import { + normalizeNeteaseCommentPage, + optionalString, + scanCreatorComments, + type NeteaseCommentBody, +} from "./data"; +import { findNeteaseSongMeta } from "./index"; + +const NETEASE_SOURCE_ID = "builtin:netease"; +const NETEASE_RESOURCE_TYPE = "R_SO_4_"; +const PAGE_LIMIT = 20; +const MAX_HOT_PAGES = 3; +const MAX_NEW_PAGES = 5; +const ARTIST_ACCOUNT_CACHE_LIMIT = 200; +const CREATOR_COMMENTS_CACHE_LIMIT = 100; +const CREATOR_COMMENTS_TTL = 10 * 60 * 1000; + +/** 歌手账号解析结果,failed 表示请求异常,不能当作未绑定 */ +type ArtistAccountResult = + | { state: "bound"; accountId: string; artistName: string } + | { state: "unbound" } + | { state: "failed" }; + +/** 歌手 id → 账号解析结果,只缓存确定结论 */ +const artistAccountCache = new BoundedMap(ARTIST_ACCOUNT_CACHE_LIMIT); +/** 网易云歌曲 id → 主创评论列表,空数组为负缓存 */ +const creatorCommentsCache = new BoundedMap( + CREATOR_COMMENTS_CACHE_LIMIT, + CREATOR_COMMENTS_TTL, +); +/** 歌手账号解析在途请求 */ +const artistAccountInflight = new Map>(); +/** 主创评论扫描在途请求 */ +const creatorInflight = new Map>(); + +/** + * 拉取一页网易云评论,非 200 状态抛错 + * @param songId - 网易云歌曲 id + * @param type - 评论分类 + * @param page - 页码 + * @returns 归一化分页,含原生的 hasMore 信号 + */ +const fetchPage = async (songId: string, type: "hot" | "new", page: number) => { + const apiName = type === "hot" ? "comment_hot" : "comment_music"; + const { status, body } = await callNetease(apiName, { + id: songId, + type: NETEASE_RESOURCE_TYPE, + limit: PAGE_LIMIT, + offset: (page - 1) * PAGE_LIMIT, + }); + if (status !== 200) throw new Error(`netease comment request failed: ${status}`); + return normalizeNeteaseCommentPage(body as NeteaseCommentBody, type, page, PAGE_LIMIT); +}; + +/** + * 解析单个歌手绑定的网易云账号 + * 请求异常时不下结论,避免一次超时被当成永久未绑定 + * @param artistId - 歌手 id + * @returns 账号解析结果 + */ +const resolveArtistAccount = async (artistId: string): Promise => { + const cached = artistAccountCache.get(artistId); + if (cached !== undefined) return cached; + return coalesce(artistAccountInflight, artistId, async () => { + try { + const { status, body } = await callNetease("artists", { id: artistId }); + if (status !== 200) throw new Error(`netease artist request failed: ${status}`); + const rawAccountId = body?.artist?.accountId; + const accountId = + typeof rawAccountId === "number" && rawAccountId > 0 + ? String(rawAccountId) + : typeof rawAccountId === "string" && rawAccountId + ? rawAccountId + : ""; + const result: ArtistAccountResult = accountId + ? { state: "bound", accountId, artistName: optionalString(body?.artist?.name) ?? "" } + : { state: "unbound" }; + artistAccountCache.set(artistId, result); + return result; + } catch (err) { + coreLog.warn(`[comments] resolve artist ${artistId} accountId failed:`, err); + return { state: "failed" }; + } + }); +}; + +/** + * 解析歌手列表对应的网易云账号 + * @param artistIds - 歌手 id 列表 + * @returns 账号 id → 歌手名,以及是否存在解析失败的歌手 + */ +const resolveArtistAccountIds = async ( + artistIds: string[], +): Promise<{ accountMap: Map; failed: boolean }> => { + const accountMap = new Map(); + const uniqueIds = [...new Set(artistIds.filter(Boolean))]; + const results = await Promise.all(uniqueIds.map((artistId) => resolveArtistAccount(artistId))); + let failed = false; + for (const result of results) { + if (result.state === "bound") accountMap.set(result.accountId, result.artistName); + else if (result.state === "failed") failed = true; + } + return { accountMap, failed }; +}; + +/** + * 顺序翻页扫描主创评论,命中或没有下一页时停止 + * @param songId - 网易云歌曲 id + * @param type - 评论分类 + * @param maxPages - 最多翻页数 + * @param accountMap - 账号 id → 歌手名 + * @returns 命中的主创评论 + */ +const scanPages = async ( + songId: string, + type: "hot" | "new", + maxPages: number, + accountMap: Map, +): Promise => { + const hits: MusicCommentItem[] = []; + for (let page = 1; page <= maxPages; page++) { + const data = await fetchPage(songId, type, page); + hits.push(...scanCreatorComments(data.list, accountMap)); + if (hits.length > 0 || data.hasMore !== true) break; + } + return hits; +}; + +/** + * 获取主创说评论:歌曲关联歌手在网易云绑定的账号在该曲下的发言 + * 先扫热评,热评无命中时才扫普通评论,两个阶段都顺序按需翻页并命中即停 + * @param args - 评论源与曲目 + * @returns 按点赞数降序的主创评论 + */ +export const getCreatorComments = async ( + args: MusicCommentCreatorQuery, +): Promise => { + if (args.sourceId !== NETEASE_SOURCE_ID) return []; + const meta = await findNeteaseSongMeta(args.track); + if (!meta) return []; + + const cached = creatorCommentsCache.get(meta.songId); + if (cached !== undefined) return cached; + + return coalesce(creatorInflight, meta.songId, async () => { + const { accountMap, failed } = await resolveArtistAccountIds(meta.artistIds); + if (accountMap.size === 0) { + if (!failed) creatorCommentsCache.set(meta.songId, []); + return []; + } + + let result = await scanPages(meta.songId, "hot", MAX_HOT_PAGES, accountMap); + if (result.length === 0) { + result = await scanPages(meta.songId, "new", MAX_NEW_PAGES, accountMap); + } + + const seen = new Set(); + result = result.filter((item) => (seen.has(item.id) ? false : seen.add(item.id))); + result.sort((a, b) => (b.likedCount ?? -1) - (a.likedCount ?? -1)); + if (!failed) creatorCommentsCache.set(meta.songId, result); + return result; + }); +}; diff --git a/electron/main/services/comments/data.spec.ts b/electron/main/services/comments/data.spec.ts new file mode 100644 index 000000000..84578421e --- /dev/null +++ b/electron/main/services/comments/data.spec.ts @@ -0,0 +1,144 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import type { MusicCommentItem } from "@shared/types/comment"; +import { + normalizeNeteaseComment, + normalizeNeteaseCommentPage, + normalizeQQMusicCommentPage, + scanCreatorComments, +} from "./data"; + +const comment = (overrides: Record = {}) => ({ + commentId: 1, + content: "内容", + user: { userId: 9, nickname: "听众" }, + ...overrides, +}); + +describe("网易云评论归一化", () => { + it("top-level more 决定 hasMore", () => { + const page = normalizeNeteaseCommentPage( + { comments: [comment()], total: 100, more: true }, + "new", + 1, + 20, + ); + expect(page.hasMore).toBe(true); + }); + + it("回落到 data.hasMore", () => { + const page = normalizeNeteaseCommentPage( + { data: { comments: [comment()], hasMore: true } }, + "new", + 1, + 20, + ); + expect(page.hasMore).toBe(true); + }); + + it("两者都缺失时 hasMore 为 undefined", () => { + const page = normalizeNeteaseCommentPage({ comments: [comment()] }, "new", 1, 20); + expect(page.hasMore).toBeUndefined(); + }); + + it("total 缺失时回退 0", () => { + const page = normalizeNeteaseCommentPage({ comments: [comment()] }, "new", 1, 20); + expect(page.total).toBe(0); + }); + + it("回复缺少自身 id 时用 fallbackId 生成互不相同的 id", () => { + const item = normalizeNeteaseComment({ + commentId: 7, + content: "父评论", + user: { userId: 1, nickname: "甲" }, + beReplied: [ + { content: "回复一", user: { userId: 2, nickname: "乙" } }, + { content: "回复二", user: { userId: 3, nickname: "丙" } }, + ], + }); + const ids = item?.reply?.map((reply) => reply.id) ?? []; + expect(ids).toHaveLength(2); + expect(new Set(ids).size).toBe(2); + }); +}); + +describe("QQ 音乐评论归一化", () => { + it("透传 hasMore 并在为真时保留空游标", () => { + const page = normalizeQQMusicCommentPage( + { comments: [{ CmId: "1", Content: "内容", Nick: "听众" }], hasMore: true }, + 1, + 20, + ); + expect(page.hasMore).toBe(true); + expect(page.nextCursor).toBe(""); + }); + + it("hasMore 为假时省略游标", () => { + const page = normalizeQQMusicCommentPage( + { comments: [{ CmId: "1", Content: "内容", Nick: "听众" }], hasMore: false }, + 1, + 20, + ); + expect(page.hasMore).toBe(false); + expect(page.nextCursor).toBeUndefined(); + }); +}); + +describe("主创评论扫描", () => { + const accountMap = new Map([["9", "主创名"]]); + + it("命中顶层评论并写入 creatorName", () => { + const items: MusicCommentItem[] = [{ id: "1", userId: "9", userName: "a", text: "t" }]; + const hits = scanCreatorComments(items, accountMap); + expect(hits).toHaveLength(1); + expect(hits[0]?.creatorName).toBe("主创名"); + }); + + it("主创在粉丝评论下的回复同样计入", () => { + const items: MusicCommentItem[] = [ + { + id: "1", + userId: "100", + userName: "粉丝", + text: "问", + reply: [{ id: "2", userId: "9", userName: "主创", text: "答" }], + }, + ]; + const hits = scanCreatorComments(items, accountMap); + expect(hits).toHaveLength(1); + expect(hits[0]?.id).toBe("2"); + expect(hits[0]?.creatorName).toBe("主创名"); + }); + + it("顶层主创评论下的其他主创回复也会返回", () => { + const items: MusicCommentItem[] = [ + { + id: "1", + userId: "9", + userName: "主创甲", + text: "顶层", + reply: [{ id: "2", userId: "10", userName: "主创乙", text: "回复" }], + }, + ]; + const hits = scanCreatorComments( + items, + new Map([ + ["9", "主创甲"], + ["10", "主创乙"], + ]), + ); + expect(hits.map((hit) => hit.id)).toEqual(["1", "2"]); + expect(hits.map((hit) => hit.creatorName)).toEqual(["主创甲", "主创乙"]); + }); + + it("歌手名为空时 creatorName 为空串", () => { + const items: MusicCommentItem[] = [{ id: "1", userId: "9", userName: "a", text: "t" }]; + const hits = scanCreatorComments(items, new Map([["9", ""]])); + expect(hits[0]?.creatorName).toBe(""); + }); + + it("账号映射为空时返回空数组", () => { + const items: MusicCommentItem[] = [{ id: "1", userId: "9", userName: "a", text: "t" }]; + expect(scanCreatorComments(items, new Map())).toEqual([]); + }); +}); diff --git a/electron/main/services/comments/data.ts b/electron/main/services/comments/data.ts index 85f1d5ac7..481a8bc39 100644 --- a/electron/main/services/comments/data.ts +++ b/electron/main/services/comments/data.ts @@ -28,10 +28,13 @@ interface NeteaseComment { replyCount?: number; } -interface NeteaseCommentBody { +export interface NeteaseCommentBody { total?: number; hotComments?: NeteaseComment[]; comments?: NeteaseComment[]; + /** legacy 评论端点用顶层 more 表示还有下一页 */ + more?: boolean; + hasMore?: boolean; data?: { totalCount?: number; comments?: NeteaseComment[]; @@ -98,21 +101,31 @@ const toStringId = (value: unknown): string => { return ""; }; -const optionalString = (value: unknown): string | undefined => { +export const optionalString = (value: unknown): string | undefined => { if (typeof value !== "string") return undefined; const text = value.trim(); return text || undefined; }; -/** 转换 NCM 评论项 */ -export const normalizeNeteaseComment = (raw: NeteaseComment): MusicCommentItem | null => { - const id = toStringId(raw.commentId ?? raw.beRepliedCommentId); +/** + * 转换 NCM 评论项 + * @param raw - 原始评论数据 + * @param fallbackId - 回复项缺少自身 id 时的兜底,避免多条回复共享父 id + * @returns 归一化评论,缺少 id 或正文时返回 null + */ +export const normalizeNeteaseComment = ( + raw: NeteaseComment, + fallbackId?: { parentId: string; index: number }, +): MusicCommentItem | null => { + const id = + toStringId(raw.commentId ?? raw.beRepliedCommentId) || + (fallbackId ? `${fallbackId.parentId}_reply_${fallbackId.index}` : ""); const text = optionalString(raw.content); if (!id || !text) return null; const userId = toStringId(raw.user?.userId); const reply = (raw.beReplied ?? []) - .map((item) => normalizeNeteaseComment(item)) + .map((item, index) => normalizeNeteaseComment(item, { parentId: id, index })) .filter((item): item is MusicCommentItem => item !== null); const item: MusicCommentItem = { @@ -132,7 +145,14 @@ export const normalizeNeteaseComment = (raw: NeteaseComment): MusicCommentItem | return item; }; -/** 转换 NCM 评论分页 */ +/** + * 转换 NCM 评论分页 + * @param body - 原始响应正文 + * @param type - 评论分类 + * @param page - 页码 + * @param limit - 每页数量 + * @returns 归一化分页,total 缺失时以 0 表示未知 + */ export const normalizeNeteaseCommentPage = ( body: NeteaseCommentBody, type: CommentTab, @@ -146,12 +166,15 @@ export const normalizeNeteaseCommentPage = ( const list = rawList .map((item) => normalizeNeteaseComment(item)) .filter((item): item is MusicCommentItem => item !== null); + // legacy 端点用顶层 more,新端点给出 data.hasMore + const hasMore = body.more ?? body.hasMore ?? body.data?.hasMore; return { list, - total: body.total ?? body.data?.totalCount ?? list.length, + total: body.total ?? body.data?.totalCount ?? 0, page, limit, + ...(typeof hasMore === "boolean" ? { hasMore } : {}), }; }; @@ -184,7 +207,13 @@ export const normalizeQQMusicComment = (raw: QQMusicComment): MusicCommentItem | return item; }; -/** 转换 QM 评论分页 */ +/** + * 转换 QM 评论分页 + * @param body - 原始响应正文 + * @param page - 页码 + * @param limit - 每页数量 + * @returns 归一化分页,仍有下一页时保留游标(允许空串) + */ export const normalizeQQMusicCommentPage = ( body: QQMusicCommentBody, page: number, @@ -193,12 +222,14 @@ export const normalizeQQMusicCommentPage = ( const list = (body.comments ?? []) .map((item) => normalizeQQMusicComment(item)) .filter((item): item is MusicCommentItem => item !== null); + const hasMore = typeof body.hasMore === "boolean" ? body.hasMore : undefined; return { list, - total: body.total ?? list.length, + total: body.total ?? 0, page, limit, - ...(body.hasMore && body.nextCursor ? { nextCursor: body.nextCursor } : {}), + ...(hasMore === undefined ? {} : { hasMore }), + ...(hasMore ? { nextCursor: body.nextCursor ?? "" } : {}), }; }; @@ -245,12 +276,38 @@ export const normalizeKugouCommentPage = ( .filter((item): item is MusicCommentItem => item !== null); return { list, - total: data.count ?? list.length, + total: data.count ?? 0, page: data.current_page ?? page, limit, }; }; +/** + * 从已归一化的评论中筛出主创评论,主创在粉丝评论下的回复同样计入 + * @param items - 已归一化的评论列表 + * @param accountMap - 网易云账号 id → 歌手名 + * @returns 命中的主创评论,creatorName 为对应歌手名 + */ +export const scanCreatorComments = ( + items: MusicCommentItem[], + accountMap: Map, +): MusicCommentItem[] => { + if (accountMap.size === 0) return []; + const result: MusicCommentItem[] = []; + const push = (item: MusicCommentItem, userId: string): void => { + result.push({ ...item, creatorName: accountMap.get(userId) ?? "" }); + }; + for (const item of items) { + const userId = item.userId; + if (userId && accountMap.has(userId)) push(item, userId); + for (const reply of item.reply ?? []) { + const replyUserId = reply.userId; + if (replyUserId && accountMap.has(replyUserId)) push(reply, replyUserId); + } + } + return result; +}; + /** 构建可用评论源 */ export const buildCommentSources = ( plugins: Array< diff --git a/electron/main/services/comments/index.ts b/electron/main/services/comments/index.ts index 76895c136..4febaf74b 100644 --- a/electron/main/services/comments/index.ts +++ b/electron/main/services/comments/index.ts @@ -9,6 +9,7 @@ import { pluginLog } from "@main/utils/logger"; import type { CommentSource, MusicCommentPage, MusicCommentQuery } from "@shared/types/comment"; import type { MusicSearchCandidate } from "@shared/types/plugin"; import type { Track } from "@shared/types/player"; +import { BoundedMap, coalesce } from "./cache"; import { buildCommentSources, normalizeNeteaseCommentPage, @@ -23,6 +24,21 @@ const QQMUSIC_SOURCE_ID = "builtin:qqmusic"; const KUGOU_SOURCE_ID = "builtin:kugou"; const NETEASE_RESOURCE_TYPE = "R_SO_4_"; +const SONG_META_CACHE_LIMIT = 200; + +/** 网易云歌曲元信息 */ +export type NeteaseSongMeta = { songId: string; artistIds: string[] }; + +/** 曲目 key → 网易云歌曲元信息(null 表示负缓存),分页与主创说复用同一次搜索 */ +const songMetaCache = new BoundedMap(SONG_META_CACHE_LIMIT); +/** 元信息解析在途请求 */ +const songMetaInflight = new Map>(); +/** QQ / 酷狗 id 解析在途请求,避免两个 tab 同时搜索同一曲目 */ +const trackIdInflight = new Map>(); + +const makeTrackKey = (track: Track): string => + `${track.source}\n${track.serverId ?? ""}\n${track.id}`; + const PLATFORM_TO_PLUGIN_SOURCE: Record = { netease: "wy", qqmusic: "tx", @@ -80,46 +96,73 @@ const findPluginMatch = async ( return pickBestCandidate(candidates, track)?.extra ?? null; }; -const findNeteaseId = async (track: Track): Promise => { - if (track.source === "netease" && track.id) return track.id; - const keyword = toKeyword(track); - if (!keyword) return null; - const { status, body } = await callNetease("search", { - keywords: keyword, - type: 1, - limit: 20, +/** + * 解析曲目对应的网易云歌曲元信息 + * 曲目本身来自网易云时直接取自身信息,其余来源走一次搜索同时取歌曲 id 与歌手 id + * @param track - 待解析曲目 + * @returns 歌曲元信息,无匹配结果时返回 null + */ +export const findNeteaseSongMeta = async (track: Track): Promise => { + const cacheKey = makeTrackKey(track); + const cached = songMetaCache.get(cacheKey); + if (cached !== undefined) return cached; + if (track.source === "netease" && track.id) { + const meta: NeteaseSongMeta = { + songId: track.id, + artistIds: track.artists + .map((artist) => artist.id) + .filter((id): id is string => Boolean(id) && id !== "undefined"), + }; + songMetaCache.set(cacheKey, meta); + return meta; + } + return coalesce(songMetaInflight, cacheKey, async () => { + const keyword = toKeyword(track); + if (!keyword) return null; + const { status, body } = await callNetease("search", { + keywords: keyword, + type: 1, + limit: 20, + }); + if (status !== 200) return null; + const songs = body.result?.songs ?? []; + const candidates: LyricCandidate[] = songs.map( + (song: { + id: string | number; + name?: string; + artists?: { id?: string | number; name: string }[]; + album?: { name?: string }; + duration?: number; + }) => ({ + name: song.name ?? "", + artist: (song.artists ?? []).map((artist) => artist.name).join(" / "), + album: song.album?.name, + duration: song.duration, + extra: { + songId: String(song.id), + artistIds: (song.artists ?? []) + .map((artist) => (artist.id ? String(artist.id) : "")) + .filter(Boolean), + }, + }), + ); + const meta = pickBestCandidate(candidates, track)?.extra ?? null; + songMetaCache.set(cacheKey, meta); + return meta; }); - if (status !== 200) return null; - const songs = body.result?.songs ?? []; - const candidates: LyricCandidate<{ id: string }>[] = songs.map( - (song: { - id: string | number; - name?: string; - artists?: { name: string }[]; - album?: { name?: string }; - duration?: number; - }) => ({ - name: song.name ?? "", - artist: (song.artists ?? []).map((artist) => artist.name).join(" / "), - album: song.album?.name, - duration: song.duration, - extra: { id: String(song.id) }, - }), - ); - return pickBestCandidate(candidates, track)?.extra.id ?? null; }; const getNeteaseComments = async (args: MusicCommentQuery): Promise => { - const id = await findNeteaseId(args.track); - if (!id) return { list: [], total: 0, page: args.page, limit: args.limit }; - + const meta = await findNeteaseSongMeta(args.track); + if (!meta) return { list: [], total: 0, page: args.page, limit: args.limit, hasMore: false }; const apiName = args.type === "hot" ? "comment_hot" : "comment_music"; - const { body } = await callNetease(apiName, { - id, + const { status, body } = await callNetease(apiName, { + id: meta.songId, type: NETEASE_RESOURCE_TYPE, limit: args.limit, offset: (args.page - 1) * args.limit, }); + if (status !== 200) throw new Error(`netease comment request failed: ${status}`); return normalizeNeteaseCommentPage(body, args.type, args.page, args.limit); }; @@ -129,22 +172,30 @@ const findQQMusicId = async (track: Track): Promise => { } const keyword = toKeyword(track); if (!keyword) return null; - const body = await callQQMusic("search", { keywords: keyword, type: 0, page: 1, limit: 20 }); - const candidates: LyricCandidate<{ id: string }>[] = (body.songs ?? []).map( - (song: { id?: string; name?: string; artist?: string; album?: string; duration?: number }) => ({ - name: song.name ?? "", - artist: song.artist ?? "", - album: song.album, - duration: song.duration, - extra: { id: song.id ?? "" }, - }), - ); - return pickBestCandidate(candidates, track)?.extra.id || null; + return coalesce(trackIdInflight, `qqmusic\n${makeTrackKey(track)}`, async () => { + const body = await callQQMusic("search", { keywords: keyword, type: 0, page: 1, limit: 20 }); + const candidates: LyricCandidate<{ id: string }>[] = (body.songs ?? []).map( + (song: { + id?: string; + name?: string; + artist?: string; + album?: string; + duration?: number; + }) => ({ + name: song.name ?? "", + artist: song.artist ?? "", + album: song.album, + duration: song.duration, + extra: { id: song.id ?? "" }, + }), + ); + return pickBestCandidate(candidates, track)?.extra.id || null; + }); }; const getQQMusicComments = async (args: MusicCommentQuery): Promise => { const id = await findQQMusicId(args.track); - if (!id) return { list: [], total: 0, page: args.page, limit: args.limit }; + if (!id) return { list: [], total: 0, page: args.page, limit: args.limit, hasMore: false }; const body = await callQQMusic("comment", { id, type: args.type, @@ -159,26 +210,29 @@ const findKugouId = async (track: Track): Promise => { if (track.source === "kugou" && track.extId) return track.extId; const keyword = toKeyword(track); if (!keyword) return null; - const body = await callKugou<{ songs?: KGSong[] }>("search", { - keywords: keyword, - type: 0, - page: 1, - limit: 20, + return coalesce(trackIdInflight, `kugou\n${makeTrackKey(track)}`, async () => { + const body = await callKugou<{ songs?: KGSong[] }>("search", { + keywords: keyword, + type: 0, + page: 1, + limit: 20, + }); + const candidates: LyricCandidate<{ id: string }>[] = (body.songs ?? []).map((song) => ({ + name: song.name, + artist: song.artist, + album: song.album, + duration: song.duration, + extra: { id: song.albumAudioId ? String(song.albumAudioId) : "" }, + })); + return pickBestCandidate(candidates, track)?.extra.id || null; }); - const candidates: LyricCandidate<{ id: string }>[] = (body.songs ?? []).map((song) => ({ - name: song.name, - artist: song.artist, - album: song.album, - duration: song.duration, - extra: { id: song.albumAudioId ? String(song.albumAudioId) : "" }, - })); - return pickBestCandidate(candidates, track)?.extra.id || null; }; const getKugouComments = async (args: MusicCommentQuery): Promise => { - if (args.type !== "hot") return { list: [], total: 0, page: args.page, limit: args.limit }; + if (args.type !== "hot") + return { list: [], total: 0, page: args.page, limit: args.limit, hasMore: false }; const id = await findKugouId(args.track); - if (!id) return { list: [], total: 0, page: args.page, limit: args.limit }; + if (!id) return { list: [], total: 0, page: args.page, limit: args.limit, hasMore: false }; const body = await callKugou("comment", { id, page: args.page, @@ -195,7 +249,8 @@ const getPluginComments = async ( if (!rt || rt.status.state !== "ready") throw new Error("plugin comment source is not ready"); try { const musicInfo = await findPluginMatch(rt, parsed.source, args.track); - if (!musicInfo) return { list: [], total: 0, page: args.page, limit: args.limit }; + if (!musicInfo) + return { list: [], total: 0, page: args.page, limit: args.limit, hasMore: false }; return await callMusicComment(rt, { source: parsed.source, musicInfo, diff --git a/electron/preload/index.ts b/electron/preload/index.ts index c4acf4dbb..fc207f881 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -24,7 +24,7 @@ import type { PlayEventInput, FavoriteEventInput } from "@shared/types/stats"; import type { TagEditRequest } from "@shared/types/tagEditor"; import type { UpdateEvent } from "@shared/types/update"; import type { CloudUploadProgress } from "@shared/types/cloudUpload"; -import type { MusicCommentQuery } from "@shared/types/comment"; +import type { MusicCommentCreatorQuery, MusicCommentQuery } from "@shared/types/comment"; import type { AiModelSaveInput } from "@shared/types/ai"; import type { DesktopLyricUnlockButtonBounds } from "@shared/types/window"; import type { @@ -532,6 +532,7 @@ const api = { comments: { sources: () => ipcRenderer.invoke("comments:sources"), get: (args: MusicCommentQuery) => ipcRenderer.invoke("comments:get", args), + creator: (args: MusicCommentCreatorQuery) => ipcRenderer.invoke("comments:creator", args), }, download: { // 入队下载 diff --git a/shared/types/comment.ts b/shared/types/comment.ts index 671f3fbcd..2b82a02b1 100644 --- a/shared/types/comment.ts +++ b/shared/types/comment.ts @@ -33,6 +33,8 @@ export interface MusicCommentItem { replyTotal?: number; reply?: MusicCommentItem[]; hasMoreReply?: boolean; + /** 主创评论对应的歌手名,仅主创说结果携带 */ + creatorName?: string; } /** 歌曲评论分页 */ @@ -43,6 +45,8 @@ export interface MusicCommentPage { limit: number; /** 下一页游标 */ nextCursor?: string; + /** 是否还有下一页,由各来源的原生信号给出 */ + hasMore?: boolean; } /** 评论查询参数 */ @@ -60,8 +64,19 @@ export interface MusicCommentQuery { export type MusicCommentResponse = { ok: true; data: MusicCommentPage } | { ok: false; error: string }; +/** 主创说查询参数 */ +export interface MusicCommentCreatorQuery { + sourceId: string; + track: Track; +} + +/** 主创说 IPC 响应 */ +export type MusicCommentCreatorResponse = + { ok: true; data: MusicCommentItem[] } | { ok: false; error: string }; + /** 渲染端评论 API */ export interface CommentsApi { sources: () => Promise; get: (args: MusicCommentQuery) => Promise; + creator: (args: MusicCommentCreatorQuery) => Promise; } From b980140c6cc1913cc43e4475a5ab956c455e1e46 Mon Sep 17 00:00:00 2001 From: WindDrift <115442729+WindDrift@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:50:40 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E7=AB=AF=E8=AF=84=E8=AE=BA=E6=95=B0=E6=8D=AE=E5=B1=82?= =?UTF-8?q?=E4=B8=8E=E5=88=86=E9=A1=B5=E7=80=91=E5=B8=83=E6=B5=81=E5=B7=A5?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/composables/useMusicComments.spec.ts | 128 +++++++++ src/composables/useMusicComments.ts | 320 +++++++++++++++++++++++ src/services/commentPageSnapshot.ts | 22 ++ src/utils/commentMasonry.spec.ts | 22 ++ src/utils/commentMasonry.ts | 9 + src/utils/commentPagination.spec.ts | 44 ++++ src/utils/commentPagination.ts | 28 ++ 7 files changed, 573 insertions(+) create mode 100644 src/composables/useMusicComments.spec.ts create mode 100644 src/composables/useMusicComments.ts create mode 100644 src/services/commentPageSnapshot.ts create mode 100644 src/utils/commentMasonry.spec.ts create mode 100644 src/utils/commentMasonry.ts create mode 100644 src/utils/commentPagination.spec.ts create mode 100644 src/utils/commentPagination.ts diff --git a/src/composables/useMusicComments.spec.ts b/src/composables/useMusicComments.spec.ts new file mode 100644 index 000000000..f7b740d68 --- /dev/null +++ b/src/composables/useMusicComments.spec.ts @@ -0,0 +1,128 @@ +import { effectScope, nextTick, ref, type Ref } from "vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Track } from "@shared/types/player"; + +type CreatorResult = { ok: true; data: never[] } | { ok: false; error: string }; + +const api = vi.hoisted(() => ({ + sources: vi.fn(async () => [{ id: "builtin:netease", name: "网易云", kind: "builtin" }]), + get: vi.fn(), + creator: vi.fn<() => Promise>(async () => ({ ok: true, data: [] })), +})); + +vi.stubGlobal("window", { api: { comments: api } }); +vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock("@/composables/useToast", () => ({ toast: { error: vi.fn() } })); + +const { useMusicComments } = await import("./useMusicComments"); + +const track = (id: string): Track => ({ + id, + source: "netease", + title: `歌名${id}`, + artists: [], + duration: 0, +}); + +const pageOf = (ids: string[], overrides: Record = {}) => ({ + list: ids.map((id) => ({ id, userName: id, text: id })), + total: ids.length, + page: 1, + limit: 20, + hasMore: false, + ...overrides, +}); + +/** + * 在独立作用域内运行 composable,便于统一销毁 + * @param trackRef - 曲目引用 + * @returns 作用域内的返回值与销毁函数 + */ +const setup = (trackRef: Ref) => { + const scope = effectScope(); + const result = scope.run(() => useMusicComments(trackRef, ref(null)))!; + return { ...result, stop: () => scope.stop() }; +}; + +/** + * 等待初始加载链路完成 + * @returns 无 + */ +const settle = async (): Promise => { + for (let round = 0; round < 5; round++) { + await Promise.resolve(); + await nextTick(); + } +}; + +describe("评论数据层", () => { + beforeEach(() => { + api.sources.mockClear(); + api.get.mockReset(); + api.creator.mockClear(); + api.get.mockResolvedValue({ ok: true, data: pageOf(["1", "2"]) }); + }); + + it("请求进行中切换曲目后丢弃过期响应", async () => { + let resolveFirst!: (value: unknown) => void; + api.get + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValue({ ok: true, data: pageOf(["9"]) }); + + const trackRef = ref(track("a")); + const comments = setup(trackRef); + trackRef.value = track("b"); + await settle(); + resolveFirst({ ok: true, data: pageOf(["1", "2"]) }); + await settle(); + + expect(comments.page.value.list.map((entry) => entry.id)).toEqual(["9"]); + comments.stop(); + }); + + it("追加时带上已保存的游标", async () => { + api.get + .mockResolvedValueOnce({ ok: true, data: pageOf(["1"], { hasMore: true, nextCursor: "c1" }) }) + .mockResolvedValue({ ok: true, data: pageOf(["2"], { hasMore: false }) }); + const trackRef = ref(track("a")); + const comments = setup(trackRef); + await settle(); + await comments.loadMore(); + const lastCall = api.get.mock.calls.at(-1)?.[0]; + expect(lastCall?.cursor).toBe("c1"); + expect(lastCall?.page).toBe(2); + comments.stop(); + }); + + it("主创说失败时写入错误并可重试", async () => { + api.creator.mockResolvedValueOnce({ ok: false, error: "boom" }); + api.creator.mockResolvedValue({ ok: true, data: [] }); + const trackRef = ref(track("a")); + const comments = setup(trackRef); + await settle(); + expect(comments.creatorError.value).toBe("boom"); + await comments.retryCreator(); + expect(comments.creatorError.value).toBe(""); + comments.stop(); + }); + + it("热评为空而最新非空时切换到最新", async () => { + api.get.mockImplementation(async (args: { type: string }) => ({ + ok: true, + data: + args.type === "hot" + ? { list: [], total: 0, page: 1, limit: 20, hasMore: false } + : { list: [{ id: "1", userName: "u", text: "t" }], total: 1, page: 1, limit: 20 }, + })); + const trackRef = ref(track("a")); + const comments = setup(trackRef); + await settle(); + expect(comments.activeTab.value).toBe("new"); + comments.stop(); + }); +}); diff --git a/src/composables/useMusicComments.ts b/src/composables/useMusicComments.ts new file mode 100644 index 000000000..2c738b5d9 --- /dev/null +++ b/src/composables/useMusicComments.ts @@ -0,0 +1,320 @@ +import type { Ref } from "vue"; +import type { Track } from "@shared/types/player"; +import type { + CommentSource, + CommentTab, + MusicCommentItem, + MusicCommentPage, +} from "@shared/types/comment"; +import { toast } from "@/composables/useToast"; +import { appendUniqueComments, hasNextCommentPage } from "@/utils/commentPagination"; + +const DEFAULT_LIMIT = 20; +const NETEASE_SOURCE_ID = "builtin:netease"; + +type CommentTabState = MusicCommentPage & { + hasMore: boolean; + loadingMore: boolean; + initialError: string; + appendError: string; +}; + +/** + * 构造空的评论分页状态 + * @returns 尚未加载任何数据的分页状态 + */ +const createTabState = (): CommentTabState => ({ + list: [], + total: 0, + page: 0, + limit: DEFAULT_LIMIT, + hasMore: true, + loadingMore: false, + initialError: "", + appendError: "", +}); + +/** + * 生成曲目身份键,用于判断请求期间是否已切换曲目 + * @param track - 曲目 + * @returns 曲目身份键 + */ +const makeTrackKey = (track: Track): string => + `${track.source}\n${track.serverId ?? ""}\n${track.id}`; + +/** + * 生成曲目与评论源组成的上下文键 + * @param track - 曲目 + * @param source - 评论源 id + * @returns 上下文键 + */ +const makeContextKey = (track: Track, source: string): string => + `${makeTrackKey(track)}\n${source}`; + +/** + * 歌曲评论数据加载 + * 四个作废层共同保证迟到的响应不会覆盖新的曲目、来源或分页状态 + * @param trackRef - 当前曲目引用,独立页为冻结快照,全屏内嵌为当前播放曲目 + * @param scrollRef - 评论滚动容器 + */ +export const useMusicComments = ( + trackRef: Ref, + scrollRef: Ref, +) => { + const { t } = useI18n(); + + const sources = shallowRef([]); + const sourceId = ref(""); + const activeTab = ref("hot"); + const loadingCount = ref(0); + const sourceError = ref(""); + + const pages = reactive>({ + hot: createTabState(), + new: createTabState(), + }); + const requestTokens = reactive>({ hot: 0, new: 0 }); + + const creatorComments = shallowRef([]); + const creatorLoading = ref(false); + const creatorError = ref(""); + + let loadingEpoch = 0; + let creatorEpoch = 0; + let disposed = false; + + const sourceOptions = computed(() => + sources.value.map((source) => ({ value: source.id, label: source.name })), + ); + const tabs = computed(() => [ + { key: "hot", label: `${t("comments.hot")} (${pages.hot.total})` }, + { key: "new", label: `${t("comments.new")} (${pages.new.total})` }, + ]); + + const loading = computed(() => loadingCount.value > 0); + const page = computed(() => pages[activeTab.value]); + const error = computed(() => sourceError.value || page.value.initialError); + const creatorIds = computed(() => new Set(creatorComments.value.map((comment) => comment.id))); + const dedupedList = computed(() => + page.value.list.filter((item) => !creatorIds.value.has(item.id)), + ); + + /** 加载可用评论源,失败时记录错误供界面重试 */ + const loadSources = async (): Promise => { + try { + const available = await window.api.comments.sources(); + if (disposed) return; + sources.value = available; + sourceError.value = ""; + if (!available.some((source) => source.id === sourceId.value)) { + sourceId.value = available[0]?.id ?? ""; + } + } catch (err) { + if (disposed) return; + sources.value = []; + sourceError.value = err instanceof Error ? err.message : String(err); + } + }; + + /** 重置分页与主创数据,并作废所有进行中的请求 */ + const resetPages = (): void => { + requestTokens.hot += 1; + requestTokens.new += 1; + creatorEpoch += 1; + loadingEpoch += 1; + loadingCount.value = 0; + pages.hot = createTabState(); + pages.new = createTabState(); + creatorComments.value = []; + creatorLoading.value = false; + creatorError.value = ""; + }; + + /** + * 加载指定分类的评论 + * @param type - 评论分类 + * @param pageNo - 页码,从 1 开始 + * @param append - 是否追加到现有列表 + */ + const loadPage = async (type: CommentTab, pageNo = 1, append = false): Promise => { + const track = trackRef.value; + if (!track || !sourceId.value) return; + + const token = requestTokens[type] + 1; + requestTokens[type] = token; + const contextKey = makeContextKey(track, sourceId.value); + const epoch = loadingEpoch; + const state = pages[type]; + + if (append) { + state.loadingMore = true; + state.appendError = ""; + } else { + loadingCount.value += 1; + state.initialError = ""; + } + + const isCurrentRequest = (): boolean => { + const currentTrack = trackRef.value; + return ( + !disposed && + requestTokens[type] === token && + loadingEpoch === epoch && + !!currentTrack && + makeContextKey(currentTrack, sourceId.value) === contextKey + ); + }; + + try { + const result = await window.api.comments.get({ + sourceId: sourceId.value, + track: toRaw(track), + type, + page: pageNo, + limit: state.limit, + cursor: append ? (state.nextCursor ?? undefined) : undefined, + }); + if (!result.ok) throw new Error(result.error); + if (!isCurrentRequest()) return; + + const previousCount = state.list.length; + const nextList = append + ? appendUniqueComments(state.list, result.data.list) + : result.data.list; + pages[type] = { + ...result.data, + list: nextList, + hasMore: + hasNextCommentPage(result.data, nextList.length) && + (!append || nextList.length > previousCount), + loadingMore: false, + initialError: "", + appendError: "", + }; + } catch (err) { + if (!isCurrentRequest()) return; + const message = err instanceof Error ? err.message : String(err); + if (append) { + pages[type].appendError = message; + } else { + pages[type].initialError = message; + toast.error(message); + } + } finally { + if (isCurrentRequest()) { + if (append) pages[type].loadingMore = false; + else loadingCount.value = Math.max(0, loadingCount.value - 1); + } + } + }; + + /** 加载当前分类的下一页 */ + const loadMore = async (): Promise => { + const state = pages[activeTab.value]; + if (loading.value || state.loadingMore || !state.hasMore || state.page < 1) return; + await loadPage(activeTab.value, state.page + 1, true); + }; + + /** 加载主创说评论,仅网易云内建源可用 */ + const loadCreator = async (): Promise => { + const track = trackRef.value; + if (!track || sourceId.value !== NETEASE_SOURCE_ID) { + creatorComments.value = []; + creatorError.value = ""; + return; + } + const epoch = ++creatorEpoch; + creatorLoading.value = true; + creatorError.value = ""; + try { + const result = await window.api.comments.creator({ + sourceId: sourceId.value, + track: toRaw(track), + }); + if (epoch !== creatorEpoch) return; + if (!result.ok) throw new Error(result.error); + creatorComments.value = result.data; + } catch (err) { + if (epoch !== creatorEpoch) return; + creatorError.value = err instanceof Error ? err.message : String(err); + creatorComments.value = []; + } finally { + if (epoch === creatorEpoch) creatorLoading.value = false; + } + }; + + /** 重试主创说加载 */ + const retryCreator = async (): Promise => { + creatorError.value = ""; + await loadCreator(); + }; + + /** 刷新两组评论与主创说 */ + const refresh = async (): Promise => { + resetPages(); + await Promise.all([loadPage("hot"), loadPage("new"), loadCreator()]); + // 小众歌曲可能没有热门评论,自动落到最新评论避免开局空状态 + if ( + activeTab.value === "hot" && + pages.hot.total === 0 && + pages.hot.initialError === "" && + pages.new.total > 0 + ) { + activeTab.value = "new"; + } + await nextTick(); + scrollRef.value?.scrollTo({ top: 0 }); + }; + + /** 统一重试:评论源加载失败时先重新加载源 */ + const retry = async (): Promise => { + if (sourceError.value) { + await loadSources(); + if (!sourceError.value) await refresh(); + return; + } + await loadPage(activeTab.value, 1); + }; + + watch(trackRef, (next, previous) => { + if (!next || (previous && makeTrackKey(next) === makeTrackKey(previous))) return; + void refresh(); + }); + + watch(sourceId, (next, previous) => { + if (!next || !previous || next === previous) return; + void refresh(); + }); + + void loadSources().then(async () => { + if (!disposed && !sourceError.value) await refresh(); + }); + + onScopeDispose(() => { + disposed = true; + requestTokens.hot += 1; + requestTokens.new += 1; + creatorEpoch += 1; + loadingEpoch += 1; + }); + + return { + sources, + sourceId, + activeTab, + creatorComments, + creatorLoading, + creatorError, + loading, + page, + sourceOptions, + tabs, + creatorIds, + dedupedList, + error, + loadMore, + refresh, + retry, + retryCreator, + }; +}; diff --git a/src/services/commentPageSnapshot.ts b/src/services/commentPageSnapshot.ts new file mode 100644 index 000000000..b21400489 --- /dev/null +++ b/src/services/commentPageSnapshot.ts @@ -0,0 +1,22 @@ +import type { Track } from "@shared/types/player"; + +/** 进入评论页前暂存曲目,页面消费后立即清空,避免长期持有引用 */ +let snapshot: Track | null = null; + +/** + * 设置评论页曲目快照 + * @param track - 进入评论页的曲目 + */ +export const setCommentPageSnapshot = (track: Track): void => { + snapshot = track; +}; + +/** + * 消费评论页曲目快照,取后即空 + * @returns 快照曲目,没有快照时返回 null + */ +export const consumeCommentPageSnapshot = (): Track | null => { + const track = snapshot; + snapshot = null; + return track; +}; diff --git a/src/utils/commentMasonry.spec.ts b/src/utils/commentMasonry.spec.ts new file mode 100644 index 000000000..1ce36d5d4 --- /dev/null +++ b/src/utils/commentMasonry.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { getMasonryRowSpan } from "./commentMasonry"; + +describe("瀑布流行数计算", () => { + it("整除时得到精确行数", () => { + expect(getMasonryRowSpan(30, 10, 0)).toBe(3); + }); + + it("小数向上取整", () => { + expect(getMasonryRowSpan(31, 10, 0)).toBe(4); + }); + + it("零高度与极小高度都至少占一行", () => { + expect(getMasonryRowSpan(0, 1, 12)).toBe(1); + expect(getMasonryRowSpan(0.2, 1, 0)).toBe(1); + }); + + it("间距参与行数计算", () => { + expect(getMasonryRowSpan(21, 10, 10)).toBe(2); + expect(getMasonryRowSpan(21, 10, 0)).toBe(3); + }); +}); diff --git a/src/utils/commentMasonry.ts b/src/utils/commentMasonry.ts new file mode 100644 index 000000000..5cdb588bf --- /dev/null +++ b/src/utils/commentMasonry.ts @@ -0,0 +1,9 @@ +/** + * 计算瀑布流卡片占用的网格行数 + * @param height - 卡片实际内容高度(px) + * @param rowHeight - 网格行高(px) + * @param gap - 网格间距(px) + * @returns 至少 1 的行数 + */ +export const getMasonryRowSpan = (height: number, rowHeight: number, gap: number): number => + Math.max(1, Math.ceil((height + gap) / (rowHeight + gap))); diff --git a/src/utils/commentPagination.spec.ts b/src/utils/commentPagination.spec.ts new file mode 100644 index 000000000..1e81211b1 --- /dev/null +++ b/src/utils/commentPagination.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import type { MusicCommentItem } from "@shared/types/comment"; +import { appendUniqueComments, hasNextCommentPage } from "./commentPagination"; + +const item = (id: string): MusicCommentItem => ({ id, userName: id, text: id }); + +const pageOf = (list: MusicCommentItem[], total: number, limit = 20, hasMore?: boolean) => ({ + list, + total, + page: 1, + limit, + ...(hasMore === undefined ? {} : { hasMore }), +}); + +describe("评论追加去重", () => { + it("对既有与新增内部都去重且保序", () => { + const merged = appendUniqueComments( + [item("1"), item("2")], + [item("2"), item("3"), item("3"), item("1"), item("4")], + ); + expect(merged.map((entry) => entry.id)).toEqual(["1", "2", "3", "4"]); + }); +}); + +describe("评论下一页判定", () => { + it("显式 hasMore 优先于总数推断", () => { + expect(hasNextCommentPage(pageOf([item("1")], 100, 20, true), 1)).toBe(true); + expect(hasNextCommentPage(pageOf([item("1")], 100, 20, false), 1)).toBe(false); + }); + + it("满页且已加载数小于总数时继续", () => { + const list = Array.from({ length: 20 }, (_, index) => item(String(index))); + expect(hasNextCommentPage(pageOf(list, 40), 20)).toBe(true); + }); + + it("短页时结束", () => { + expect(hasNextCommentPage(pageOf([item("1")], 40), 1)).toBe(false); + }); + + it("总数未知时满页继续翻页", () => { + const list = Array.from({ length: 20 }, (_, index) => item(String(index))); + expect(hasNextCommentPage(pageOf(list, 0), 20)).toBe(true); + }); +}); diff --git a/src/utils/commentPagination.ts b/src/utils/commentPagination.ts new file mode 100644 index 000000000..5faf947ba --- /dev/null +++ b/src/utils/commentPagination.ts @@ -0,0 +1,28 @@ +import type { MusicCommentItem, MusicCommentPage } from "@shared/types/comment"; + +/** + * 合并两段评论并去重,保持先后顺序 + * @param current - 已加载的评论 + * @param incoming - 新一页评论 + * @returns 去重合并后的评论列表 + */ +export const appendUniqueComments = ( + current: readonly MusicCommentItem[], + incoming: readonly MusicCommentItem[], +): MusicCommentItem[] => { + const ids = new Set(current.map((item) => item.id)); + return [...current, ...incoming.filter((item) => !ids.has(item.id) && Boolean(ids.add(item.id)))]; +}; + +/** + * 判断评论是否还有下一页 + * 来源给出 hasMore 时以其为准,否则用本页条数与已知总数推断 + * @param page - 本次返回的分页数据 + * @param loadedCount - 去重合并后的已加载总数 + * @returns 是否还有下一页 + */ +export const hasNextCommentPage = (page: MusicCommentPage, loadedCount: number): boolean => { + if (page.hasMore !== undefined) return page.hasMore; + if (page.list.length < page.limit) return false; + return page.total <= 0 || loadedCount < page.total; +}; From e0bf3898cac96fa6c305e6d76c2bc3096db0bc8a Mon Sep 17 00:00:00 2001 From: WindDrift <115442729+WindDrift@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:50:42 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E8=AF=84=E8=AE=BA=E9=A1=B5=E4=B8=8E=E7=80=91=E5=B8=83?= =?UTF-8?q?=E6=B5=81=E8=AF=84=E8=AE=BA=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/comments/CommentCard.vue | 98 +++++++++ src/components/comments/CommentList.vue | 127 ++++++++++++ src/components/ui/SSelect.vue | 15 +- src/components/ui/STabs.vue | 37 ++-- src/i18n/locales/en-US.json | 5 +- src/i18n/locales/zh-CN.json | 5 +- src/pages/Comments.vue | 255 ++++++++++++++++++++++++ src/router/index.ts | 5 + 8 files changed, 529 insertions(+), 18 deletions(-) create mode 100644 src/components/comments/CommentCard.vue create mode 100644 src/components/comments/CommentList.vue create mode 100644 src/pages/Comments.vue diff --git a/src/components/comments/CommentCard.vue b/src/components/comments/CommentCard.vue new file mode 100644 index 000000000..303dc761c --- /dev/null +++ b/src/components/comments/CommentCard.vue @@ -0,0 +1,98 @@ + + + diff --git a/src/components/comments/CommentList.vue b/src/components/comments/CommentList.vue new file mode 100644 index 000000000..e19d37c3a --- /dev/null +++ b/src/components/comments/CommentList.vue @@ -0,0 +1,127 @@ + + + diff --git a/src/components/ui/SSelect.vue b/src/components/ui/SSelect.vue index 6a3bd2657..85d3981ce 100644 --- a/src/components/ui/SSelect.vue +++ b/src/components/ui/SSelect.vue @@ -16,6 +16,8 @@ export interface SSelectProps { placeholder?: string; /** 全圆角胶囊形 */ round?: boolean; + /** 使用全屏播放器配色 */ + cover?: boolean; } const props = withDefaults(defineProps(), { @@ -23,6 +25,7 @@ const props = withDefaults(defineProps(), { disabled: false, placeholder: "", round: false, + cover: false, }); const emit = defineEmits<{ @@ -49,15 +52,21 @@ const handleChange = (val: string) => { @update:open="onOpenChange" > {{ selectedLabel }} diff --git a/src/components/ui/STabs.vue b/src/components/ui/STabs.vue index 65ec500fe..36f5584ce 100644 --- a/src/components/ui/STabs.vue +++ b/src/components/ui/STabs.vue @@ -24,6 +24,8 @@ export interface STabsProps { animated?: boolean; /** 圆角胶囊 */ round?: boolean; + /** 使用全屏播放器配色 */ + cover?: boolean; } const props = withDefaults(defineProps(), { @@ -31,6 +33,7 @@ const props = withDefaults(defineProps(), { size: "medium", animated: false, round: false, + cover: false, }); const emit = defineEmits<{ @@ -216,15 +219,19 @@ const panelTransitionClasses = computed(() => { type === 'bar' && (justifyContent ? 'flex w-full pb-1' : 'inline-flex shrink-0 gap-3 pb-1'), ]" > -
+
@@ -241,16 +248,20 @@ const panelTransitionClasses = computed(() => { type === 'segment' ? ['flex-1 h-full', segmentTabClasses[size]] : sizeClasses[size], tab.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer', modelValue === tab.key - ? { - bar: 'text-primary font-medium', - line: 'text-primary font-medium', - segment: 'text-on-surface font-medium', - }[type] - : { - bar: 'text-on-surface-variant', - line: 'text-on-surface-variant', - segment: 'text-on-surface', - }[type], + ? cover + ? 'text-cover font-medium' + : { + bar: 'text-primary font-medium', + line: 'text-primary font-medium', + segment: 'text-on-surface font-medium', + }[type] + : cover + ? 'text-cover/55 hover:text-cover/75' + : { + bar: 'text-on-surface-variant', + line: 'text-on-surface-variant', + segment: 'text-on-surface', + }[type], ]" @click="select(tab)" > diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index bd48c5af9..e6870cfcd 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -137,7 +137,10 @@ "empty": "No comments yet", "noSource": "No comment source available", "location": "From {location}", - "page": "Page {page} / {total}" + "page": "Page {page} / {total}", + "creator": "Creator Says", + "creatorBadge": "Creator", + "playTrack": "Play Track" }, "equalizer": { "title": "Equalizer", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 532c60ef8..68a347e62 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -137,7 +137,10 @@ "empty": "暂无评论", "noSource": "没有可用的评论来源", "location": "来自{location}", - "page": "第 {page} / {total} 页" + "page": "第 {page} / {total} 页", + "creator": "主创说", + "creatorBadge": "主创", + "playTrack": "播放此曲" }, "equalizer": { "title": "均衡器", diff --git a/src/pages/Comments.vue b/src/pages/Comments.vue new file mode 100644 index 000000000..80853017e --- /dev/null +++ b/src/pages/Comments.vue @@ -0,0 +1,255 @@ + + + diff --git a/src/router/index.ts b/src/router/index.ts index fef5f7f6e..377eb32ff 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -67,6 +67,11 @@ const router = createRouter({ name: "artist", component: () => import("@/pages/Artist.vue"), }, + { + path: "comments/:source/:id", + name: "comments", + component: () => import("@/pages/Comments.vue"), + }, { path: "artists/local", name: "artist-list", From d50cab00645fb35d6d7bbca5ed38b764c8ad906b Mon Sep 17 00:00:00 2001 From: WindDrift <115442729+WindDrift@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:50:44 +0800 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20=E8=AF=84=E8=AE=BA=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E5=88=87=E6=8D=A2=E4=B8=BA=E7=8B=AC=E7=AB=8B=E9=A1=B5?= =?UTF-8?q?=E5=B9=B6=E7=A7=BB=E9=99=A4=E6=97=A7=E8=AF=84=E8=AE=BA=E5=BC=B9?= =?UTF-8?q?=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/modals/MusicCommentsDialog.vue | 308 ------------------ src/components/player/FullPlayer/index.vue | 8 +- src/composables/useTrackMenu.ts | 16 +- src/i18n/locales/en-US.json | 2 - src/i18n/locales/zh-CN.json | 2 - src/layouts/MainLayout.vue | 2 - src/stores/status.ts | 32 +- 7 files changed, 31 insertions(+), 339 deletions(-) delete mode 100644 src/components/modals/MusicCommentsDialog.vue diff --git a/src/components/modals/MusicCommentsDialog.vue b/src/components/modals/MusicCommentsDialog.vue deleted file mode 100644 index 37edc101b..000000000 --- a/src/components/modals/MusicCommentsDialog.vue +++ /dev/null @@ -1,308 +0,0 @@ - - - diff --git a/src/components/player/FullPlayer/index.vue b/src/components/player/FullPlayer/index.vue index 88b34bfc1..774648837 100644 --- a/src/components/player/FullPlayer/index.vue +++ b/src/components/player/FullPlayer/index.vue @@ -260,8 +260,9 @@ const toggleLyric = (): void => { } }; -const showComments = (): void => { - if (displayTrack.value) status.showComments(displayTrack.value); +/** 切换全屏内嵌评论,与播放队列互斥 */ +const toggleFullComments = (): void => { + status.toggleFullComments(); }; @@ -471,7 +472,8 @@ const showComments = (): void => { size="large" circle :disabled="!hasTrack" - @click="showComments" + :class="status.fullCommentsOpen ? 'opacity-100' : 'opacity-40'" + @click="toggleFullComments" > diff --git a/src/composables/useTrackMenu.ts b/src/composables/useTrackMenu.ts index 2acf6f44b..b509b9713 100644 --- a/src/composables/useTrackMenu.ts +++ b/src/composables/useTrackMenu.ts @@ -6,12 +6,12 @@ import type { DropdownMenuItem } from "@/components/ui/SDropdownMenu.vue"; import * as player from "@/core/player"; import { useSettingsStore } from "@/stores/settings"; import { usePluginsStore } from "@/stores/plugins"; -import { useStatusStore } from "@/stores/status"; import { useDownloadStore } from "@/stores/download"; import { useCopyText } from "@/composables/useCopyText"; import { toast } from "@/composables/useToast"; import { buildDownloadQualityItems } from "@/composables/useDownload"; import { getTrackShareUrl } from "@/utils/format/shareUrl"; +import { setCommentPageSnapshot } from "@/services/commentPageSnapshot"; import { openExternal } from "@/utils/url"; import IconPlay from "~icons/lucide/play"; import IconListEnd from "~icons/lucide/list-end"; @@ -62,7 +62,6 @@ export const useTrackMenu = ( ) => { const { t } = useI18n(); const router = useRouter(); - const status = useStatusStore(); const settings = useSettingsStore(); const plugins = usePluginsStore(); const download = useDownloadStore(); @@ -292,9 +291,18 @@ export const useTrackMenu = ( }, }); break; - case "comments": - status.showComments(current); + case "comments": { + setCommentPageSnapshot(toRaw(current)); + await router.push({ + name: "comments", + params: { source: current.source, id: encodeURIComponent(current.id) }, + query: { + name: current.title, + artists: current.artists.map((artist) => artist.name).join(" / "), + }, + }); break; + } case "copyTitle": await copy(current.title); break; diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index e6870cfcd..663d9a56e 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -130,14 +130,12 @@ }, "comments": { "name": "Song Comments", - "title": "Comments for \"{name}\"", "hot": "Top Comments", "new": "Latest Comments", "loading": "Loading comments...", "empty": "No comments yet", "noSource": "No comment source available", "location": "From {location}", - "page": "Page {page} / {total}", "creator": "Creator Says", "creatorBadge": "Creator", "playTrack": "Play Track" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 68a347e62..4ecf953ea 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -130,14 +130,12 @@ }, "comments": { "name": "歌曲评论", - "title": "「{name}」的评论", "hot": "热门评论", "new": "最新评论", "loading": "评论加载中...", "empty": "暂无评论", "noSource": "没有可用的评论来源", "location": "来自{location}", - "page": "第 {page} / {total} 页", "creator": "主创说", "creatorBadge": "主创", "playTrack": "播放此曲" diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index b4679f818..de9ede1a4 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -227,8 +227,6 @@ const playerBarInnerClass = computed(() => { - - diff --git a/src/stores/status.ts b/src/stores/status.ts index d22ba1750..8748f6821 100644 --- a/src/stores/status.ts +++ b/src/stores/status.ts @@ -1,10 +1,4 @@ -import type { - PlayerState, - AudioDevice, - RepeatMode, - ShuffleMode, - Track, -} from "@shared/types/player"; +import type { PlayerState, AudioDevice, RepeatMode, ShuffleMode } from "@shared/types/player"; import type { Platform } from "@shared/types/platform"; import type { ContentScope } from "@/types/collection"; import type { SongListSortKey, SongListSortPreference } from "@/types/list"; @@ -38,12 +32,10 @@ export const useStatusStore = defineStore( const fullQueueOpen = ref(false); /** 搜索弹窗状态 */ const searchOpen = ref(false); - /** 评论弹窗状态 */ - const commentsOpen = ref(false); - /** 评论弹窗当前歌曲 */ - const commentsTrack = shallowRef(null); /** 音频输出信息弹窗状态 */ const audioInfoOpen = ref(false); + /** 全屏播放器内嵌评论开关 */ + const fullCommentsOpen = ref(false); /** 全屏播放器是否展示歌词 */ const showLyric = ref(true); /** 当前播放索引 */ @@ -132,12 +124,17 @@ export const useStatusStore = defineStore( /** 当前队列项对应的播放来源上下文 */ const currentPlaybackContext = computed(() => queue.getQueueItem(playIndex.value)?.context); - /** 打开指定歌曲评论 */ - const showComments = (track: Track): void => { - commentsTrack.value = track; - commentsOpen.value = true; + /** 切换全屏播放器内嵌评论,与播放队列互斥 */ + const toggleFullComments = (): void => { + if (fullQueueOpen.value) fullQueueOpen.value = false; + fullCommentsOpen.value = !fullCommentsOpen.value; }; + // 播放队列打开时收起评论,覆盖所有打开队列的入口 + watch(fullQueueOpen, (open) => { + if (open) fullCommentsOpen.value = false; + }); + return { state, position, @@ -154,9 +151,9 @@ export const useStatusStore = defineStore( outerQueueOpen, fullQueueOpen, searchOpen, - commentsOpen, - commentsTrack, audioInfoOpen, + fullCommentsOpen, + toggleFullComments, showLyric, outputDevices, playIndex, @@ -181,7 +178,6 @@ export const useStatusStore = defineStore( currentTrack, currentIndex, currentPlaybackContext, - showComments, }; }, { From f4ca797437c9470fcceec8a41866e4228f9dfde2 Mon Sep 17 00:00:00 2001 From: WindDrift <115442729+WindDrift@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:50:48 +0800 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20=E5=85=A8=E5=B1=8F=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=99=A8=E5=86=85=E5=B5=8C=E8=AF=84=E8=AE=BA=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../player/FullPlayer/FullPlayerComments.vue | 246 ++++++++++++++++++ src/components/player/FullPlayer/index.vue | 88 +++++-- 2 files changed, 312 insertions(+), 22 deletions(-) create mode 100644 src/components/player/FullPlayer/FullPlayerComments.vue diff --git a/src/components/player/FullPlayer/FullPlayerComments.vue b/src/components/player/FullPlayer/FullPlayerComments.vue new file mode 100644 index 000000000..d28cccb77 --- /dev/null +++ b/src/components/player/FullPlayer/FullPlayerComments.vue @@ -0,0 +1,246 @@ + + + diff --git a/src/components/player/FullPlayer/index.vue b/src/components/player/FullPlayer/index.vue index 774648837..afae7f41a 100644 --- a/src/components/player/FullPlayer/index.vue +++ b/src/components/player/FullPlayer/index.vue @@ -12,6 +12,7 @@ import { useImmersiveMode } from "@/composables/useImmersiveMode"; import { useTimeFormat } from "@/composables/useTimeFormat"; import { useProgressLyric } from "@/composables/useProgressLyric"; import Lyrics from "@/components/player/Lyrics/index.vue"; +import FullPlayerComments from "./FullPlayerComments.vue"; import { useWindowControls } from "@/composables/useWindowControls"; import * as player from "@/core/player"; import { playerTransitionActive, setPlayerExpanded } from "@/services/playerTransition"; @@ -191,10 +192,17 @@ const fullscreenCover = computed(() => settings.player.coverLayout === "fullscre const coverWidth = computed(() => `${settings.player.coverLyricRatio * 100}%`); const coverCentered = computed(() => { - if (fullscreenCover.value || status.fullQueueOpen) return false; + if (fullscreenCover.value || status.fullQueueOpen || status.fullCommentsOpen) return false; return !showLyric.value || (settings.player.autoCenterCover && !hasLyric.value); }); +/** 全屏内嵌评论布局:off 关闭、half 左半屏、full 全屏 */ +const fullCommentMode = computed<"off" | "half" | "full">(() => { + if (!status.fullCommentsOpen) return "off"; + // 以实际就绪的歌词判定,避免歌词加载中导致面板宽度反复切换 + return media.parsedLyric.length > 0 && showLyric.value ? "half" : "full"; +}); + const handleLyricSeek = async (timeMs: number): Promise => { await player.seek(timeMs); if (!isPlaying.value) await player.play(); @@ -259,11 +267,6 @@ const toggleLyric = (): void => { showLyric.value = !showLyric.value; } }; - -/** 切换全屏内嵌评论,与播放队列互斥 */ -const toggleFullComments = (): void => { - status.toggleFullComments(); -};