初次提交
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user