数据模型: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 覆盖。
587 lines
17 KiB
Vue
587 lines
17 KiB
Vue
<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>
|