197 lines
6.4 KiB
Vue
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>
|