feat(songs): 歌曲管理支持批量驳回、投稿留言自动审查与排期范围可见性过滤
- 新增 /api/admin/songs/batch-reject.post 批量驳回接口(事务清排期/投票/扣接纳数/黑名单去重/异步通知) - requireSongAdmin 三角色校验 + createApiError 统一错误包装 - scheduleDaysBefore/After 四字段排期可见范围限制(开放 API 与公开排期接口三处缓存路径 + DB 条件过滤) - submissionNoteAutoModeration 基于黑名单 KEYWORD/LANGUAGE/GENRE 自动判定留言 publicStatus,命中自动关闭 submissionNotePublic - 更新 songs/request.post 与 songs/[id]/update.put 接入自动审查逻辑
This commit is contained in:
@@ -61,6 +61,12 @@
|
||||
>
|
||||
<RotateCcw :size="14" class="text-blue-500/70" /> 未播放 ({{ selectedSongs.length }})
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-amber-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
|
||||
@click="batchReject"
|
||||
>
|
||||
<X :size="14" class="text-amber-500/70" /> 驳回 ({{ selectedSongs.length }})
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-red-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
|
||||
@click="batchDelete"
|
||||
@@ -408,7 +414,7 @@
|
||||
@click.stop
|
||||
>
|
||||
<div class="px-8 py-6 border-b border-zinc-800/50 flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-zinc-100">驳回歌曲</h3>
|
||||
<h3 class="text-xl font-black text-zinc-100">{{ isBatchReject ? `批量驳回歌曲 (${selectedSongs.length} 首)` : '驳回歌曲' }}</h3>
|
||||
<button
|
||||
class="text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
@click="cancelReject"
|
||||
@@ -418,7 +424,7 @@
|
||||
</div>
|
||||
|
||||
<div class="p-8 space-y-6">
|
||||
<div class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg flex items-center gap-4">
|
||||
<div v-if="!isBatchReject" class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg bg-red-500/10 text-red-500 flex items-center justify-center border border-red-500/10"
|
||||
>
|
||||
@@ -429,6 +435,17 @@
|
||||
<p class="text-xs text-zinc-500">投稿人: {{ rejectSongInfo.requester }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg bg-amber-500/10 text-amber-500 flex items-center justify-center border border-amber-500/10"
|
||||
>
|
||||
<ListMusic :size="18" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-zinc-100 text-sm">即将批量驳回 {{ selectedSongs.length }} 首歌曲</h4>
|
||||
<p class="text-xs text-zinc-500">驳回后将一并清理投票、排期记录并发送通知给对应投稿人</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
@@ -961,6 +978,7 @@ import {
|
||||
X,
|
||||
Trash2,
|
||||
Music,
|
||||
ListMusic,
|
||||
Heart,
|
||||
Download,
|
||||
ChevronDown,
|
||||
@@ -1040,6 +1058,7 @@ const showRejectDialog = ref(false)
|
||||
const rejectLoading = ref(false)
|
||||
const rejectReason = ref('')
|
||||
const addToBlacklist = ref(false)
|
||||
const isBatchReject = ref(false)
|
||||
const rejectSongInfo = ref({
|
||||
id: null,
|
||||
title: '',
|
||||
@@ -1475,6 +1494,7 @@ const rejectSong = (songId) => {
|
||||
const song = songs.value.find((s) => s.id === songId)
|
||||
if (!song) return
|
||||
|
||||
isBatchReject.value = false
|
||||
rejectSongInfo.value = {
|
||||
id: song.id,
|
||||
title: song.title || '',
|
||||
@@ -1487,6 +1507,22 @@ const rejectSong = (songId) => {
|
||||
showRejectDialog.value = true
|
||||
}
|
||||
|
||||
// 批量驳回歌曲
|
||||
const batchReject = () => {
|
||||
if (selectedSongs.value.length === 0) return
|
||||
|
||||
isBatchReject.value = true
|
||||
rejectSongInfo.value = {
|
||||
id: null,
|
||||
title: '',
|
||||
artist: '',
|
||||
requester: ''
|
||||
}
|
||||
rejectReason.value = ''
|
||||
addToBlacklist.value = false
|
||||
showRejectDialog.value = true
|
||||
}
|
||||
|
||||
// 确认驳回
|
||||
const confirmReject = async () => {
|
||||
if (!rejectReason.value.trim()) {
|
||||
@@ -1496,26 +1532,40 @@ const confirmReject = async () => {
|
||||
|
||||
rejectLoading.value = true
|
||||
try {
|
||||
await $fetch('/api/admin/songs/reject', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
songId: rejectSongInfo.value.id,
|
||||
reason: rejectReason.value.trim(),
|
||||
addToBlacklist: addToBlacklist.value
|
||||
if (isBatchReject.value) {
|
||||
await $fetch('/api/admin/songs/batch-reject', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
songIds: selectedSongs.value,
|
||||
reason: rejectReason.value.trim(),
|
||||
addToBlacklist: addToBlacklist.value
|
||||
}
|
||||
})
|
||||
await refreshSongs(true)
|
||||
selectedSongs.value = []
|
||||
showNotification(`批量驳回成功,共处理 ${selectedSongs.value.length} 首歌曲`, 'success')
|
||||
} else {
|
||||
await $fetch('/api/admin/songs/reject', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
songId: rejectSongInfo.value.id,
|
||||
reason: rejectReason.value.trim(),
|
||||
addToBlacklist: addToBlacklist.value
|
||||
}
|
||||
})
|
||||
|
||||
await refreshSongs(true)
|
||||
|
||||
const index = selectedSongs.value.indexOf(rejectSongInfo.value.id)
|
||||
if (index > -1) {
|
||||
selectedSongs.value.splice(index, 1)
|
||||
}
|
||||
})
|
||||
|
||||
await refreshSongs(true)
|
||||
|
||||
const index = selectedSongs.value.indexOf(rejectSongInfo.value.id)
|
||||
if (index > -1) {
|
||||
selectedSongs.value.splice(index, 1)
|
||||
showNotification('歌曲驳回成功,已通知投稿人', 'success')
|
||||
}
|
||||
|
||||
showRejectDialog.value = false
|
||||
|
||||
showNotification('歌曲驳回成功,已通知投稿人', 'success')
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('驳回歌曲失败:', error)
|
||||
showNotification('驳回失败: ' + (error.data?.message || error.message), 'error')
|
||||
} finally {
|
||||
@@ -1528,6 +1578,7 @@ const cancelReject = () => {
|
||||
showRejectDialog.value = false
|
||||
rejectReason.value = ''
|
||||
addToBlacklist.value = false
|
||||
isBatchReject.value = false
|
||||
rejectSongInfo.value = {
|
||||
id: null,
|
||||
title: '',
|
||||
|
||||
@@ -26,7 +26,17 @@
|
||||
<AuthProvidersGoogleIcon />
|
||||
</button>
|
||||
|
||||
<!-- 其他提供商可在此添加 -->
|
||||
<button
|
||||
v-if="config.public.oauth.aggregate"
|
||||
type="button"
|
||||
class="w-12 h-12 rounded-full border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-primary)] flex items-center justify-center cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm hover:bg-[var(--bg-tertiary)] hover:bg-[#12B7F5] hover:text-white hover:border-[#12B7F5]"
|
||||
title="使用 QQ / 微信登录"
|
||||
@click="loginWith('aggregate')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8.5 4C4.9 4 2 6.8 2 10.3c0 2 1.1 3.7 2.8 4.9L4 18l3-1.5c.5.1 1 .2 1.5.2h.6c-.1-.4-.1-.8 0-1.2H8.5C5.5 15.5 3 13.2 3 10.3 3 7.4 5.5 5 8.5 5h1.6c.3-1.1 1.3-1.9 2.5-1.9.5 0 1 .2 1.4.5C13.3 3 10.7 4 8.5 4zM10.1 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm8.4 1.2c-2.5 0-4.5 2-4.5 4.5s2 4.5 4.5 4.5c.6 0 1.2-.1 1.7-.3L22 17.5l-.8-2.3c1.4-1 2.3-2.6 2.3-4.3C23.5 9.2 21.5 7.2 19 7.2c-.5 0-1 0-1.4.1C16.9 7.8 17.9 7.2 19 7.2zM17 9.7c-.8 0-1.5.7-1.5 1.5s.7 1.5 1.5 1.5 1.5-.7 1.5-1.5-.7-1.5-1.5-1.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -39,7 +49,6 @@ const hasEnabledProviders = computed(() => {
|
||||
})
|
||||
|
||||
const loginWith = (provider: string) => {
|
||||
// 外部导航到 API 端点
|
||||
navigateTo(`/api/auth/${provider}`, { external: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user