Files
BYW-VoiceHub/app/components/Songs/SongCommentsDialog.vue
T
Henry_Du 4ce4649aaf
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s
初次提交
2026-09-06 08:19:02 +08:00

197 lines
6.4 KiB
Vue

<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
@click="emit('close')"
>
<div class="bg-zinc-950 border border-zinc-800 rounded-2xl w-full max-w-2xl overflow-hidden" @click.stop>
<div class="px-5 py-4 border-b border-zinc-800 flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-black text-zinc-100">歌曲评论</p>
<p class="text-[10px] text-zinc-500 mt-1 truncate">
{{ song?.title }} - {{ song?.artist }}
</p>
</div>
<button
class="h-9 w-9 rounded-lg border border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900 transition-colors"
@click="emit('close')"
>
<X :size="16" class="mx-auto" />
</button>
</div>
<div class="p-5 space-y-4 max-h-[70vh] overflow-y-auto">
<div v-if="loading" class="text-center text-zinc-500 py-6">加载中...</div>
<div v-else-if="comments.length === 0" class="text-center text-zinc-500 py-6">
暂无评论
</div>
<div v-else class="space-y-3">
<div
v-for="c in comments"
:key="c.id"
class="p-4 rounded-xl border border-zinc-800 bg-zinc-900/30"
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-xs font-black text-zinc-200 truncate">{{ c.authorName || '用户' }}</p>
<p class="text-[10px] text-zinc-500 mt-0.5">{{ formatTime(c.createdAt) }}</p>
</div>
<button
v-if="canDelete(c)"
class="px-3 py-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 text-xs font-bold disabled:opacity-50"
:disabled="deletingId === c.id"
@click="deleteComment(c)"
>
删除
</button>
</div>
<p class="text-sm text-zinc-200 mt-3 whitespace-pre-wrap leading-relaxed">{{ c.content }}</p>
</div>
</div>
</div>
<div class="px-5 py-4 border-t border-zinc-800 space-y-3">
<div v-if="!isAuthenticated" class="text-xs text-zinc-500">
登录后可发表评论
</div>
<div v-else class="space-y-2">
<textarea
v-model="draft"
:rows="3"
class="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all resize-none"
placeholder="写下你的评论(最多 300 字)"
/>
<div class="flex items-center justify-between gap-3">
<p class="text-[10px] text-zinc-500">{{ draft.length }} / 300</p>
<button
class="px-5 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-black disabled:opacity-50"
:disabled="posting || !draft.trim() || draft.trim().length > 300"
@click="postComment"
>
{{ posting ? '发送中...' : '发送' }}
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, watch, ref } from 'vue'
import { X } from '@lucide/vue'
import { useAuth } from '~/composables/useAuth'
import { useToast } from '~/composables/useToast'
const props = defineProps({
show: { type: Boolean, default: false },
song: { type: Object, default: null }
})
const emit = defineEmits(['close'])
const { user, isAuthenticated, getAuthConfig } = useAuth()
const { showToast } = useToast()
const comments = ref([])
const loading = ref(false)
const posting = ref(false)
const deletingId = ref(null)
const draft = ref('')
const songId = computed(() => props.song?.id)
const formatTime = (value) => {
if (!value) return ''
const d = new Date(value)
if (Number.isNaN(d.getTime())) return ''
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
const load = async () => {
if (!props.show || !songId.value) return
if (!isAuthenticated.value) {
comments.value = []
return
}
try {
loading.value = true
const authConfig = getAuthConfig()
const data = await $fetch(`/api/songs/${songId.value}/comments`, { ...authConfig, query: { t: Date.now() } })
comments.value = Array.isArray(data) ? data : []
} catch {
showToast('加载评论失败', 'error')
} finally {
loading.value = false
}
}
watch(
() => props.show,
(v) => {
if (v) {
draft.value = ''
load()
}
}
)
const canDelete = (c) => {
const me = user.value
if (!me) return false
return c.userId === me.id || ['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(me.role)
}
const postComment = async () => {
if (!songId.value) return
const text = draft.value.trim()
if (!text) return
if (text.length > 300) return
try {
posting.value = true
const authConfig = getAuthConfig()
await $fetch(`/api/songs/${songId.value}/comments`, {
method: 'POST',
...authConfig,
body: { content: text }
})
draft.value = ''
await load()
} catch {
showToast('发送失败', 'error')
} finally {
posting.value = false
}
}
const deleteComment = async (c) => {
if (!songId.value) return
try {
deletingId.value = c.id
const authConfig = getAuthConfig()
await $fetch(`/api/songs/${songId.value}/comments/${c.id}`, {
method: 'DELETE',
...authConfig
})
await load()
} catch {
showToast('删除失败', 'error')
} finally {
deletingId.value = null
}
}
</script>