Files
BYW-VoiceHub/app/components/UI/SongComments.vue
T

1328 lines
37 KiB
Vue

<template>
<section class="song-comments" @click.stop>
<header class="comments-header">
<div>
<p class="comments-eyebrow">{{ locale.eyebrow }}</p>
<h2 class="comments-title">{{ locale.title }}</h2>
</div>
<div class="comments-sort" role="group">
<button :class="{ active: commentSort === 'hot' }" @click="selectCommentSort('hot')">
{{ locale.hotSort }} ({{ formatCount(sortTotals.hot) }})
</button>
<button :class="{ active: commentSort === 'latest' }" @click="selectCommentSort('latest')">
{{ locale.latest }} ({{ formatCount(sortTotals.latest) }})
</button>
</div>
<button
class="refresh-button"
:disabled="isLoading || !canFetchComments"
:title="locale.refresh"
@click="refreshComments"
>
<Icon name="refresh" size="18" />
</button>
</header>
<div v-if="!canFetchComments" class="comments-state">
<Icon name="message-circle" size="28" />
<p>{{ locale.noSource }}</p>
</div>
<div v-else-if="isLoading && !commentItems.length" class="comments-state">
<AppSpinner :size="24" />
<p>{{ locale.loading }}</p>
</div>
<div v-else-if="error && !commentItems.length" class="comments-state">
<Icon name="alert-circle" size="28" />
<p>{{ error }}</p>
<button class="state-action" @click="refreshComments">{{ locale.retry }}</button>
</div>
<template v-else>
<div v-if="totalCount" class="comments-summary">
<span v-if="totalCount">{{
formatLocaleValue(locale.commentsCount, String(totalCount))
}}</span>
</div>
<div v-if="!commentItems.length" class="comments-state">
<Icon name="message-circle" size="28" />
<p>{{ locale.empty }}</p>
</div>
<div v-else class="comments-list">
<article v-for="item in commentItems" :key="item.key" class="comment-item">
<div class="avatar">
<img
v-if="item.user?.avatarUrl"
:src="convertToHttps(item.user.avatarUrl)"
:alt="item.user?.nickname || locale.userAvatar"
referrerpolicy="no-referrer"
>
<Icon v-else name="user" size="18" />
</div>
<div class="comment-body">
<div class="comment-meta">
<div class="comment-user">
<span class="nickname">{{ item.user?.nickname || locale.neteaseUser }}</span>
<span v-if="item.user?.location" class="comment-location">
{{ locale.locationLabel }} {{ item.user.location }}
</span>
</div>
<span class="comment-time">{{ formatCommentTime(item.time) }}</span>
</div>
<div class="comment-content">
<span>{{ item.content }}</span>
<img
v-for="(emoji, emojiIndex) in item.emojis"
:key="`emoji-${emojiIndex}`"
:src="getCommentImageUrl(emoji)"
alt=""
class="inline-comment-emoji"
loading="lazy"
referrerpolicy="no-referrer"
@error="handleCommentImageError($event, emoji)"
>
</div>
<div v-if="item.images?.length" class="comment-images">
<img
v-for="(image, imageIndex) in item.images"
:key="`image-${imageIndex}`"
:src="getCommentImageUrl(image)"
:alt="locale.commentImage"
loading="lazy"
referrerpolicy="no-referrer"
class="comment-image-previewable"
role="button"
tabindex="0"
:aria-label="locale.openImagePreview"
@click="openCommentImagePreview($event, image)"
@error="handleCommentImageError($event, image)"
@keydown.enter.prevent="openCommentImagePreview($event, image)"
>
</div>
<div v-if="item.beReplied?.length" class="reply-preview">
<div class="reply-meta">
{{ item.beReplied[0]?.user?.nickname || locale.originalComment
}}<span v-if="item.beReplied[0]?.user?.location">
· {{ locale.locationLabel }} {{ item.beReplied[0].user.location }}</span
>
</div>
<div>{{ item.beReplied[0]?.content }}</div>
</div>
<div
v-for="reply in getVisibleReplies(item)"
:key="reply.displayKey"
class="reply-preview"
:style="{ '--reply-depth': reply.displayDepth }"
>
<div class="reply-meta">
<div class="reply-user">
<div v-if="!isTencent" class="reply-avatar">
<img
v-if="reply.user?.avatarUrl"
:src="convertToHttps(reply.user.avatarUrl)"
:alt="reply.user?.nickname || locale.userAvatar"
referrerpolicy="no-referrer"
@error="handleCommentImageError($event, reply.user.avatarUrl)"
>
<Icon v-else name="user" size="12" />
</div>
<span>{{ reply.user?.nickname || locale.neteaseUser }}</span>
<span v-if="reply.user?.location">
· {{ locale.locationLabel }} {{ reply.user.location }}</span
>
</div>
</div>
<div class="reply-content">
<span>{{ reply.content }}</span>
<img
v-for="(emoji, emojiIndex) in reply.emojis || []"
:key="`reply-emoji-${emojiIndex}`"
:src="getCommentImageUrl(emoji)"
alt=""
class="inline-comment-emoji"
loading="lazy"
referrerpolicy="no-referrer"
@error="handleCommentImageError($event, emoji)"
>
</div>
<div v-if="reply.images?.length" class="comment-images reply-images">
<img
v-for="(image, imageIndex) in reply.images"
:key="`reply-image-${imageIndex}`"
:src="getCommentImageUrl(image)"
:alt="locale.commentImage"
loading="lazy"
referrerpolicy="no-referrer"
class="comment-image-previewable"
role="button"
tabindex="0"
:aria-label="locale.openImagePreview"
@click="openCommentImagePreview($event, image)"
@error="handleCommentImageError($event, image)"
@keydown.enter.prevent="openCommentImagePreview($event, image)"
>
</div>
<div v-if="!isTencent" class="reply-actions">
<span class="qq-liked-count">
<Icon name="thumbs-up" size="13" />
{{ formatCount(reply.likedCount || 0) }}
</span>
</div>
</div>
<button
v-if="!isTencent && item.displayReplies.length > 1"
class="replies-toggle"
type="button"
@click="toggleReplies(item.key)"
>
{{ isRepliesExpanded(item.key) ? locale.hideReplies : locale.showMoreReplies }}
({{ item.displayReplies.length }})
</button>
<div class="comment-actions">
<button
v-if="!isTencent"
class="liked-count"
:class="{ liked: item.liked }"
:disabled="likeUpdatingKey === String(item.commentId)"
:title="item.liked ? locale.unlike : locale.like"
@click="toggleCommentLike(item)"
>
<Icon :name="item.liked ? 'thumbs-up-filled' : 'thumbs-up'" size="13" />
{{ formatCount(item.likedCount || 0) }}
</button>
<span v-else class="qq-liked-count">
<Icon name="thumbs-up" size="13" />
{{ formatCount(item.likedCount || 0) }}
</span>
</div>
</div>
</article>
</div>
<button
v-if="hasMore"
class="load-more-button"
:disabled="isLoading"
@click="loadMoreComments"
>
{{ isLoading ? locale.loadingMore : locale.loadMore }}
</button>
</template>
</section>
<Teleport to="body">
<div
v-if="previewImageUrl"
class="comment-image-preview"
role="dialog"
aria-modal="true"
:aria-label="locale.commentImage"
@click.self="closeCommentImagePreview"
>
<button
class="comment-image-preview-close"
:title="locale.closeImagePreview"
:aria-label="locale.closeImagePreview"
@click="closeCommentImagePreview"
>
<Icon name="x" size="24" />
</button>
<img :src="previewImageUrl" :alt="locale.commentImage">
</div>
</Teleport>
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import Icon from '~/components/UI/Icon.vue'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import { fetchNetease } from '~/utils/neteaseApi'
import { convertToHttps, getNeteaseCookie } from '~/utils/url'
import { useLocale } from '~/utils/locale'
import { useServerErrors } from '~/composables/useLocaleText'
const props = defineProps({
song: {
type: Object,
default: null
},
visible: {
type: Boolean,
default: false
}
})
const PAGE_SIZE = 20
const { ui } = useLocale()
const { localize: localizeServerError } = useServerErrors()
const locale = computed(() => {
const base = ui.value?.songComments || {}
const emptyText = () => ''
const qq = props.song?.musicPlatform === 'tencent'
return useSafeLocale({
...base,
eyebrow: qq ? base.qqEyebrow || base.eyebrow : base.eyebrow,
noSource: qq ? base.qqNoSource || base.noSource : base.noSource,
neteaseUser: qq ? base.qqUser || base.neteaseUser : base.neteaseUser,
commentsCount: base.commentsCount || emptyText,
minutesAgo: base.minutesAgo || emptyText,
hoursAgo: base.hoursAgo || emptyText
})
})
const comments = ref([])
const hotComments = ref([])
const sortTotals = ref({ hot: 0, latest: 0 })
const sortHasMore = ref({ hot: false, latest: false })
const sortOffsets = ref({ hot: 0, latest: 0 })
const sortCursors = ref({ hot: '', latest: '' })
const sortLoaded = ref({ hot: false, latest: false })
const sortErrors = ref({ hot: '', latest: '' })
const sortLoading = ref({ hot: false, latest: false })
const loadingCount = ref(0)
const likeUpdatingKey = ref('')
const previewImageUrl = ref('')
const requestId = ref(0)
const commentSort = ref('hot')
const expandedReplyKeys = ref(new Set())
const pendingQqReplies = ref([])
const isLoading = computed(() => loadingCount.value > 0)
const error = computed(() => sortErrors.value[commentSort.value] || '')
const totalCount = computed(() => sortTotals.value.latest || sortTotals.value.hot || 0)
const hasMore = computed(() => {
if (commentSort.value === 'hot') {
// 网易云热门评论是初始请求返回的固定集合,接口按最新流翻页,热门 Tab 不提供加载更多
if (!isTencent.value) return false
return Boolean(sortHasMore.value.hot || sortHasMore.value.latest)
}
return Boolean(sortHasMore.value[commentSort.value])
})
const selectCommentSort = (value) => {
if (commentSort.value === value) return
commentSort.value = value
if (props.visible && !sortLoaded.value[value]) {
fetchComments(false, value)
}
}
const neteaseSongId = computed(() => {
const song = props.song
if (!song || song.musicPlatform !== 'netease') return null
const id = String(song.musicId || '').trim()
if (!/^\d+$/.test(id)) return null
return id
})
const tencentSongId = computed(() => {
const song = props.song
if (!song || song.musicPlatform !== 'tencent') return null
const id = String(song.musicId || '').trim()
return id || null
})
const tencentOriginalSongId = computed(() => {
const song = props.song
if (!song || song.musicPlatform !== 'tencent') return null
const id = String(song.sourceInfo?.originalSongId || song.originalSongId || '').trim()
return /^\d+$/.test(id) ? id : null
})
const canFetchComments = computed(() => !!neteaseSongId.value || !!tencentSongId.value)
const isTencent = computed(() => !!tencentSongId.value)
const commentSourceKey = computed(() => {
if (neteaseSongId.value) return `netease:${neteaseSongId.value}`
if (tencentSongId.value) {
return `tencent:${tencentSongId.value}:${tencentOriginalSongId.value || ''}`
}
return ''
})
const flattenCommentReplies = (replies) => {
const flattened = []
const visit = (items, depth, path, ancestorIds) => {
if (!Array.isArray(items) || depth > 8) return
items.forEach((reply, index) => {
if (!reply || typeof reply !== 'object') return
const commentId = String(reply.commentId ?? `${depth}-${index}`)
if (ancestorIds.has(commentId)) return
const displayKey = `${path}-${commentId}-${index}`
flattened.push({
...reply,
displayDepth: Math.min(depth, 3),
displayKey
})
const nextAncestorIds = new Set(ancestorIds)
nextAncestorIds.add(commentId)
visit(reply.replies, depth + 1, displayKey, nextAncestorIds)
})
}
visit(replies, 0, 'reply', new Set())
return flattened
}
const getCommentIdentity = (item) =>
item.commentId != null
? `id:${item.commentId}`
: `text:${item.user?.nickname || ''}|${item.time || 0}|${item.content || ''}`
const uniqueComments = (items) => {
const seen = new Set()
return items.filter((item) => {
const key = getCommentIdentity(item)
if (seen.has(key)) return false
seen.add(key)
return true
})
}
const isPopularByLikes = (item) => Number(item.likedCount || 0) >= 10
const normalizeNeteaseLocation = (item) => {
const source = item?.ipLocation
const fallback = String(item?.user?.location || '').trim()
if (typeof source === 'string') return source.trim()
if (!source || typeof source !== 'object') return fallback
return String(source.location || source.province || source.city || source.country || '').trim() || fallback
}
const normalizeNeteaseComment = (item) => {
if (!item || typeof item !== 'object') return item
const user = item.user && typeof item.user === 'object' ? item.user : {}
return {
...item,
user: {
...user,
location: normalizeNeteaseLocation(item)
},
beReplied: Array.isArray(item.beReplied)
? item.beReplied.map(normalizeNeteaseComment)
: item.beReplied,
replies: Array.isArray(item.replies) ? item.replies.map(normalizeNeteaseComment) : item.replies
}
}
const mergeHotComments = (...groups) =>
uniqueComments(groups.flat()).sort(
(left, right) => Number(right.likedCount || 0) - Number(left.likedCount || 0)
)
const commentItems = computed(() => {
const source = commentSort.value === 'hot' ? hotComments.value : comments.value
return uniqueComments(source).map((item, index) => ({
...item,
displayReplies: flattenCommentReplies(item.replies),
key: getCommentKey(item, index)
}))
})
const isRepliesExpanded = (commentKey) => expandedReplyKeys.value.has(commentKey)
const getVisibleReplies = (item) => {
if (isTencent.value || isRepliesExpanded(item.key)) return item.displayReplies
return item.displayReplies.slice(0, 1)
}
const toggleReplies = (commentKey) => {
const next = new Set(expandedReplyKeys.value)
if (next.has(commentKey)) next.delete(commentKey)
else next.add(commentKey)
expandedReplyKeys.value = next
}
function getCommentKey(item, index) {
const prefix = commentSort.value === 'hot' ? 'hot' : 'comment'
if (item.commentId !== undefined && item.commentId !== null) {
return `${prefix}-${item.commentId}`
}
const fallback = [item.user?.nickname, item.time, item.content]
.filter(Boolean)
.join('-')
.slice(0, 96)
return `${prefix}-${fallback || `fallback-${index}`}`
}
const formatCount = (count) => {
if (count >= 10000) {
return `${(count / 10000).toFixed(count >= 100000 ? 0 : 1)}${locale.value.tenThousand}`
}
return String(count)
}
const formatCommentTime = (time) => {
if (!time) return ''
const date = new Date(time)
const now = getSyncedDate()
const diffMs = now.getTime() - date.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
if (diffMinutes < 1) return locale.value.justNow
if (diffMinutes < 60) return formatLocaleValue(locale.value.minutesAgo, diffMinutes)
if (diffMinutes < 1440)
return formatLocaleValue(locale.value.hoursAgo, Math.floor(diffMinutes / 60))
const isSameYear = date.getFullYear() === now.getFullYear()
const formatted = date.toLocaleString(locale.value.dateLocale, {
...(isSameYear ? {} : { year: 'numeric' }),
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
return formatted.replace(/\//g, '-')
}
const updateCommentLikeState = (commentId, liked, likedCount) => {
const applyState = (item) => {
if (String(item.commentId) !== String(commentId)) return item
return {
...item,
liked,
likedCount
}
}
comments.value = comments.value.map(applyState)
hotComments.value = mergeHotComments(
hotComments.value.map(applyState),
comments.value.filter(isPopularByLikes)
)
sortTotals.value = { ...sortTotals.value, hot: hotComments.value.length }
}
const attachPendingQqReplies = () => {
if (!pendingQqReplies.value.length) return
const pendingByRoot = new Map()
pendingQqReplies.value.forEach((reply) => {
const rootId = String(reply.rootCommentId || '')
if (!rootId || !reply.commentId) return
const replies = pendingByRoot.get(rootId) || []
replies.push(reply)
pendingByRoot.set(rootId, replies)
})
const resolvedRootIds = new Set()
const attachToList = (items) =>
items.map((item) => {
const additions = pendingByRoot.get(String(item.commentId))
if (!additions?.length) return item
resolvedRootIds.add(String(item.commentId))
const existingIds = new Set(flattenCommentReplies(item.replies).map((reply) => String(reply.commentId)))
const nextReplies = additions.filter((reply) => {
const replyId = String(reply.commentId)
if (existingIds.has(replyId)) return false
existingIds.add(replyId)
return true
})
if (!nextReplies.length) return item
return {
...item,
replies: [...(Array.isArray(item.replies) ? item.replies : []), ...nextReplies],
replyCount: Math.max(Number(item.replyCount || 0), (item.replies?.length || 0) + nextReplies.length)
}
})
comments.value = attachToList(comments.value)
hotComments.value = attachToList(hotComments.value)
if (resolvedRootIds.size) {
pendingQqReplies.value = pendingQqReplies.value.filter(
(reply) => !resolvedRootIds.has(String(reply.rootCommentId))
)
}
}
const getCommentImageUrl = (url) => {
const value = String(url || '').trim()
if (!value) return ''
const absolute = value.startsWith('//') ? `https:${value}` : convertToHttps(value)
return absolute
}
const getCommentProxyImageUrl = (url) => {
const value = String(url || '').trim()
if (!value) return ''
const absolute = value.startsWith('//') ? `https:${value}` : convertToHttps(value)
return `/api/proxy/image?url=${encodeURIComponent(absolute)}`
}
const handleCommentImageError = (event, originalUrl) => {
const image = event.target
if (!image || image.dataset.fallback === '1') return
image.dataset.fallback = '1'
image.src = getCommentProxyImageUrl(originalUrl)
}
const handlePreviewKeydown = (event) => {
if (event.key === 'Escape') closeCommentImagePreview()
}
const openCommentImagePreview = (event, originalUrl) => {
const image = event.currentTarget
previewImageUrl.value = image?.currentSrc || image?.src || getCommentImageUrl(originalUrl)
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', handlePreviewKeydown)
window.addEventListener('keydown', handlePreviewKeydown)
}
}
const closeCommentImagePreview = () => {
previewImageUrl.value = ''
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', handlePreviewKeydown)
}
}
const toggleCommentLike = async (comment) => {
const songId = neteaseSongId.value
if (
!songId ||
comment.commentId === undefined ||
comment.commentId === null ||
likeUpdatingKey.value
) {
return
}
const cookie = getNeteaseCookie()
if (!cookie) {
if (window.$showNotification) {
window.$showNotification(locale.value.loginRequiredToLike, 'warning')
}
return
}
const commentId = comment.commentId
const nextLiked = !comment.liked
const currentLikedCount = Number(comment.likedCount || 0)
const nextLikedCount = Math.max(0, currentLikedCount + (nextLiked ? 1 : -1))
likeUpdatingKey.value = String(commentId)
updateCommentLikeState(commentId, nextLiked, nextLikedCount)
try {
const response = await fetchNetease(
'/comment/like',
{
id: songId,
cid: commentId,
t: nextLiked ? 1 : 0,
type: 0
},
cookie
)
if (response.code !== 200) {
throw new Error(locale.value.likeFailed)
}
} catch (err) {
updateCommentLikeState(commentId, !!comment.liked, currentLikedCount)
if (window.$showNotification) {
window.$showNotification(localizeServerError(err, locale.value.likeFailed), 'error')
}
} finally {
likeUpdatingKey.value = ''
}
}
const fetchTencentComments = async (append, sort, currentRequestId) => {
if (!tencentSongId.value || sortLoading.value[sort]) return
const nextOffset = append ? sortOffsets.value[sort] : 0
sortLoading.value = { ...sortLoading.value, [sort]: true }
sortErrors.value = { ...sortErrors.value, [sort]: '' }
loadingCount.value += 1
try {
const cursor = append ? sortCursors.value[sort] : ''
const response = await $fetch('/api/native-api/comment/tx', {
params: {
musicId: tencentSongId.value,
songId: tencentOriginalSongId.value || undefined,
page: Math.floor(nextOffset / PAGE_SIZE),
pageSize: PAGE_SIZE,
type: sort,
cursor
}
})
if (currentRequestId !== requestId.value) return
if (response.code !== 200) throw new Error(locale.value.loadFailed)
const body = response.data || {}
const nextComments = Array.isArray(body.comments) ? body.comments : []
const orphanReplies = Array.isArray(body.orphanReplies) ? body.orphanReplies : []
if (orphanReplies.length) {
const existingPending = new Set(
pendingQqReplies.value.map((reply) => `${reply.rootCommentId}:${reply.commentId}`)
)
pendingQqReplies.value = [
...pendingQqReplies.value,
...orphanReplies.filter((reply) => {
const key = `${reply.rootCommentId}:${reply.commentId}`
if (!reply.rootCommentId || !reply.commentId || existingPending.has(key)) return false
existingPending.add(key)
return true
})
]
}
const nextCursor = String(body.nextCursor || '')
if (sort === 'hot') {
const promoted = hotComments.value.filter(isPopularByLikes)
const previous = append ? hotComments.value : promoted
hotComments.value = mergeHotComments(previous, nextComments)
} else {
const previous = append ? comments.value : []
comments.value = uniqueComments([...previous, ...nextComments])
hotComments.value = mergeHotComments(
hotComments.value,
nextComments.filter(isPopularByLikes)
)
}
sortTotals.value = {
...sortTotals.value,
[sort]: sort === 'hot' ? hotComments.value.length : Number(body.total) || nextComments.length,
hot: hotComments.value.length
}
sortHasMore.value = {
...sortHasMore.value,
[sort]: Boolean(body.more) && Boolean(nextCursor)
}
attachPendingQqReplies()
sortOffsets.value = { ...sortOffsets.value, [sort]: nextOffset + PAGE_SIZE }
sortCursors.value = { ...sortCursors.value, [sort]: nextCursor }
sortLoaded.value = { ...sortLoaded.value, [sort]: true }
} catch (err) {
if (currentRequestId !== requestId.value) return
sortErrors.value = {
...sortErrors.value,
[sort]: localizeServerError(err, locale.value.loadFailed)
}
} finally {
if (currentRequestId === requestId.value) {
sortLoading.value = { ...sortLoading.value, [sort]: false }
loadingCount.value = Math.max(0, loadingCount.value - 1)
}
}
}
const fetchNeteaseComments = async (append, currentRequestId) => {
if (!neteaseSongId.value || sortLoading.value.latest) return
const nextOffset = append ? sortOffsets.value.latest : 0
const lastCommentTime = append ? comments.value[comments.value.length - 1]?.time : undefined
const params = { id: neteaseSongId.value, limit: PAGE_SIZE }
if (append && nextOffset >= 5000 && lastCommentTime) params.before = lastCommentTime
else params.offset = nextOffset
sortLoading.value = { hot: !append, latest: true }
sortErrors.value = { hot: '', latest: '' }
loadingCount.value += 1
try {
const response = await fetchNetease('/comment/music', params)
if (currentRequestId !== requestId.value) return
if (response.code !== 200) throw new Error(locale.value.loadFailed)
const body = response.body || response.data || {}
const nextComments = Array.isArray(body.comments)
? body.comments.map(normalizeNeteaseComment)
: []
comments.value = uniqueComments(append ? [...comments.value, ...nextComments] : nextComments)
if (!append) {
hotComments.value = Array.isArray(body.hotComments)
? body.hotComments.map(normalizeNeteaseComment)
: []
}
hotComments.value = mergeHotComments(
hotComments.value,
nextComments.filter(isPopularByLikes)
)
const latestTotal = Number(body.total) || comments.value.length
sortTotals.value = {
hot: hotComments.value.length,
latest: latestTotal
}
sortHasMore.value = {
hot: typeof body.more === 'boolean' ? body.more : comments.value.length < latestTotal,
latest: typeof body.more === 'boolean' ? body.more : comments.value.length < latestTotal
}
sortOffsets.value = { ...sortOffsets.value, latest: nextOffset + PAGE_SIZE }
sortLoaded.value = { hot: true, latest: true }
} catch (err) {
if (currentRequestId !== requestId.value) return
const message = localizeServerError(err, locale.value.loadFailed)
sortErrors.value = append
? { ...sortErrors.value, latest: message }
: { hot: message, latest: message }
} finally {
if (currentRequestId === requestId.value) {
sortLoading.value = { hot: false, latest: false }
loadingCount.value = Math.max(0, loadingCount.value - 1)
}
}
}
const fetchComments = async (append = false, sort = commentSort.value) => {
if (!canFetchComments.value) return
const currentRequestId = requestId.value
if (isTencent.value) {
await fetchTencentComments(append, sort, currentRequestId)
} else {
await fetchNeteaseComments(append, currentRequestId)
}
}
const loadInitialComments = async () => {
const currentRequestId = requestId.value
if (isTencent.value) {
await Promise.all([
fetchTencentComments(false, 'hot', currentRequestId),
fetchTencentComments(false, 'latest', currentRequestId)
])
} else {
await fetchNeteaseComments(false, currentRequestId)
}
}
const resetComments = (resetSort = false) => {
requestId.value += 1
loadingCount.value = 0
comments.value = []
hotComments.value = []
pendingQqReplies.value = []
sortTotals.value = { hot: 0, latest: 0 }
sortHasMore.value = { hot: false, latest: false }
sortOffsets.value = { hot: 0, latest: 0 }
sortCursors.value = { hot: '', latest: '' }
sortLoaded.value = { hot: false, latest: false }
sortErrors.value = { hot: '', latest: '' }
sortLoading.value = { hot: false, latest: false }
expandedReplyKeys.value = new Set()
if (resetSort) commentSort.value = 'hot'
}
const refreshComments = () => {
resetComments()
if (canFetchComments.value) loadInitialComments()
}
const loadMoreComments = () => {
if (isTencent.value && commentSort.value === 'hot') {
if (sortHasMore.value.hot) fetchTencentComments(true, 'hot', requestId.value)
else if (sortHasMore.value.latest) fetchTencentComments(true, 'latest', requestId.value)
return
}
fetchComments(true, commentSort.value)
}
watch(
() => [commentSourceKey.value, props.visible],
([sourceKey, visible], oldValue) => {
const oldSourceKey = oldValue?.[0]
if (sourceKey !== oldSourceKey) resetComments(true)
if (!visible) closeCommentImagePreview()
if (sourceKey && visible && !sortLoaded.value.hot && loadingCount.value === 0) {
loadInitialComments()
}
},
{ immediate: true }
)
onBeforeUnmount(closeCommentImagePreview)
defineExpose({ totalCount })
</script>
<style scoped>
.song-comments {
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
color: var(--lyrics-modal-text);
overflow: hidden;
}
.comments-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-shrink: 0;
padding-bottom: 1rem;
}
.comments-eyebrow {
margin: 0 0 0.25rem;
color: var(--lyrics-modal-text-muted);
font-size: 0.75rem;
font-weight: 700;
}
.comments-title {
margin: 0;
color: var(--lyrics-modal-text);
font-size: 1.35rem;
font-weight: 800;
letter-spacing: 0;
}
.refresh-button {
width: 36px;
height: 36px;
border: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--lyrics-modal-text-secondary);
background: var(--lyrics-modal-surface);
cursor: pointer;
transition: all 0.2s ease;
}
.refresh-button:hover:not(:disabled) {
color: var(--lyrics-modal-text);
background: var(--lyrics-modal-surface-strong);
}
.refresh-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.comments-summary {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
padding-bottom: 1rem;
color: var(--lyrics-modal-text-muted);
font-size: 0.82rem;
font-weight: 600;
}
.comments-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 0.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.comments-list::-webkit-scrollbar {
width: 6px;
}
.comments-list::-webkit-scrollbar-track {
background: transparent;
}
.comments-list::-webkit-scrollbar-thumb {
background: var(--lyrics-modal-surface-strong);
border-radius: 999px;
}
.comment-item {
display: flex;
gap: 0.85rem;
padding: 1rem;
border: 1px solid var(--lyrics-modal-surface-border);
border-radius: 8px;
background: var(--lyrics-modal-surface);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.avatar {
width: 38px;
height: 38px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--lyrics-modal-surface);
color: var(--lyrics-modal-text-muted);
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.comment-body {
min-width: 0;
flex: 1;
}
.comment-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.4rem;
}
.comment-user {
display: flex;
align-items: baseline;
min-width: 0;
gap: 0.4rem;
}
.nickname {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--lyrics-modal-text);
font-size: 0.9rem;
font-weight: 700;
}
.comment-time {
flex-shrink: 0;
color: var(--lyrics-modal-text-muted);
font-size: 0.75rem;
font-weight: 600;
}
.comment-content {
margin: 0;
color: var(--lyrics-modal-text-secondary);
font-size: 0.95rem;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
}
.comments-sort {
display: inline-flex;
gap: 0.2rem;
margin-left: auto;
padding: 0.2rem;
border-radius: 6px;
background: var(--lyrics-modal-surface);
}
.comments-sort button {
border: 0;
border-radius: 4px;
padding: 0.35rem 0.55rem;
color: var(--lyrics-modal-text-muted);
background: transparent;
cursor: pointer;
font: inherit;
font-size: 0.75rem;
font-weight: 700;
}
.comments-sort button.active {
color: var(--lyrics-modal-text);
background: var(--lyrics-modal-surface-strong);
}
.comment-content :deep(.inline-comment-emoji),
.reply-preview :deep(.inline-comment-emoji) {
width: 1.35em;
height: 1.35em;
display: inline-block;
vertical-align: -0.3em;
object-fit: contain;
}
.comment-images {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.7rem;
}
.comment-images img {
width: 96px;
height: 96px;
border-radius: 6px;
object-fit: cover;
background: var(--lyrics-modal-surface-strong);
}
.comment-image-previewable {
cursor: zoom-in;
}
.comment-image-previewable:focus-visible {
outline: 2px solid var(--lyrics-modal-text);
outline-offset: 2px;
}
.comment-image-preview {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: center;
padding: 1rem;
background: rgb(0 0 0 / 88%);
backdrop-filter: blur(6px);
}
.comment-image-preview > img {
display: block;
max-width: calc(100vw - 2rem);
max-height: calc(100dvh - 2rem);
border-radius: 6px;
object-fit: contain;
box-shadow: 0 20px 60px rgb(0 0 0 / 45%);
}
.comment-image-preview-close {
position: fixed;
top: max(1rem, env(safe-area-inset-top));
right: max(1rem, env(safe-area-inset-right));
z-index: 1;
width: 44px;
height: 44px;
border: 1px solid rgb(255 255 255 / 20%);
border-radius: 50%;
display: grid;
place-items: center;
padding: 0;
color: #fff;
background: rgb(20 20 20 / 76%);
cursor: pointer;
}
.comment-image-preview-close:hover,
.comment-image-preview-close:focus-visible {
background: rgb(45 45 45 / 92%);
}
.comment-location,
.reply-meta {
flex-shrink: 0;
color: var(--lyrics-modal-text-muted);
font-size: 0.72rem;
font-weight: 600;
}
.reply-meta {
margin-bottom: 0.25rem;
}
.reply-user {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.reply-avatar {
width: 1.25rem;
height: 1.25rem;
flex: 0 0 1.25rem;
display: grid;
place-items: center;
overflow: hidden;
border-radius: 50%;
color: var(--lyrics-modal-text-muted);
background: var(--lyrics-modal-surface-strong);
}
.reply-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.reply-preview {
margin-left: calc(var(--reply-depth, 0) * 0.75rem);
margin-top: 0.7rem;
padding: 0.65rem 0.8rem;
border-radius: 8px;
color: var(--lyrics-modal-text-muted);
background: var(--surface-card-bg-soft);
font-size: 0.82rem;
line-height: 1.55;
word-break: break-word;
white-space: pre-wrap;
}
.replies-toggle {
margin-top: 0.55rem;
margin-left: calc(var(--reply-depth, 0) * 0.75rem);
border: 0;
padding: 0;
color: var(--lyrics-modal-text-secondary);
background: transparent;
cursor: pointer;
font-size: 0.76rem;
font-weight: 700;
}
.replies-toggle:hover {
color: var(--lyrics-modal-text);
}
.reply-actions {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 0.35rem;
}
.comment-actions {
display: flex;
align-items: center;
gap: 0.65rem;
margin-top: 0.65rem;
color: var(--lyrics-modal-text-muted);
font-size: 0.78rem;
font-weight: 700;
}
.liked-count {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 0;
padding: 0;
color: inherit;
background: transparent;
cursor: pointer;
font: inherit;
transition:
color 0.2s ease,
transform 0.2s ease;
}
.liked-count:hover:not(:disabled),
.liked-count.liked {
color: var(--lyrics-modal-text);
}
.liked-count:hover:not(:disabled) {
transform: translateY(-1px);
}
.liked-count:disabled {
cursor: wait;
opacity: 0.6;
}
.qq-liked-count {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: inherit;
cursor: default;
}
.comments-state {
flex: 1;
min-height: 180px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: var(--lyrics-modal-text-muted);
text-align: center;
}
.comments-state p {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
}
.state-action,
.load-more-button {
border: 0;
border-radius: 8px;
color: var(--lyrics-modal-text);
background: var(--lyrics-modal-surface);
cursor: pointer;
font-weight: 700;
transition: all 0.2s ease;
}
.state-action {
padding: 0.55rem 0.95rem;
}
.load-more-button {
flex-shrink: 0;
width: 100%;
margin-top: 1rem;
padding: 0.75rem 1rem;
}
.state-action:hover,
.load-more-button:hover:not(:disabled) {
background: var(--lyrics-modal-surface-strong);
}
.load-more-button:disabled {
opacity: 0.6;
cursor: wait;
}
@media (max-width: 1024px) {
.song-comments {
padding-top: 0;
}
.comments-header {
padding: 0 0 0.8rem 0;
align-items: flex-start;
}
.comments-eyebrow {
font-size: 0.68rem;
}
.comments-title {
font-size: 1.08rem;
}
.refresh-button {
margin-top: 44px;
}
.comments-list {
padding-right: 0;
}
.comment-item {
padding: 0.85rem;
}
}
</style>