From 97c0f4e61b92aedfc0b8db04eca88f2cf3e164dc Mon Sep 17 00:00:00 2001 From: WindDrift <115442729+WindDrift@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:35:10 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(comment):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E6=AD=8C=E6=9B=B2=E8=AF=84=E8=AE=BA=E9=A1=B5?= =?UTF-8?q?=E4=B8=8E=E5=85=A8=E5=B1=8F=E5=86=85=E5=B5=8C=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/ipc/comments.ts | 23 +- electron/main/services/comments/creator.ts | 155 ++++++++++++ electron/main/services/comments/data.ts | 11 + electron/main/services/comments/index.ts | 38 ++- electron/preload/index.ts | 4 +- shared/types/comment.ts | 12 + src/components/comments/CommentCard.vue | 69 +++++ src/components/comments/CommentList.vue | 42 ++++ src/components/modals/MusicCommentsDialog.vue | 4 + .../player/FullPlayer/FullPlayerComments.vue | 172 +++++++++++++ src/components/player/FullPlayer/index.vue | 64 ++++- src/composables/useMusicComments.ts | 209 ++++++++++++++++ src/composables/useTrackMenu.ts | 16 +- src/i18n/locales/en-US.json | 7 +- src/i18n/locales/zh-CN.json | 7 +- src/pages/Comments.vue | 236 ++++++++++++++++++ src/router/index.ts | 5 + src/services/commentPageSnapshot.ts | 22 ++ src/stores/status.ts | 3 + 19 files changed, 1076 insertions(+), 23 deletions(-) create mode 100644 electron/main/services/comments/creator.ts create mode 100644 src/components/comments/CommentCard.vue create mode 100644 src/components/comments/CommentList.vue create mode 100644 src/components/player/FullPlayer/FullPlayerComments.vue create mode 100644 src/composables/useMusicComments.ts create mode 100644 src/pages/Comments.vue create mode 100644 src/services/commentPageSnapshot.ts diff --git a/electron/main/ipc/comments.ts b/electron/main/ipc/comments.ts index e4dbdbf0..85b54b8e 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,19 @@ 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/creator.ts b/electron/main/services/comments/creator.ts new file mode 100644 index 00000000..dfa8873e --- /dev/null +++ b/electron/main/services/comments/creator.ts @@ -0,0 +1,155 @@ +import { callNetease } from "@main/apis/netease"; +import { coreLog } from "@main/utils/logger"; +import type { + MusicCommentCreatorQuery, + MusicCommentItem, +} from "@shared/types/comment"; +import { normalizeNeteaseCommentPage, scanCreatorComments } from "./data"; +import { findNeteaseSongMeta } from "./index"; + +const NETEASE_SOURCE_ID = "builtin:netease"; +const NETEASE_RESOURCE_TYPE = "R_SO_4_"; +const HOT_LIMIT = 20; +/** 首屏前两页未命中时,最多额外翻页数 */ +const MAX_EXTRA_PAGES = 3; +const ARTIST_ACCOUNT_CACHE_LIMIT = 200; +const CREATOR_COMMENTS_CACHE_LIMIT = 100; + +/** + * 简易有界 LRU Map + * Map 保持插入顺序,访问时移到末尾,超限时删除首项 + */ +class BoundedMap { + private readonly map = new Map(); + constructor(private readonly limit: number) {} + + has(key: K): boolean { + return this.map.has(key); + } + + get(key: K): V | undefined { + const v = this.map.get(key); + if (v !== undefined && this.map.size > 1) { + this.map.delete(key); + this.map.set(key, v); + } + return v; + } + + set(key: K, value: V): void { + if (this.map.has(key)) this.map.delete(key); + this.map.set(key, value); + while (this.map.size > this.limit) { + const first = this.map.keys().next().value; + if (first === undefined) break; + this.map.delete(first); + } + } +} + +/** artist id → 绑定的网易云用户 accountId(null 表示负缓存:未绑定) */ +const artistAccountIdCache = new BoundedMap( + ARTIST_ACCOUNT_CACHE_LIMIT, +); +/** 网易云 songId → 主创评论列表(空数组表示负缓存:无主创评论) */ +const creatorCommentsCache = new BoundedMap( + CREATOR_COMMENTS_CACHE_LIMIT, +); + +/** 拉取一页热门评论(归一化后返回) */ +const fetchHotPage = async ( + songId: string, + page: number, +): Promise<{ items: MusicCommentItem[]; hasMore: boolean }> => { + const { body } = await callNetease("comment_hot", { + id: songId, + type: NETEASE_RESOURCE_TYPE, + limit: HOT_LIMIT, + offset: (page - 1) * HOT_LIMIT, + }); + const normalized = normalizeNeteaseCommentPage(body, "hot", page, HOT_LIMIT); + const hasMore = Boolean(body?.hasMore ?? body?.data?.hasMore ?? false); + return { items: normalized.list, hasMore }; +}; + +/** + * 解析歌手列表对应的网易云用户 accountId 集合 + * 多歌手多账号匹配并去重;accountId 为 0 或不存在视为未绑定(负缓存) + */ +const resolveArtistAccountIds = async ( + artistIds: string[], +): Promise> => { + const accountIds = new Set(); + for (const artistId of artistIds) { + if (!artistId) continue; + const cached = artistAccountIdCache.get(artistId); + if (cached !== undefined) { + if (cached) accountIds.add(cached); + continue; + } + try { + const { body } = await callNetease("artists", { id: artistId }); + const raw = body?.artist?.accountId; + const normalized = + typeof raw === "number" && raw > 0 + ? String(raw) + : typeof raw === "string" && raw + ? raw + : null; + artistAccountIdCache.set(artistId, normalized); + if (normalized) accountIds.add(normalized); + } catch (err) { + coreLog.warn( + `[comments] resolve artist ${artistId} accountId failed:`, + err, + ); + artistAccountIdCache.set(artistId, null); + } + } + return accountIds; +}; + +/** + * 获取「主创说」评论:歌曲关联歌手在网易云绑定的账号,在该歌曲热门评论中的发言 + * 仅网易云内建源启用;前两页必拉,未命中且 hasMore 时额外翻页最多 MAX_EXTRA_PAGES 页 + */ +export const getCreatorComments = async ( + args: MusicCommentCreatorQuery, +): Promise => { + if (args.sourceId !== NETEASE_SOURCE_ID) return []; + + const meta = await findNeteaseSongMeta(args.track); + if (!meta) return []; + const { songId, artistIds } = meta; + + const cached = creatorCommentsCache.get(songId); + if (cached) return cached; + + const accountIds = await resolveArtistAccountIds(artistIds); + if (accountIds.size === 0) { + creatorCommentsCache.set(songId, []); + return []; + } + + const result: MusicCommentItem[] = []; + let hasMore = true; + // 前两页必拉,收集全部命中 + for (let page = 1; page <= 2 && hasMore; page++) { + const { items, hasMore: hm } = await fetchHotPage(songId, page); + result.push(...scanCreatorComments(items, accountIds)); + hasMore = hm; + } + // 前两页无命中 + 还有更多 → 额外翻页,命中即停 + if (result.length === 0 && hasMore) { + for (let extra = 1; extra <= MAX_EXTRA_PAGES && hasMore; extra++) { + const { items, hasMore: hm } = await fetchHotPage(songId, 2 + extra); + const hits = scanCreatorComments(items, accountIds); + result.push(...hits); + hasMore = hm; + if (hits.length > 0) break; + } + } + + creatorCommentsCache.set(songId, result); + return result; +}; diff --git a/electron/main/services/comments/data.ts b/electron/main/services/comments/data.ts index 7a9828bf..5792fdf1 100644 --- a/electron/main/services/comments/data.ts +++ b/electron/main/services/comments/data.ts @@ -104,6 +104,17 @@ export const normalizeNeteaseCommentPage = ( }; }; +/** 纯函数:从已归一化的评论中筛选主创评论(userId 命中账号集合) */ +export const scanCreatorComments = ( + items: MusicCommentItem[], + accountIds: Set, +): MusicCommentItem[] => { + if (accountIds.size === 0) return []; + return items.filter( + (item) => item.userId != null && accountIds.has(item.userId), + ); +}; + /** 构建可用评论源 */ export const buildCommentSources = ( plugins: Array< diff --git a/electron/main/services/comments/index.ts b/electron/main/services/comments/index.ts index 0bbe8eef..2797435a 100644 --- a/electron/main/services/comments/index.ts +++ b/electron/main/services/comments/index.ts @@ -68,8 +68,21 @@ const findPluginMatch = async ( return pickBestCandidate(candidates, track)?.extra ?? null; }; -const findNeteaseId = async (track: Track): Promise => { - if (track.source === "netease" && track.id) return track.id; +/** + * 解析 Track 对应的网易云歌曲元信息:songId + 歌手 id 列表 + * 一次 search 同时取两者,避免主创说二次搜索 + */ +export const findNeteaseSongMeta = async ( + track: Track, +): Promise<{ songId: string; artistIds: string[] } | null> => { + if (track.source === "netease" && track.id) { + return { + songId: track.id, + artistIds: track.artists + .map((a) => a.id) + .filter((id): id is string => !!id), + }; + } const keyword = toKeyword(track); if (!keyword) return null; const { status, body } = await callNetease("search", { @@ -79,11 +92,11 @@ const findNeteaseId = async (track: Track): Promise => { }); if (status !== 200) return null; const songs = body.result?.songs ?? []; - const candidates: LyricCandidate<{ id: string }>[] = songs.map( + const candidates: LyricCandidate<{ id: string; artistIds: string[] }>[] = songs.map( (song: { id: string | number; name?: string; - artists?: { name: string }[]; + artists?: { id?: string | number; name: string }[]; album?: { name?: string }; duration?: number; }) => ({ @@ -91,10 +104,23 @@ const findNeteaseId = async (track: Track): Promise => { artist: (song.artists ?? []).map((artist) => artist.name).join(" / "), album: song.album?.name, duration: song.duration, - extra: { id: String(song.id) }, + extra: { + id: String(song.id), + artistIds: (song.artists ?? []) + .map((a) => (a.id ? String(a.id) : "")) + .filter(Boolean), + }, }), ); - return pickBestCandidate(candidates, track)?.extra.id ?? null; + const best = pickBestCandidate(candidates, track)?.extra; + if (!best) return null; + return { songId: best.id, artistIds: best.artistIds }; +}; + +/** 取网易云歌曲 id(薄包装,供普通评论拉取复用) */ +const findNeteaseId = async (track: Track): Promise => { + const meta = await findNeteaseSongMeta(track); + return meta?.songId ?? null; }; const getNeteaseComments = async (args: MusicCommentQuery): Promise => { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index bd2db81d..1ebbd9d7 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -15,7 +15,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"; /** 订阅主进程推送的事件 */ @@ -369,6 +369,8 @@ 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 c8317f2e..4e38738c 100644 --- a/shared/types/comment.ts +++ b/shared/types/comment.ts @@ -54,8 +54,20 @@ 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; } diff --git a/src/components/comments/CommentCard.vue b/src/components/comments/CommentCard.vue new file mode 100644 index 00000000..fcef2c84 --- /dev/null +++ b/src/components/comments/CommentCard.vue @@ -0,0 +1,69 @@ + + + diff --git a/src/components/comments/CommentList.vue b/src/components/comments/CommentList.vue new file mode 100644 index 00000000..a389b3bd --- /dev/null +++ b/src/components/comments/CommentList.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/components/modals/MusicCommentsDialog.vue b/src/components/modals/MusicCommentsDialog.vue index 8f6fe7a4..b728c912 100644 --- a/src/components/modals/MusicCommentsDialog.vue +++ b/src/components/modals/MusicCommentsDialog.vue @@ -1,4 +1,8 @@ + + diff --git a/src/components/player/FullPlayer/index.vue b/src/components/player/FullPlayer/index.vue index 03d8a43c..1d423716 100644 --- a/src/components/player/FullPlayer/index.vue +++ b/src/components/player/FullPlayer/index.vue @@ -13,6 +13,7 @@ import { useTimeFormat } from "@/composables/useTimeFormat"; import Lyrics from "@/components/player/Lyrics/index.vue"; import AMLLLyrics from "@/components/player/Lyrics/AMLLLyrics.vue"; import PlaylistPickerDialog from "@/components/modals/PlaylistPickerDialog.vue"; +import FullPlayerComments from "./FullPlayerComments.vue"; import { useWindowControls } from "@/composables/useWindowControls"; import * as player from "@/core/player"; import { openExternal } from "@/utils/url"; @@ -105,10 +106,18 @@ watch( const fullscreenCover = computed(() => settings.player.coverLayout === "fullscreen"); 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"; + if (fullscreenCover.value) return "half"; + if (!hasLyric.value && settings.player.autoCenterCover) return "full"; + return "half"; +}); + const handleLyricSeek = async (timeMs: number): Promise => { await player.seek(timeMs); if (!isPlaying.value) await player.play(); @@ -173,9 +182,17 @@ const toggleLyric = (): void => { } }; -const showComments = (): void => { - if (media.track) status.showComments(media.track); +const toggleFullComments = (): void => { + if (status.fullQueueOpen) status.fullQueueOpen = false; + status.fullCommentsOpen = !status.fullCommentsOpen; }; + +watch( + () => status.fullQueueOpen, + (open) => { + if (open && status.fullCommentsOpen) status.fullCommentsOpen = false; + }, +);