Files
BYW-VoiceHub/app/pages/dashboard.vue
T
Henry_Du a0184fdeb0 feat(admin+server): 酷狗对接 - 扫码绑定管理员账号 / 按排期自动创建酷狗日歌单 + 同步歌曲
数据模型:SystemSettings 新增 kugouApiBaseUrl / kugouAdminCookie / kugouAdminToken / kugouAdminUser / kugouAdminBoundAt 5 字段;新建 KuGouPlaylistSync 同步历史表(12 字段 + playDate 倒序索引)。迁移脚本:app/drizzle/migrations/20260919143000_add_kugou_integration.sql

后端 Server:新增 server/services/kugouService.ts 封装 KuGouMusicApi sidecar 转发(自动拼 Cookie / Token header);新增 6 个 admin/kugou 路由:status / qr-create / qr-check / unbind / sync-schedule-playlists / sync-history。同步核心:逐日出非草稿排期 → kgHash 直取优先 → 失败用 cloudsearch/search 按标题+歌手三级召回 → /playlist/create + /playlist/tracks op=add 每 100 首分批;写同步历史(status、added/missing、歌单 URL)。

后台 UI:新建 Admin/KuGouImporter.client.vue 三栏布局——左栏 sidecar 健康卡+部署说明+扫码绑定/解绑弹窗;中栏多选排期日期+逐日出排期预览卡+按日同步;下栏最近 100 条同步历史(7 种状态 badge + 歌单链接跳转)。dashboard.vue L105 playlist-card 挂载点由 PlaylistCardPage 替换为 KuGouImporter;权限 permissions.js 5 个 ADMIN 族角色开放 playlist-card 页面访问(接口内部绑定/同步再强校验 ADMIN/SUPER_ADMIN)。

侧car要求:部署 KuGouMusicApi(参考文档 https://kugoumusicapi-docs.4everland.app),默认本机 http://127.0.0.1:3000,可通过 SystemSettings.kugouApiBaseUrl 或环境变量 KUGOU_API_BASE 覆盖。
2026-09-19 11:57:02 +08:00

587 lines
17 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>
<ClientOnly>
<div class="admin-layout" @touchend="handleTouchEnd" @touchstart="handleTouchStart">
<!-- 左侧导航栏 -->
<AdminSidebar
:is-open="sidebarOpen"
:active-tab="activeTab"
:current-user="currentUser"
:permissions="permissions"
:site-title="siteTitle"
@navigate="handleNavigate"
@close="closeSidebar"
@logout="handleLogout"
/>
<!-- 移动端侧边栏遮罩 -->
<div
v-if="sidebarOpen"
class="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden transition-opacity duration-300"
@click="closeSidebar"
/>
<!-- 主内容区域 -->
<main
class="flex-1 flex flex-col h-screen overflow-hidden lg:ml-64 relative bg-[#f8fafc] text-zinc-900"
>
<header
class="admin-main-header h-16 shrink-0 flex items-center justify-between px-4 md:px-8 border-b border-zinc-200 bg-white/80 backdrop-blur-xl z-30"
>
<div class="flex items-center gap-3">
<button
class="admin-mobile-menu-btn lg:hidden p-2 text-zinc-600 hover:bg-zinc-100 rounded-lg transition-colors"
@click="toggleSidebar"
>
<Menu :size="20" />
</button>
<h1 class="text-xl font-bold tracking-tight">{{ getPageTitle() }}</h1>
</div>
<div class="flex items-center gap-2">
<button
class="px-3 py-1.5 text-xs font-semibold rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-100 transition-colors"
@click="openOnboardingTutorial"
>
新手引导
</button>
</div>
</header>
<div
ref="contentScrollRef"
class="admin-content-area flex-1 custom-scrollbar p-4 md:p-8 overflow-y-auto"
>
<!-- 移动端返回顶部按钮 -->
<button
v-if="showBackToTop"
aria-label="返回顶部"
class="back-to-top-btn fixed bottom-8 right-8 p-3 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 transition-all z-50"
@click="scrollToTop"
>
<ChevronUp :size="24" />
</button>
<div :key="`${activeTab}-${tabRefreshKey}`">
<!-- 数据概览 -->
<div
v-if="activeTab === 'overview' && permissions.canAccessPage('overview')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminOverviewDashboard @navigate="handleNavigate" />
</div>
<!-- 工作流教程 -->
<div
v-if="activeTab === 'workflow-tutorial' && permissions.canAccessPage('workflow-tutorial')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminWorkflowTutorial />
</div>
<!-- 歌曲管理 -->
<div
v-if="activeTab === 'songs' && permissions.canAccessPage('songs')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminSongManagement />
</div>
<!-- 排期管理 -->
<div
v-if="activeTab === 'schedule' && permissions.canAccessPage('schedule')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
>
<LazyAdminScheduleManager />
</div>
<!-- 打印排期 -->
<div
v-if="activeTab === 'print' && permissions.canAccessPage('print')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
>
<LazyAdminSchedulePrinter />
</div>
<!-- 导入酷狗:账号扫码绑定 + 按排期自动创建/同步酷狗歌单 -->
<div
v-if="activeTab === 'playlist-card' && permissions.canAccessPage('playlist-card')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
>
<LazyAdminKuGouImporter />
</div>
<!-- 数据分析 -->
<div
v-if="activeTab === 'data-analysis' && permissions.canAccessPage('data-analysis')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminDataAnalysisPanel />
</div>
<!-- 用户管理 -->
<div
v-if="activeTab === 'users' && permissions.canAccessPage('users')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminUserManager />
</div>
<!-- 消息管理 -->
<div
v-if="activeTab === 'notifications' && permissions.canAccessPage('notifications')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminNotificationSender />
</div>
<!-- SMTP邮件配置 -->
<div
v-if="activeTab === 'smtp-config' && permissions.canAccessPage('smtp-config')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminSmtpManager />
</div>
<!-- 播出时段 -->
<div
v-if="activeTab === 'playtimes' && permissions.canAccessPage('playtimes')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminPlayTimeManager />
</div>
<!-- 投稿管理 -->
<div
v-if="activeTab === 'request-times' && permissions.canAccessPage('request-times')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminRequestTimeManager />
</div>
<!-- 学期管理 -->
<div
v-if="activeTab === 'semesters' && permissions.canAccessPage('semesters')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminSemesterManager />
</div>
<!-- 黑名单管理 -->
<div
v-if="activeTab === 'blacklist' && permissions.canAccessPage('blacklist')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminBlacklistManager />
</div>
<!-- 站点配置 -->
<div
v-if="activeTab === 'site-config' && permissions.canAccessPage('site-config')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminSiteConfigManager />
</div>
<!-- 更新日志 -->
<div
v-if="activeTab === 'changelog' && permissions.canAccessPage('changelog')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminChangelogManager />
</div>
<!-- 选曲日期管理 -->
<div
v-if="activeTab === 'expected-play-dates' && permissions.canAccessPage('settings')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
>
<LazyAdminExpectedPlayDates />
</div>
<!-- 数据库操作 -->
<div
v-if="activeTab === 'database' && permissions.canAccessPage('database')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminDatabaseManager />
</div>
<!-- 权限组管理 -->
<div
v-if="activeTab === 'roles' && permissions.canAccessPage('roles')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminRolesManager />
</div>
<!-- API密钥管理 -->
<div
v-if="activeTab === 'api-keys' && permissions.canAccessPage('api-keys')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminApiKeyManager />
</div>
</div>
</div>
<AdminOnboardingTutorial
:visible="showOnboardingTutorial"
@navigate="handleNavigate"
@close="closeOnboardingTutorial"
@complete="completeOnboardingTutorial"
/>
</main>
</div>
</ClientOnly>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { Menu, ChevronUp } from '@lucide/vue'
import { useAuth } from '~/composables/useAuth'
import { usePermissions } from '~/composables/usePermissions'
import { fetchRolesConfig } from '~/composables/usePermissions'
import { useSiteConfig } from '~/composables/useSiteConfig'
import AdminOnboardingTutorial from '~/components/Admin/AdminOnboardingTutorial.vue'
// 使用站点配置
const { siteTitle, initSiteConfig } = useSiteConfig()
// 导入组件
// 页面元数据
definePageMeta({
layout: false
})
// 响应式数据
const activeTab = ref('overview')
const currentUser = ref(null)
const sidebarOpen = ref(false)
const showBackToTop = ref(false)
const showOnboardingTutorial = ref(false)
const contentScrollRef = ref(null)
const beforeNavigateHooks = ref([])
const tabRefreshKey = ref(0)
let revisionInterval = null
let lastDataRevision = ''
// 提供注册导航拦截钩子的方法
const registerBeforeNavigate = (hook) => {
beforeNavigateHooks.value.push(hook)
return () => {
const index = beforeNavigateHooks.value.indexOf(hook)
if (index > -1) {
beforeNavigateHooks.value.splice(index, 1)
}
}
}
provide('registerBeforeNavigate', registerBeforeNavigate)
// 服务
let auth = null
const permissions = usePermissions()
const fetchDataRevision = async () => {
try {
if (!auth || !auth.isAuthenticated.value) return ''
const authConfig = auth.getAuthConfig()
const result = await $fetch('/api/songs/revision', {
...authConfig,
query: { t: Date.now() }
})
return result?.revision || ''
} catch (e) {
console.error('获取数据修订号失败:', e)
return ''
}
}
const startRevisionPolling = async () => {
if (revisionInterval) clearInterval(revisionInterval)
lastDataRevision = await fetchDataRevision()
revisionInterval = setInterval(async () => {
const next = await fetchDataRevision()
if (!next || next === lastDataRevision) return
lastDataRevision = next
tabRefreshKey.value++
}, 5000)
}
// 方法
const getPageTitle = () => {
const titles = {
overview: '数据概览',
'workflow-tutorial': '流程与教程',
'data-analysis': '数据分析',
songs: '歌曲管理',
schedule: '排期管理',
print: '打印排期',
'playlist-card': '导入酷狗',
users: '用户管理',
blacklist: '黑名单管理',
'api-keys': 'API密钥管理',
notifications: '通知管理',
'smtp-config': '邮件配置',
playtimes: '播出时段',
'request-times': '投稿管理',
semesters: '学期管理',
'expected-play-dates': '选曲日期',
'site-config': '站点配置',
changelog: '更新日志',
database: '数据库操作',
password: '修改密码'
}
return titles[activeTab.value] || '管理后台'
}
// 动态页面标题
const dynamicTitle = computed(() => {
const currentPageTitle = getPageTitle()
if (siteTitle && siteTitle.value) {
return `${currentPageTitle} | ${siteTitle.value}`
}
return `${currentPageTitle} | 校园广播站点歌系统`
})
// 监听activeTab变化,更新页面标题
watch(
activeTab,
() => {
if (typeof document !== 'undefined') {
document.title = dynamicTitle.value
}
},
{ immediate: true }
)
// 监听siteTitle变化,更新页面标题
watch(
() => siteTitle?.value,
() => {
if (typeof document !== 'undefined') {
document.title = dynamicTitle.value
}
}
)
const getRoleDisplayName = (role) => {
const roleNames = {
USER: '普通用户',
SONG_ADMIN: '歌曲管理员',
ADMIN: '超级管理员',
SUPER_ADMIN: '超级管理员'
}
return roleNames[role] || role
}
const handleLogout = async () => {
if (auth) {
await auth.logout()
await navigateTo('/login')
}
}
// 导航方法
const handleNavigate = async (tab) => {
if (activeTab.value === tab) return
// 检查是否有拦截
for (const hook of beforeNavigateHooks.value) {
if (!(await hook(tab))) return
}
activeTab.value = tab
// 移动端点击导航后关闭侧边栏
if (window.innerWidth <= 768) {
closeSidebar()
}
}
// 侧边栏控制方法
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
const closeSidebar = () => {
sidebarOpen.value = false
}
const openOnboardingTutorial = () => {
showOnboardingTutorial.value = true
}
const closeOnboardingTutorial = () => {
showOnboardingTutorial.value = false
}
const completeOnboardingTutorial = () => {
showOnboardingTutorial.value = false
if (typeof window !== 'undefined') {
localStorage.setItem('voicehub_admin_tutorial_seen_v1', 'true')
}
}
// 监听窗口大小变化,大屏幕时自动关闭移动端侧边栏
const handleResize = () => {
if (window.innerWidth > 768) {
sidebarOpen.value = false
}
}
// 返回顶部功能
const scrollToTop = () => {
if (contentScrollRef.value) {
contentScrollRef.value.scrollTo({ top: 0, behavior: 'smooth' })
}
}
// 监听滚动事件
const handleScroll = () => {
showBackToTop.value = (contentScrollRef.value?.scrollTop || 0) > 300
}
// 触摸手势支持
let touchStartX = 0
let touchStartY = 0
let touchEndX = 0
let touchEndY = 0
const handleTouchStart = (e) => {
touchStartX = e.changedTouches[0].screenX
touchStartY = e.changedTouches[0].screenY
}
const handleTouchEnd = (e) => {
touchEndX = e.changedTouches[0].screenX
touchEndY = e.changedTouches[0].screenY
handleSwipe()
}
const handleSwipe = () => {
const deltaX = touchEndX - touchStartX
const deltaY = touchEndY - touchStartY
const minSwipeDistance = 50
// 只在移动端处理滑动手势
if (window.innerWidth > 768) return
// 水平滑动距离大于垂直滑动距离,且超过最小滑动距离
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > minSwipeDistance) {
if (deltaX > 0 && touchStartX < 50) {
// 从左边缘向右滑动,打开侧边栏
sidebarOpen.value = true
} else if (deltaX < 0 && sidebarOpen.value) {
// 向左滑动,关闭侧边栏
sidebarOpen.value = false
}
}
}
// 生命周期
onMounted(async () => {
// 初始化站点配置
await initSiteConfig()
// 初始化服务
auth = useAuth()
await fetchRolesConfig()
// 检查认证状态(plugin已经初始化过了)
if (!auth.isAuthenticated.value) {
await navigateTo('/login')
return
}
// 检查用户是否有访问后台的权限
if (!permissions.canAccessAdmin.value) {
await navigateTo('/')
return
}
currentUser.value = auth.user.value
// 设置默认页面
const userPages = permissions.getUserPages.value
if (userPages.length > 0 && !userPages.includes(activeTab.value)) {
activeTab.value = userPages[0]
}
// 设置初始页面标题
if (typeof document !== 'undefined') {
document.title = dynamicTitle.value
}
// 添加窗口大小监听器
window.addEventListener('resize', handleResize)
contentScrollRef.value?.addEventListener('scroll', handleScroll, { passive: true })
handleScroll()
// 添加双击关闭侧边栏事件
document.addEventListener('dblclick', (e) => {
if (window.innerWidth <= 768 && sidebarOpen.value) {
closeSidebar()
}
})
if (!localStorage.getItem('voicehub_admin_tutorial_seen_v1')) {
setTimeout(() => {
showOnboardingTutorial.value = true
}, 800)
}
await startRevisionPolling()
})
// 组件卸载时清理事件监听器
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
contentScrollRef.value?.removeEventListener('scroll', handleScroll)
if (revisionInterval) clearInterval(revisionInterval)
})
</script>
<style scoped>
.admin-layout {
display: flex;
min-height: 100vh;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #1a2332);
position: relative;
}
/* 自定义滚动条样式 */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--border-primary, #cbd5e1);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--border-secondary, #94a3b8);
}
@media (max-width: 768px) {
.admin-main-header {
padding-top: max(0px, env(safe-area-inset-top));
}
.admin-content-area {
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
}
.back-to-top-btn {
right: 1rem;
bottom: calc(1rem + env(safe-area-inset-bottom));
padding: 0.65rem;
}
}
</style>