Files
BYW-VoiceHub/app/components/Admin/BlacklistManager.vue
T
Henry_Du 573e168702 feat(admin-ui): 管理后台整体切换为浅色主题(ModernLight) + 新增 lightbulb 图标
1. layout(管理控制台 dashboard.vue): main bg-[#09090b] text-zinc-100 → bg-[#f8fafc] text-zinc-900; header bg-zinc-950/60 border-zinc-800 → bg-white/80 border-zinc-200; 移动菜单按钮/新手引导按钮 text-zinc-300/400 hover:bg-zinc-800 → text-zinc-600 hover:bg-zinc-100 border-zinc-200

2. Admin/Sidebar.vue: bg-[#09090b] border-zinc-800 → bg-white border-zinc-200 + shadow-sm; 标题 text-zinc-100 → 900; activeTab bg-blue-600/10 text-blue-400 → bg-blue-50 text-blue-700 border-blue-200; 未选中 text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/40 → text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100; 用户卡 avatar/首字母 bg-white/70 border-zinc-200 + 退出按钮 hover:bg-red-50 text-red-600

3. 批量 33 个 Admin 组件硬编码深色原子类 1414 处替换(bg-zinc-9xx / border-zinc-8xx / text-zinc-100,200,300 / shadow-black/ -> 对应 light 版本) + style 块原生 CSS rgba(255,255,255,x) 16 处改为 rgba(0,0,0,x) / var 浅色主题 + 硬编码 bg-[#0c0c0e] / border-white/10 / color:white / surface-primary fallback 对应浅色修正

4. 二轮批量追加: bg-zinc-950 + opacity 变体(80/60/50/40/30/20/10) 共 431 处 → bg-white 对应 opacity 版本;placeholder:text-zinc-800 → 500;text-blue/red/emerald/amber/rose/purple 400 版 → 700/600 深浅一致版;border color /500 /30 → 200;focus:ring /20 → /30;shadow color 阴影降低强度

5. hover:text-white 扫尾: 13 处 DataAnalysisPanel/DatabaseManager/NotificationSender/SemesterManager/SongManagement/UserSongsModal hover 文本从 white → zinc-900;按钮三态从 hover:bg-*600 白字 → hover:bg-*-100/200 对应深字色,和前台 indigo/浅色体系保持一致

6. BlacklistManager 前台可见提示 💡→ <Icon lightbulb amber-600 size=13>;ChangelogManager 徽章色从 emerald-300/blue-300/zinc-400 → emerald-700/blue-700/zinc-600 可读;Icon.vue 新增 lightbulb 分支 stroke 图标

7. 受影响文件: dashboard.vue + Sidebar.vue + Icon.vue + Admin/* (33个组件共 1874 处批量替换 + 20 处手工精细化修正)
2026-09-19 08:30:59 +08:00

653 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div
class="max-w-[1400px] mx-auto space-y-8 pb-20 px-2 animate-in fade-in slide-in-from-bottom-4 duration-700"
>
<!-- 页面标题 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-zinc-900 tracking-tight flex items-center gap-3">
歌曲黑名单管理
</h2>
<p class="text-xs text-zinc-500 mt-1 font-medium">
禁止特定歌曲或包含关键词的歌曲被点播,维护广播内容的健康与合规
</p>
</div>
</div>
<!-- 添加黑名单项表单 -->
<section class="bg-zinc-50/40 border border-zinc-200 rounded-xl p-8 shadow-xl">
<h3 class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] mb-6 px-1">
添加黑名单项
</h3>
<div class="grid grid-cols-1 xl:grid-cols-12 gap-6 items-end">
<div class="xl:col-span-3 space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>封禁类型</label
>
<div class="flex p-1 bg-white border border-zinc-200 rounded-lg gap-1">
<button
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'SONG' ? 'bg-zinc-100 text-blue-700' : 'text-zinc-600'"
@click="newItem.type = 'SONG'"
>
<Music :size="12" /> 歌曲
</button>
<button
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'KEYWORD' ? 'bg-zinc-100 text-purple-400' : 'text-zinc-600'"
@click="newItem.type = 'KEYWORD'"
>
<Type :size="12" /> 关键词
</button>
<button
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'LANGUAGE' ? 'bg-zinc-100 text-amber-700' : 'text-zinc-600'"
@click="newItem.type = 'LANGUAGE'"
>
<Languages :size="12" /> 语种
</button>
<button
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'GENRE' ? 'bg-zinc-100 text-emerald-700' : 'text-zinc-600'"
@click="newItem.type = 'GENRE'"
>
<Disc3 :size="12" /> 曲风
</button>
</div>
</div>
<div class="xl:col-span-4 space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">
{{ typeValueLabel }}
</label>
<input
v-model="newItem.value"
type="text"
:placeholder="typePlaceholder"
class="w-full bg-white border border-zinc-200 rounded-lg px-5 py-3 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 placeholder:text-zinc-700 transition-all"
>
</div>
<div v-if="newItem.type === 'LANGUAGE' || newItem.type === 'GENRE'" class="xl:col-span-5 xl:col-start-auto space-y-1">
<p class="text-[10px] text-zinc-600 font-bold leading-relaxed px-1 flex items-start gap-1.5">
<Icon name="lightbulb" :size="13" class="text-amber-600 shrink-0 mt-[2px]" />
<template v-if="newItem.type === 'LANGUAGE'">
支持多语种匹配:中文 / 英文 / 日文 / 韩文 / 俄文 / 阿拉伯文 等,支持逗号分隔,例如:<span class="text-amber-700">日文,韩文</span>
</template>
<template v-else>
支持曲风关键词,可逗号分隔,例如:<span class="text-emerald-700">DJ,摇滚,rap</span>
</template>
</p>
</div>
<div class="xl:col-span-4 space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>封禁原因 (可选)</label
>
<input
v-model="newItem.reason"
type="text"
placeholder="简述加入黑名单的原因"
class="w-full bg-white border border-zinc-200 rounded-lg px-5 py-3 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 placeholder:text-zinc-700 transition-all"
>
</div>
<div class="xl:col-span-2">
<button
:disabled="!newItem.value || loading"
class="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-black rounded-lg shadow-lg shadow-blue-900/20 transition-all active:scale-95"
@click="addBlacklistItem"
>
<Plus v-if="!loading" :size="16" />
<div
v-else
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"
/>
{{ loading ? '处理中...' : '添加到黑名单' }}
</button>
</div>
</div>
</section>
<!-- 筛选和搜索 -->
<div
class="bg-zinc-50/20 border border-zinc-200 rounded-xl p-4 flex flex-col md:flex-row gap-4 items-center"
>
<div class="flex-1 relative w-full">
<Search class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-700" :size="18" />
<input
v-model="filters.search"
type="text"
placeholder="在黑名单中搜索..."
class="w-full bg-white/70 border border-zinc-200 rounded-lg pl-12 pr-4 py-3 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 placeholder:text-zinc-700 transition-all"
@input="debounceSearch"
>
</div>
<div class="flex gap-3 w-full md:w-auto">
<CustomSelect
label="类型筛选"
:value="typeFilterLabel"
:options="['全部类型', '具体歌曲', '关键词', '语种', '曲风']"
class="flex-1 md:w-48"
@change="handleTypeFilterChange"
/>
<button
class="p-3 bg-white border border-zinc-200 rounded-lg text-zinc-600 hover:text-blue-700 transition-all active:scale-95"
@click="loadBlacklist"
>
<Filter :size="18" />
</button>
</div>
</div>
<!-- 黑名单列表 -->
<div class="space-y-4">
<div class="flex items-center justify-between px-2">
<h4 class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">
黑名单列表 ({{ pagination.total }} 项)
</h4>
</div>
<div class="grid grid-cols-1 gap-3">
<TransitionGroup
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-200 ease-in absolute w-full"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-for="item in blacklist"
:key="item.id"
class="group flex flex-col lg:flex-row lg:items-center gap-6 p-6 bg-zinc-50/30 border rounded-xl transition-all hover:border-zinc-200"
:class="[item.isActive ? 'border-zinc-200' : 'border-zinc-200 opacity-60']"
>
<div class="flex-1 flex items-start gap-5">
<div
class="shrink-0 w-12 h-12 rounded-lg flex items-center justify-center border transition-all"
:class="getTypeIconClass(item.type)"
>
<Music v-if="item.type === 'SONG'" :size="22" />
<Type v-else-if="item.type === 'KEYWORD'" :size="22" />
<Languages v-else-if="item.type === 'LANGUAGE'" :size="22" />
<Disc3 v-else :size="22" />
</div>
<div class="space-y-1.5 min-w-0">
<div class="flex items-center gap-3">
<span
class="text-[9px] font-black uppercase tracking-widest px-2 py-0.5 rounded border"
:class="getTypeBadgeClass(item.type)"
>
{{ getTypeLabel(item.type) }}
</span>
<h5 class="text-base font-black text-zinc-900 truncate tracking-tight">
{{ item.value }}
</h5>
</div>
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2 text-zinc-500 font-bold text-[11px]">
<MessageSquare :size="12" class="text-zinc-700" />
原因: {{ item.reason || '未填写原因' }}
</div>
<div
class="flex items-center gap-2 text-zinc-600 font-bold text-[10px] uppercase tracking-tighter"
>
<Clock :size="11" class="text-zinc-800" />
创建时间: {{ formatDate(item.createdAt) }}
</div>
</div>
</div>
</div>
<div
class="flex items-center justify-end gap-3 pt-4 lg:pt-0 border-t lg:border-t-0 border-zinc-200"
>
<button
:disabled="loading"
class="flex items-center gap-2 px-5 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all"
:class="[
item.isActive
? 'bg-zinc-100 text-zinc-500 hover:text-amber-500 hover:bg-amber-500/5 hover:border-amber-500/10 border border-transparent'
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
]"
@click="toggleItemStatus(item)"
>
<Power :size="12" />
{{ item.isActive ? '禁用' : '启用' }}
</button>
<button
:disabled="loading"
class="p-3 bg-white border border-zinc-200 rounded-xl text-zinc-700 hover:text-red-500 hover:border-red-200 transition-all active:scale-95"
@click="deleteItem(item)"
>
<Trash2 :size="16" />
</button>
</div>
</div>
</TransitionGroup>
<!-- 加载状态 -->
<div
v-if="loading && blacklist.length === 0"
class="py-20 flex flex-col items-center justify-center space-y-4"
>
<div
class="w-12 h-12 border-4 border-zinc-200 border-t-blue-500 rounded-full animate-spin"
/>
<p class="text-zinc-500 text-xs font-bold uppercase tracking-widest">加载中...</p>
</div>
<!-- 空状态 -->
<div
v-else-if="blacklist.length === 0"
class="py-20 flex flex-col items-center justify-center text-center space-y-4 bg-zinc-50/10 border border-zinc-200 border-dashed rounded-xl"
>
<div
class="w-16 h-16 rounded-lg bg-white border border-zinc-200 flex items-center justify-center text-zinc-800 shadow-xl"
>
<Ban :size="32" />
</div>
<div class="space-y-1 px-4">
<h6 class="text-sm font-bold text-zinc-600">未找到匹配的黑名单项</h6>
<p class="text-[10px] text-zinc-700 font-bold uppercase tracking-widest">
请尝试调整筛选条件或添加新项目
</p>
</div>
</div>
</div>
<!-- 分页控制 -->
<div v-if="pagination.pages > 1" class="flex items-center justify-center gap-4 mt-8">
<button
:disabled="pagination.page <= 1 || loading"
class="p-3 bg-white border border-zinc-200 rounded-lg text-zinc-400 hover:text-zinc-900 disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95"
@click="changePage(pagination.page - 1)"
>
<ChevronLeft :size="20" />
</button>
<div
class="px-6 py-3 bg-zinc-50/50 border border-zinc-200 rounded-lg text-[11px] font-black text-zinc-400 uppercase tracking-widest"
>
第 {{ pagination.page }} 页 / 共 {{ pagination.pages }} 页
</div>
<button
:disabled="pagination.page >= pagination.pages || loading"
class="p-3 bg-white border border-zinc-200 rounded-lg text-zinc-400 hover:text-zinc-900 disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95"
@click="changePage(pagination.page + 1)"
>
<ChevronRight :size="20" />
</button>
</div>
</div>
<!-- 删除确认模态框 -->
<Transition
enter-active-class="transition duration-300 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="showDeleteDialog"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-white/80 backdrop-blur-sm"
>
<div
class="w-full max-w-md bg-white border border-zinc-200 rounded-xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300"
@click.stop
>
<div class="flex flex-col items-center py-8 space-y-6 text-center px-8">
<div
class="w-16 h-16 rounded-lg bg-red-600/10 text-red-500 flex items-center justify-center border border-red-500/20 shadow-2xl shadow-red-900/10"
>
<Trash2 :size="28" />
</div>
<div class="space-y-2">
<h4 class="text-lg font-bold text-zinc-900">确认从黑名单移除</h4>
<p class="text-xs text-zinc-500 leading-relaxed">
确定要移除
<span class="text-zinc-600 font-bold">"{{ deleteTargetItem?.value }}"</span>
吗?移除后,该项目将恢复为正常状态。
</p>
</div>
<div class="flex gap-3 w-full pt-4">
<button
class="flex-1 px-4 py-4 bg-zinc-100 hover:bg-zinc-200 text-zinc-400 text-[10px] font-black rounded-lg transition-all uppercase tracking-widest"
@click="showDeleteDialog = false"
>
取消
</button>
<button
:disabled="loading"
class="flex-1 px-4 py-4 bg-red-600 hover:bg-red-500 text-white text-[10px] font-black rounded-lg shadow-xl shadow-red-900/20 transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50"
@click="confirmDelete"
>
{{ loading ? '删除中...' : '确认移除' }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, computed } from 'vue'
import {
ShieldAlert,
Music,
Type,
Plus,
Search,
Filter,
MessageSquare,
Clock,
Power,
Trash2,
Ban,
ChevronLeft,
ChevronRight,
ChevronDown,
Languages,
Disc3
} from '@lucide/vue'
import { useToast } from '~/composables/useToast'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
const { showToast: showNotification } = useToast()
const blacklist = ref([])
const loading = ref(false)
const error = ref('')
const success = ref('')
// 删除对话框相关
const showDeleteDialog = ref(false)
const deleteTargetItem = ref(null)
const newItem = reactive({
type: 'SONG',
value: '',
reason: ''
})
const filters = reactive({
type: '',
search: ''
})
const pagination = reactive({
page: 1,
limit: 20,
total: 0,
pages: 0
})
const typeFilterLabel = computed(() => {
if (filters.type === 'SONG') return '具体歌曲'
if (filters.type === 'KEYWORD') return '关键词'
if (filters.type === 'LANGUAGE') return '语种'
if (filters.type === 'GENRE') return '曲风'
return '全部类型'
})
const typeValueLabel = computed(() => {
if (newItem.type === 'SONG') return '歌曲名称 - 艺术家'
if (newItem.type === 'KEYWORD') return '关键词内容'
if (newItem.type === 'LANGUAGE') return '语种名称'
if (newItem.type === 'GENRE') return '曲风名称'
return '内容'
})
const typePlaceholder = computed(() => {
if (newItem.type === 'SONG') return '例如: 歌曲名 - 艺术家'
if (newItem.type === 'KEYWORD') return '例如: 敏感词、特定风格'
if (newItem.type === 'LANGUAGE') return '例如: 日文 或 中文,英文,日文'
if (newItem.type === 'GENRE') return '例如: DJ 或 流行,摇滚,rap'
return '请输入内容'
})
const getTypeLabel = (type: string) => {
if (type === 'SONG') return '具体歌曲'
if (type === 'KEYWORD') return '关键词'
if (type === 'LANGUAGE') return '语种'
if (type === 'GENRE') return '曲风'
return type
}
const getTypeIconClass = (type: string) => {
switch (type) {
case 'SONG':
return 'bg-blue-50 text-blue-500 border-blue-200 shadow-lg shadow-blue-900/5'
case 'KEYWORD':
return 'bg-purple-600/10 text-purple-500 border-purple-500/20 shadow-lg shadow-purple-900/5'
case 'LANGUAGE':
return 'bg-amber-600/10 text-amber-500 border-amber-500/20 shadow-lg shadow-amber-900/5'
case 'GENRE':
return 'bg-emerald-600/10 text-emerald-500 border-emerald-500/20 shadow-lg shadow-emerald-900/5'
default:
return 'bg-zinc-600/10 text-zinc-500 border-zinc-200'
}
}
const getTypeBadgeClass = (type: string) => {
switch (type) {
case 'SONG':
return 'bg-blue-50 text-blue-700 border-blue-200'
case 'KEYWORD':
return 'bg-purple-600/10 text-purple-400 border-purple-500/20'
case 'LANGUAGE':
return 'bg-amber-600/10 text-amber-700 border-amber-500/20'
case 'GENRE':
return 'bg-emerald-600/10 text-emerald-700 border-emerald-500/20'
default:
return 'bg-zinc-600/10 text-zinc-400 border-zinc-200'
}
}
let searchTimeout = null
// 加载黑名单
const loadBlacklist = async () => {
loading.value = true
error.value = ''
try {
const params = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
...(filters.type && { type: filters.type }),
...(filters.search && { search: filters.search })
})
const response = await $fetch(`/api/admin/blacklist?${params}`, {
...useAuth().getAuthConfig()
})
blacklist.value = response.blacklist
Object.assign(pagination, response.pagination)
} catch (err) {
error.value = '获取黑名单失败'
console.error('获取黑名单失败:', err)
showNotification('获取黑名单失败: ' + (err.data?.message || err.message), 'error')
} finally {
loading.value = false
}
}
const handleTypeFilterChange = (label) => {
if (label === '具体歌曲') filters.type = 'SONG'
else if (label === '关键词') filters.type = 'KEYWORD'
else if (label === '语种') filters.type = 'LANGUAGE'
else if (label === '曲风') filters.type = 'GENRE'
else filters.type = ''
pagination.page = 1
loadBlacklist()
}
// 添加黑名单项
const addBlacklistItem = async () => {
if (!newItem.value.trim()) return
loading.value = true
error.value = ''
success.value = ''
try {
await $fetch('/api/admin/blacklist', {
method: 'POST',
body: {
type: newItem.type,
value: newItem.value.trim(),
reason: newItem.reason.trim() || null
},
...useAuth().getAuthConfig()
})
showNotification('黑名单项添加成功', 'success')
// 重置表单
newItem.value = ''
newItem.reason = ''
// 重新加载列表
pagination.page = 1
await loadBlacklist()
} catch (err) {
error.value = err.data?.message || '添加黑名单项失败'
console.error('添加黑名单项失败:', err)
showNotification('添加失败: ' + error.value, 'error')
} finally {
loading.value = false
}
}
// 切换项目状态
const toggleItemStatus = async (item) => {
loading.value = true
error.value = ''
try {
await $fetch(`/api/admin/blacklist/${item.id}`, {
method: 'PATCH',
body: {
isActive: !item.isActive
},
...useAuth().getAuthConfig()
})
item.isActive = !item.isActive
showNotification(`黑名单项已${item.isActive ? '启用' : '禁用'}`, 'success')
} catch (err) {
error.value = '更新状态失败'
console.error('更新状态失败:', err)
showNotification('更新状态失败', 'error')
} finally {
loading.value = false
}
}
// 删除项目
const deleteItem = (item) => {
deleteTargetItem.value = item
showDeleteDialog.value = true
}
// 确认删除
const confirmDelete = async () => {
if (!deleteTargetItem.value) return
loading.value = true
error.value = ''
try {
await $fetch(`/api/admin/blacklist/${deleteTargetItem.value.id}`, {
method: 'DELETE',
...useAuth().getAuthConfig()
})
showNotification('黑名单项删除成功', 'success')
await loadBlacklist()
} catch (err) {
error.value = '删除失败'
console.error('删除失败:', err)
showNotification('删除失败: ' + err.message, 'error')
} finally {
loading.value = false
showDeleteDialog.value = false
deleteTargetItem.value = null
}
}
// 搜索防抖
const debounceSearch = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
pagination.page = 1
loadBlacklist()
}, 500)
}
// 切换页面
const changePage = (page) => {
pagination.page = page
loadBlacklist()
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
}
onMounted(() => {
loadBlacklist()
})
</script>
<style scoped>
.animate-in {
animation-duration: 0.5s;
animation-fill-mode: both;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* 隐藏滚动条但保持功能 */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
</style>