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 覆盖。
This commit is contained in:
2026-09-19 11:57:02 +08:00
parent 573e168702
commit a0184fdeb0
12 changed files with 1255 additions and 8 deletions
@@ -0,0 +1,630 @@
<template>
<div class="flex flex-col space-y-8 pb-12 lg:pb-20 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div class="flex flex-col space-y-2">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900">导入酷狗</h2>
<p class="text-sm text-zinc-500">
绑定管理员酷狗账号后,可一键根据排期日期自动创建歌单并同步歌曲到酷狗歌单
</p>
</div>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- 左:账号绑定 & sidecar 状态 -->
<div class="xl:col-span-1 flex flex-col gap-6">
<div class="bg-white/90 border border-zinc-200 rounded-2xl p-6 shadow-sm">
<div class="flex items-center gap-2 mb-4">
<div class="w-9 h-9 rounded-xl bg-blue-50 flex items-center justify-center text-blue-600">
<Icon name="user" :size="18" />
</div>
<div>
<h3 class="text-base font-bold text-zinc-900">管理员酷狗账号</h3>
<p class="text-[11px] text-zinc-500">扫码绑定后,歌单会同步到此账号的收藏列表</p>
</div>
</div>
<div v-if="status" class="space-y-3 text-[12px]">
<div class="flex items-center justify-between">
<span class="text-zinc-500">Sidecar 地址</span>
<code class="text-[11px] px-2 py-0.5 rounded-lg bg-zinc-100 text-zinc-700 font-mono">{{ status.service.baseUrl }}</code>
</div>
<div class="flex items-center gap-2">
<span class="text-zinc-500">服务状态</span>
<span
:class="[
'inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[11px] font-bold',
status.service.serviceOk
? 'bg-emerald-50 text-emerald-700'
: 'bg-amber-50 text-amber-700',
]"
>
<span
:class="[
'w-1.5 h-1.5 rounded-full',
status.service.serviceOk ? 'bg-emerald-500' : 'bg-amber-500',
]"
></span>
{{ status.service.serviceOk ? '在线' : '待启动' }}
</span>
</div>
<p class="text-[11px] leading-relaxed text-zinc-600 whitespace-pre-wrap break-words">
{{ status.service.serviceMessage }}
</p>
<div class="flex items-center justify-between">
<span class="text-zinc-500">绑定状态</span>
<span
:class="[
'inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[11px] font-bold',
status.bound.hasToken || status.bound.hasCookie
? 'bg-blue-50 text-blue-700'
: 'bg-zinc-100 text-zinc-500',
]"
>
{{ status.bound.hasToken || status.bound.hasCookie ? '已绑定' : '未绑定' }}
</span>
</div>
<div v-if="status.bound.boundAt" class="text-[11px] text-zinc-500">
绑定时间:{{ formatDateTime(status.bound.boundAt) }}
</div>
</div>
<div v-else class="space-y-2 text-[12px]">
<LoadingState :height="72" />
</div>
<div class="mt-5 space-y-3">
<div class="flex gap-2">
<button
class="flex-1 h-[44px] px-3 rounded-xl text-[12px] font-bold bg-zinc-100 hover:bg-zinc-200 text-zinc-700 transition-colors flex items-center justify-center gap-1.5"
@click="reloadStatus"
>
<Icon name="rotate-cw" :size="14" /> 刷新状态
</button>
<button
v-if="status?.bound?.hasToken || status?.bound?.hasCookie"
class="flex-1 h-[44px] px-3 rounded-xl text-[12px] font-bold bg-red-50 hover:bg-red-100 text-red-600 transition-colors flex items-center justify-center gap-1.5 border border-red-100"
@click="handleUnbind"
>
<Icon name="log-out" :size="14" /> 解除绑定
</button>
</div>
<button
v-if="!(status?.bound?.hasToken || status?.bound?.hasCookie)"
:disabled="qrLoading"
class="w-full h-[48px] px-3 rounded-xl text-[12px] font-bold bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/60 text-white transition-colors flex items-center justify-center gap-2 shadow-md shadow-blue-900/10"
@click="startQrLogin"
>
<Icon name="qr-code" :size="16" />
{{ qrLoading ? '正在获取二维码...' : '扫码绑定管理员酷狗账号' }}
</button>
</div>
</div>
<!-- 扫码弹窗 -->
<div
v-if="qrOpen"
class="bg-white/90 border border-zinc-200 rounded-2xl p-6 shadow-sm flex flex-col items-center text-center"
>
<h3 class="text-base font-bold text-zinc-900 mb-1">扫码绑定酷狗账号</h3>
<p class="text-xs text-zinc-500 mb-4">使用酷狗 App 扫描下方二维码完成绑定</p>
<div
class="w-[260px] h-[260px] rounded-2xl border border-zinc-200 bg-zinc-50 flex items-center justify-center overflow-hidden"
>
<img
v-if="qrImg"
:src="qrImg"
alt="kugou-qr"
class="w-full h-full object-contain"
/>
<LoadingState v-else :height="260" />
</div>
<p class="mt-4 text-xs font-semibold" :class="qrStateColor">
{{ qrHint || '等待扫码...' }}
</p>
<button
class="mt-4 h-[40px] px-4 rounded-xl text-[12px] font-bold bg-zinc-100 hover:bg-zinc-200 text-zinc-700 transition-colors"
@click="closeQr"
>
取消
</button>
</div>
<!-- sidecar 部署说明 -->
<div class="bg-zinc-50 border border-dashed border-zinc-300 rounded-2xl p-5 text-[12px] leading-relaxed text-zinc-600 space-y-2">
<h4 class="text-zinc-900 font-bold flex items-center gap-1.5 text-sm">
<Icon name="info" :size="14" /> 如何启动酷狗 sidecar?
</h4>
<p>推荐方式 B:部署开源 KuGouMusicApi 项目到本机 3000 端口(与 VoiceHub 同一台机器即可)。</p>
<pre class="bg-zinc-900 text-zinc-100 text-[11px] rounded-xl p-3 overflow-x-auto font-mono"><code>git clone https://github.com/MakcRe/KuGouMusicApi
cd KuGouMusicApi
npm install
PORT=3000 npm start</code></pre>
<p>启动后点「刷新状态」验证连通性;如需自定义端口,可在站点配置管理中设置 kugouApiBaseUrl。</p>
</div>
</div>
<!-- 中:排期同步主面板 -->
<div class="xl:col-span-2 flex flex-col gap-6">
<div class="bg-white/90 border border-zinc-200 rounded-2xl p-6 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3 mb-5">
<div class="flex items-center gap-2">
<div class="w-9 h-9 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-600">
<Icon name="music" :size="18" />
</div>
<div>
<h3 class="text-base font-bold text-zinc-900">按排期日期创建酷狗歌单</h3>
<p class="text-[11px] text-zinc-500">
每个日期创建 1 个歌单,命名:<span class="font-mono text-zinc-700">BYW广播 YYYY-MM-DD (周X)</span>
</p>
</div>
</div>
<button
:disabled="syncing || selectedDates.length === 0 || !(status?.bound?.hasToken || status?.bound?.hasCookie)"
class="h-[48px] px-5 rounded-xl text-[12px] font-bold bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white transition-colors inline-flex items-center justify-center gap-2 shadow-md shadow-indigo-900/10"
@click="syncToKugou"
>
<Icon name="upload-cloud" :size="16" />
{{ syncing ? '同步中...' : `同步 ${selectedDates.length} 个排期日到酷狗` }}
</button>
</div>
<div class="space-y-3">
<div>
<label class="text-[11px] font-black uppercase tracking-wider text-zinc-600">
选择排期日期(可多选)
</label>
<div class="mt-2">
<AdminMultiDatePicker v-model="selectedDates" />
</div>
</div>
<div class="pt-2 grid grid-cols-1 md:grid-cols-3 gap-3 text-[12px]">
<div class="rounded-xl p-3 bg-zinc-50 border border-zinc-200">
<p class="text-zinc-500">已选排期数</p>
<p class="mt-1 text-lg font-bold text-zinc-900">{{ selectedDates.length }} 天</p>
</div>
<div class="rounded-xl p-3 bg-zinc-50 border border-zinc-200">
<p class="text-zinc-500">读取到歌曲总数</p>
<p class="mt-1 text-lg font-bold text-zinc-900">{{ totalLoadedSongs }} 首</p>
</div>
<div class="rounded-xl p-3 bg-zinc-50 border border-zinc-200">
<p class="text-zinc-500">最近一次同步</p>
<p class="mt-1 text-[13px] font-bold text-zinc-900 truncate">
{{ lastSyncText }}
</p>
</div>
</div>
<div class="flex gap-2">
<button
:disabled="loadingSchedules || selectedDates.length === 0"
class="h-[42px] px-4 rounded-xl text-[12px] font-bold bg-zinc-100 hover:bg-zinc-200 text-zinc-700 transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
@click="loadSchedules"
>
<Icon name="calendar" :size="14" />
{{ loadingSchedules ? '读取排期中...' : '读取所选日期排期' }}
</button>
<button
class="h-[42px] px-4 rounded-xl text-[12px] font-bold bg-zinc-100 hover:bg-zinc-200 text-zinc-700 transition-colors inline-flex items-center gap-1.5"
@click="loadSyncHistory"
>
<Icon name="list" :size="14" />
刷新历史
</button>
</div>
</div>
<!-- 预览 -->
<div v-if="Object.keys(schedulesByDate).length > 0" class="mt-6 space-y-4">
<div
v-for="(items, date) in schedulesByDate"
:key="String(date)"
class="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm"
>
<div class="flex items-center justify-between mb-3">
<div>
<h4 class="text-sm font-bold text-zinc-900">{{ date }}</h4>
<p class="text-[11px] text-zinc-500">共 {{ items.length }} 首已发布排期歌曲</p>
</div>
<button
class="h-[38px] px-3 rounded-xl text-[11px] font-bold bg-emerald-50 hover:bg-emerald-100 text-emerald-700 transition-colors inline-flex items-center gap-1.5 border border-emerald-100"
:disabled="syncing || !(status?.bound?.hasToken || status?.bound?.hasCookie) || items.length === 0"
@click="syncSingleDate(String(date))"
>
<Icon name="play" :size="13" /> 单独同步本日
</button>
</div>
<div class="space-y-2 max-h-[260px] overflow-y-auto custom-scrollbar pr-1">
<div
v-for="(s, i) in items"
:key="s.scheduleId ?? i"
class="flex items-center gap-3 p-2.5 rounded-xl bg-zinc-50 border border-zinc-200"
>
<span class="w-6 text-center text-[11px] font-bold text-indigo-600">{{ i + 1 }}</span>
<img
v-if="s.coverUrl || s.cover"
:src="s.coverUrl || s.cover"
class="w-9 h-9 rounded-lg object-cover bg-zinc-200 border border-zinc-200 shrink-0"
@error="($event.target as any).style.display = 'none'"
/>
<div
v-else
class="w-9 h-9 rounded-lg bg-indigo-50 text-indigo-600 flex items-center justify-center border border-zinc-200 shrink-0"
>
<Icon name="music" :size="14" />
</div>
<div class="min-w-0 flex-1">
<p class="text-[13px] font-semibold text-zinc-900 truncate">
{{ s.title }}
</p>
<p class="text-[11px] text-zinc-500 truncate">
{{ s.singer || s.artist || '未知歌手' }}
</p>
</div>
<span
v-if="s.kgHash"
class="text-[10px] font-bold text-emerald-700 bg-emerald-50 rounded-md px-1.5 py-0.5 border border-emerald-100"
title="已绑定酷狗 hash,可精确添加"
>
KG
</span>
</div>
</div>
</div>
</div>
<div
v-else-if="!loadingSchedules && selectedDates.length > 0"
class="mt-6 rounded-2xl border border-dashed border-zinc-300 py-14 flex flex-col items-center justify-center text-zinc-500"
>
<Icon name="calendar-x" :size="36" class="mb-2 text-zinc-400" />
<p class="text-sm">所选日期还没有已发布排期歌曲</p>
</div>
</div>
<!-- 同步历史 -->
<div class="bg-white/90 border border-zinc-200 rounded-2xl p-6 shadow-sm">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<div class="w-9 h-9 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600">
<Icon name="history" :size="18" />
</div>
<div>
<h3 class="text-base font-bold text-zinc-900">最近同步记录</h3>
<p class="text-[11px] text-zinc-500">最多显示 100 条,缺失歌曲可在“详情”里手动补录 kgHash 后重试</p>
</div>
</div>
</div>
<div v-if="historyLoading" class="space-y-2">
<LoadingState :height="96" />
</div>
<div v-else-if="!syncHistory.length" class="text-center py-10 text-zinc-500 text-sm">
暂无同步记录
</div>
<div v-else class="space-y-2 max-h-[420px] overflow-y-auto custom-scrollbar pr-1">
<div
v-for="h in syncHistory"
:key="h.id"
class="flex flex-col sm:flex-row sm:items-center gap-3 p-3 rounded-xl border border-zinc-200 bg-zinc-50"
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span
:class="[
'px-2 py-0.5 rounded-md text-[10px] font-bold border',
statusBadge(h.status).cls,
]"
>
{{ statusBadge(h.status).text }}
</span>
<span class="text-[13px] font-bold text-zinc-900 truncate">
{{ h.playlistName }}
</span>
<a
v-if="h.playlistUrl"
:href="h.playlistUrl"
target="_blank"
rel="noopener noreferrer"
class="text-[11px] text-indigo-600 hover:underline truncate"
>
在酷狗打开 →
</a>
</div>
<div class="mt-1 text-[11px] text-zinc-500 flex flex-wrap gap-x-3 gap-y-1">
<span>排期:{{ formatDateOnly(h.playDate) }}</span>
<span>成功 {{ h.addedSongs }}/{{ h.totalSongs }}</span>
<span v-if="h.missingSongs?.length" class="text-amber-700">
缺 {{ h.missingSongs.length }} 首
</span>
<span>触发:{{ formatDateTime(h.createdAt) }}</span>
</div>
<p v-if="h.error" class="mt-1 text-[11px] text-red-600 whitespace-pre-wrap break-words">
{{ h.error }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import Icon from '~/components/UI/Icon.vue'
import LoadingState from '~/components/UI/Common/LoadingState.vue'
import AdminMultiDatePicker from './AdminMultiDatePicker.vue'
import { useAuth } from '~/composables/useAuth'
import { useToast } from '~/composables/useToast'
const auth = useAuth()
const toast = useToast()
const WEEKDAY = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const today = new Date()
const todayStr = `${today.getFullYear()}-${pad2(today.getMonth() + 1)}-${pad2(today.getDate())}`
const selectedDates = ref<string[]>([todayStr])
const status = ref<any>(null)
const schedulesByDate = ref<Record<string, any[]>>({})
const syncHistory = ref<any[]>([])
const loadingSchedules = ref(false)
const historyLoading = ref(false)
const syncing = ref(false)
const qrLoading = ref(false)
const qrOpen = ref(false)
const qrImg = ref('')
const qrContext = ref<any>(null)
const qrHint = ref('')
const qrState = ref<number>(0)
const qrPollTimer = ref<any>(null)
const totalLoadedSongs = computed(
() => Object.values(schedulesByDate.value).reduce((n, arr) => n + arr.length, 0),
)
const lastSyncText = computed(() => {
if (!syncHistory.value.length) return '暂无同步'
const first = syncHistory.value[0]
return formatDateTime(first.createdAt)
})
const qrStateColor = computed(() => {
const s = qrState.value
if (s === 1) return 'text-emerald-600'
if (s === 2) return 'text-amber-600'
if (s === -1) return 'text-red-600'
return 'text-zinc-500'
})
function pad2(n: number) {
return n < 10 ? '0' + n : '' + n
}
function formatDateTime(s: any) {
if (!s) return '-'
const d = new Date(s)
if (Number.isNaN(d.getTime())) return String(s)
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
}
function formatDateOnly(s: any) {
if (!s) return '-'
const d = new Date(s)
if (Number.isNaN(d.getTime())) return String(s)
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} (${WEEKDAY[d.getDay()]})`
}
function statusBadge(s: string) {
switch (s) {
case 'success':
return { cls: 'bg-emerald-50 text-emerald-700 border-emerald-200', text: '同步成功' }
case 'partial':
return { cls: 'bg-amber-50 text-amber-700 border-amber-200', text: '部分歌曲缺失' }
case 'partial_add_failed':
return { cls: 'bg-amber-50 text-amber-700 border-amber-200', text: '添加歌曲失败' }
case 'failed_create':
return { cls: 'bg-red-50 text-red-700 border-red-200', text: '歌单创建失败' }
case 'skipped_empty':
return { cls: 'bg-zinc-100 text-zinc-500 border-zinc-200', text: '无排期跳过' }
case 'error':
return { cls: 'bg-red-50 text-red-700 border-red-200', text: '同步出错' }
default:
return { cls: 'bg-zinc-100 text-zinc-500 border-zinc-200', text: '等待中' }
}
}
async function reloadStatus() {
try {
const r = await $fetch('/api/admin/kugou/status', { ...auth.getAuthConfig() })
status.value = r
} catch (e: any) {
toast.show?.(e?.message || '状态刷新失败', 'error')
}
}
async function startQrLogin() {
qrLoading.value = true
qrOpen.value = true
qrImg.value = ''
qrHint.value = ''
qrState.value = 0
qrContext.value = null
try {
const r = await $fetch('/api/admin/kugou/qr-create', {
...auth.getAuthConfig(),
query: { platform: 'web' },
})
const d = r?.data
qrContext.value = d
const base64 = d?.img || d?.qrimg || d?.base64 || ''
if (base64) qrImg.value = base64.startsWith('data:image') ? base64 : `data:image/png;base64,${base64}`
else if (d?.qr_url) qrImg.value = d.qr_url
startQrPoll()
} catch (e: any) {
qrHint.value = e?.message || '二维码获取失败'
qrState.value = -1
} finally {
qrLoading.value = false
}
}
function startQrPoll() {
if (qrPollTimer.value) clearInterval(qrPollTimer.value)
let count = 0
qrPollTimer.value = setInterval(async () => {
count++
if (count > 120) {
clearInterval(qrPollTimer.value)
qrPollTimer.value = null
qrHint.value = '二维码已过期,请重试'
qrState.value = -1
return
}
try {
const ctx = qrContext.value || {}
const r: any = await $fetch('/api/admin/kugou/qr-check', {
method: 'POST',
...auth.getAuthConfig(),
body: {
qr_id: ctx.qr_id,
login_id: ctx.login_id,
key: ctx.key || ctx.login_id || ctx.qr_id,
cookie: ctx.cookie || '',
},
})
qrState.value = Number(r?.state ?? 0)
qrHint.value = r?.hint || ''
if (r?.bound) {
clearInterval(qrPollTimer.value)
qrPollTimer.value = null
qrOpen.value = false
toast.show?.('酷狗账号绑定成功', 'success')
await reloadStatus()
await loadSyncHistory()
} else if (qrState.value === -1) {
clearInterval(qrPollTimer.value)
qrPollTimer.value = null
}
} catch (e: any) {
qrHint.value = e?.message || '轮询失败'
}
}, 2500)
}
function closeQr() {
if (qrPollTimer.value) clearInterval(qrPollTimer.value)
qrPollTimer.value = null
qrOpen.value = false
qrImg.value = ''
qrContext.value = null
}
async function handleUnbind() {
if (!window.confirm('确定解除酷狗管理员账号绑定吗?')) return
try {
await $fetch('/api/admin/kugou/unbind', { method: 'POST', ...auth.getAuthConfig() })
toast.show?.('已解除酷狗账号绑定', 'success')
await reloadStatus()
} catch (e: any) {
toast.show?.(e?.message || '解除绑定失败', 'error')
}
}
async function loadSchedules() {
if (!selectedDates.value.length) {
schedulesByDate.value = {}
return
}
loadingSchedules.value = true
const out: Record<string, any[]> = {}
try {
const sorted = [...selectedDates.value].sort()
for (const d of sorted) {
try {
const r: any = await $fetch('/api/admin/schedule/full', {
...auth.getAuthConfig(),
query: { date: d, includeDrafts: 'false' },
})
const list: any[] = r?.success ? r?.data?.schedules ?? [] : []
const normalized = list
.filter((s) => !s.isDraft)
.map((s) => {
const song = s.song || s
return {
scheduleId: s.id,
sequence: s.sequence,
title: song?.title,
singer: song?.singer || song?.artist,
artist: song?.artist || song?.singer,
kgHash: song?.kgHash || song?.kg_hash,
musicId: song?.musicId,
coverUrl: song?.coverUrl || song?.cover_url,
cover: song?.cover,
}
})
if (normalized.length) out[d] = normalized
} catch {}
}
schedulesByDate.value = out
if (!Object.keys(out).length) toast.show?.('所选日期没有已发布排期歌曲', 'info')
} catch (e: any) {
toast.show?.(e?.message || '读取排期失败', 'error')
} finally {
loadingSchedules.value = false
}
}
async function syncToKugou(dates?: string[]) {
const useDates = dates && dates.length ? dates : selectedDates.value
if (!useDates.length) {
toast.show?.('请先选择排期日期', 'warning')
return
}
syncing.value = true
try {
const r: any = await $fetch('/api/admin/kugou/sync-schedule-playlists', {
method: 'POST',
...auth.getAuthConfig(),
body: { dates: useDates },
})
const results: any[] = r?.results || []
const ok = results.filter((x) => x.status === 'success').length
const partial = results.filter((x) => x.status === 'partial').length
const fail = results.length - ok - partial
toast.show?.(
`同步完成:成功 ${ok} 个,部分 ${partial} 个,失败 ${fail} 个`,
fail ? 'error' : partial ? 'warning' : 'success',
)
await loadSyncHistory()
} catch (e: any) {
toast.show?.(e?.message || '同步失败', 'error')
} finally {
syncing.value = false
}
}
async function syncSingleDate(date: string) {
await syncToKugou([date])
}
async function loadSyncHistory() {
historyLoading.value = true
try {
const r: any = await $fetch('/api/admin/kugou/sync-history', { ...auth.getAuthConfig() })
syncHistory.value = r?.rows || []
} catch (e: any) {
toast.show?.(e?.message || '读取同步历史失败', 'error')
} finally {
historyLoading.value = false
}
}
watch(
() => selectedDates.value.slice().sort().join(','),
() => loadSchedules(),
)
onMounted(async () => {
await reloadStatus()
await Promise.all([loadSchedules(), loadSyncHistory()])
})
</script>
@@ -0,0 +1,28 @@
-- 酷狗导入:管理员账号凭证 + 按日歌单同步记录表
ALTER TABLE "SystemSettings"
ADD COLUMN IF NOT EXISTS "kugouApiBaseUrl" text DEFAULT 'http://127.0.0.1:3000',
ADD COLUMN IF NOT EXISTS "kugouAdminCookie" text,
ADD COLUMN IF NOT EXISTS "kugouAdminToken" text,
ADD COLUMN IF NOT EXISTS "kugouAdminUser" jsonb,
ADD COLUMN IF NOT EXISTS "kugouAdminBoundAt" timestamp;
CREATE TABLE IF NOT EXISTS "KuGouPlaylistSync" (
"id" serial PRIMARY KEY,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"playDate" date NOT NULL,
"semesterId" integer,
"playlistId" text,
"playlistName" text NOT NULL,
"playlistUrl" text,
"totalSongs" integer DEFAULT 0 NOT NULL,
"addedSongs" integer DEFAULT 0 NOT NULL,
"missingSongs" jsonb DEFAULT '[]'::jsonb NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"error" text,
"triggeredByUserId" integer,
"rawResponse" jsonb DEFAULT '{}'::jsonb NOT NULL
);
CREATE INDEX IF NOT EXISTS "KuGouPlaylistSync_playDate_idx"
ON "KuGouPlaylistSync" ("playDate" DESC);
+25
View File
@@ -354,8 +354,33 @@ export const systemSettings = pgTable('SystemSettings', {
enabledThemes: text('enabledThemes').default('[\"System\",\"ClassicDark\",\"ClassicLight\",\"ModernLight\"]').notNull(),
enabledPlatforms: text('enabledPlatforms'),
platformOrder: text('platformOrder'),
kugouApiBaseUrl: text('kugouApiBaseUrl').default('http://127.0.0.1:3000'),
kugouAdminCookie: text('kugouAdminCookie'),
kugouAdminToken: text('kugouAdminToken'),
kugouAdminUser: jsonb('kugouAdminUser').$type<Record<string, any>>(),
kugouAdminBoundAt: timestamp('kugouAdminBoundAt'),
});
export const kuGouPlaylistSyncs = pgTable('KuGouPlaylistSync', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
playDate: timestamp('playDate').notNull(),
semesterId: integer('semesterId'),
playlistId: text('playlistId'),
playlistName: text('playlistName').notNull(),
playlistUrl: text('playlistUrl'),
totalSongs: integer('totalSongs').default(0).notNull(),
addedSongs: integer('addedSongs').default(0).notNull(),
missingSongs: jsonb('missingSongs').default('[]').$type<any[]>().notNull(),
status: text('status').default('pending').notNull(),
error: text('error'),
triggeredByUserId: integer('triggeredByUserId'),
rawResponse: jsonb('rawResponse').default('{}').$type<Record<string, any>>().notNull(),
}, (table) => [
index('kugou_playlist_sync_play_date_idx').on(table.playDate),
]);
// 歌曲黑名单表
export const songBlacklists = pgTable('SongBlacklist', {
id: serial('id').primaryKey(),
+3 -3
View File
@@ -102,12 +102,12 @@
<LazyAdminSchedulePrinter />
</div>
<!-- 歌单卡片图片 -->
<!-- 导入酷狗:账号扫码绑定 + 按排期自动创建/同步酷狗歌单 -->
<div
v-if="activeTab === 'playlist-card' && permissions.canAccessPage('print')"
v-if="activeTab === 'playlist-card' && permissions.canAccessPage('playlist-card')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
>
<LazyAdminPlaylistCardPage />
<LazyAdminKuGouImporter />
</div>
<!-- 数据分析 -->
+64
View File
@@ -0,0 +1,64 @@
import { eq } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { CacheService } from '~~/server/services/cacheService'
import { kuGouQrLoginCheck } from '~~/server/services/kugouService'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) throw createError({ statusCode: 401, message: '未登录' })
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role))
throw createError({ statusCode: 403, message: '仅管理员可绑定酷狗账号' })
const body = await readBody(event)
const { qr_id, login_id, key, cookie } = body || {}
if (!(qr_id || login_id || key)) {
throw createError({ statusCode: 400, message: '缺少 qr_id 或 key' })
}
const res = await kuGouQrLoginCheck({ qr_id, login_id, key, cookie })
const state = Number(res?.data?.status ?? 0)
const token = (res as any)?.data?.token || (res as any)?.token || ''
const kugouCookie = (res as any)?.data?.cookie || (res as any)?.cookie || cookie || ''
const kugouUser = (res as any)?.data?.userinfo || (res as any)?.userinfo || null
let bound = false
if (state === 1 && (token || kugouCookie)) {
const rows = await db.select().from(systemSettings).limit(1)
const patch: Record<string, any> = {
kugouAdminToken: token || null,
kugouAdminCookie: kugouCookie || null,
kugouAdminUser: kugouUser || null,
kugouAdminBoundAt: new Date(),
updatedAt: new Date(),
}
if (!rows[0]) {
await db.insert(systemSettings).values(patch as any)
} else {
await db.update(systemSettings).set(patch).where(eq(systemSettings.id, rows[0].id))
}
try {
const cs = CacheService.getInstance()
await cs.setSystemSettings?.({ ...(rows[0] || {}), ...patch })
} catch {}
bound = true
}
return {
ok: true,
state, // 0 未扫码 / 1 已完成 / 2 已扫码未确认 / -1 过期
bound,
user: kugouUser,
hint:
state === 0
? '请用酷狗 App 扫二维码'
: state === 2
? '扫码成功,请在手机端确认登录'
: state === 1
? '绑定成功'
: state === -1
? '二维码已过期,请刷新重试'
: res?.error || '等待中',
raw: res,
}
})
+39
View File
@@ -0,0 +1,39 @@
import { kuGouQrLoginCreate } from '~~/server/services/kugouService'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未登录' })
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '仅管理员可绑定酷狗账号' })
}
const query = getQuery(event)
const platform = (query.platform as string) || 'web'
try {
const res = await kuGouQrLoginCreate(
platform === 'lite' ? 'lite' : platform === 'android' ? 'android' : 'web',
)
if (res && res.status !== 1 && (res as any).errcode) {
return {
ok: false,
error: (res as any).error || '二维码生成失败',
data: null,
}
}
return {
ok: true,
data: res?.data || null,
raw: res,
}
} catch (e: any) {
throw createError({
statusCode: 502,
message:
'连接酷狗 sidecar 失败:请先启动 KuGouMusicApi 服务 (默认 http://127.0.0.1:3000),详细: ' +
(e?.message || String(e)),
})
}
})
+51
View File
@@ -0,0 +1,51 @@
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { kuGouFetch } from '~~/server/services/kugouService'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) throw createError({ statusCode: 401, message: '未登录' })
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role))
throw createError({ statusCode: 403, message: '仅管理员可访问' })
const query = getQuery(event)
const base = String(query.baseUrl || '').trim()
if (base && !/^https?:\/\//i.test(base)) {
throw createError({ statusCode: 400, message: 'baseUrl 需要是合法的 http(s) URL' })
}
const rows = await db.select().from(systemSettings).limit(1)
const settings = rows[0] as any
const usedBase = base || settings?.kugouApiBaseUrl || process.env.KUGOU_API_BASE || 'http://127.0.0.1:3000'
let serviceOk: boolean
let serviceMessage: string
let loginStatus: any = null
try {
const r = await kuGouFetch('/login/status', { timestamp: Date.now() })
loginStatus = r
serviceOk = Number((r as any)?.status ?? 0) === 1 || !!((r as any)?.data?.profile || (r as any)?.data?.account)
serviceMessage = serviceOk ? '服务在线,且账号已绑定登录' : 'sidecar 在线,但酷狗账号未登录'
} catch (e: any) {
serviceOk = false
serviceMessage =
'无法连接 sidecar (' + usedBase + '):' + (e?.message || String(e))
}
return {
ok: true,
service: {
baseUrl: usedBase,
serviceOk,
serviceMessage,
loginStatus,
},
bound: {
hasToken: !!settings?.kugouAdminToken,
hasCookie: !!settings?.kugouAdminCookie,
boundAt: settings?.kugouAdminBoundAt || null,
user: settings?.kugouAdminUser || null,
},
}
})
@@ -0,0 +1,21 @@
import { desc } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { kuGouPlaylistSyncs } from '~/drizzle/schema'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) throw createError({ statusCode: 401, message: '未登录' })
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role))
throw createError({ statusCode: 403, message: '仅管理员可查看' })
const q = getQuery(event)
const limit = Math.min(500, Number(q.limit || 100))
const rows = await db
.select()
.from(kuGouPlaylistSyncs)
.orderBy(desc(kuGouPlaylistSyncs.playDate), desc(kuGouPlaylistSyncs.createdAt))
.limit(limit)
return {
ok: true,
rows,
}
})
@@ -0,0 +1,259 @@
import { and, asc, eq, sql } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { schedules, songs, systemSettings, kuGouPlaylistSyncs } from '~/drizzle/schema'
import { kuGouFetch } from '~~/server/services/kugouService'
const WEEKDAY_ZH = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function pad2(n: number) {
return n < 10 ? '0' + n : '' + n
}
function dayKey(d: Date) {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
}
function parseDate(s: string | Date) {
const d = s instanceof Date ? new Date(s) : new Date(s)
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
async function readSettings() {
const rows = await db.select().from(systemSettings).limit(1)
return rows[0] as any
}
async function fetchScheduleForDate(dayStart: Date) {
const dayEnd = new Date(dayStart.getFullYear(), dayStart.getMonth(), dayStart.getDate() + 1)
return await db
.select({
scheduleId: schedules.id,
sequence: schedules.sequence,
songId: schedules.songId,
playDate: schedules.playDate,
title: songs.title,
singer: songs.singer,
artist: songs.artist,
kgHash: songs.kgHash,
musicId: songs.musicId,
coverUrl: songs.coverUrl,
cover: songs.cover,
})
.from(schedules)
.innerJoin(songs, eq(songs.id, schedules.songId))
.where(and(sql`${schedules.playDate} >= ${dayStart}`, sql`${schedules.playDate} < ${dayEnd}`, eq(schedules.isDraft, false)))
.orderBy(asc(schedules.sequence), asc(schedules.playDate))
}
async function ensureLogin(settings: any) {
if (!settings?.kugouAdminToken && !settings?.kugouAdminCookie) {
throw createError({
statusCode: 412,
message: '尚未绑定酷狗管理员账号,请先在本页扫码绑定',
})
}
try {
const res = await kuGouFetch('/login/status', { timestamp: Date.now() })
const ok = Number((res as any)?.status ?? 0) === 1 || !!(res as any)?.data?.profile || !!(res as any)?.data?.account
if (!ok) {
throw new Error('酷狗账号未登录或已过期,请重新扫码绑定')
}
} catch (e: any) {
throw createError({
statusCode: 412,
message: '酷狗登录状态无效:' + (e?.message || String(e)),
})
}
}
async function searchKugouTrackFor(item: { title: string; singer?: string | null; artist?: string | null; kgHash?: string | null }) {
const q = [item.title, item.singer || item.artist].filter(Boolean).join(' - ')
const res = await kuGouFetch<any>('/cloudsearch', {
keywords: q,
limit: 10,
timestamp: Date.now(),
}).catch(() =>
kuGouFetch<any>('/search', {
keywords: q,
limit: 10,
timestamp: Date.now(),
}),
)
const list: any[] =
(res as any)?.result?.songs ||
(res as any)?.data?.info ||
(res as any)?.songs ||
[]
if (!list.length) return null
const targetL = item.title.trim().toLowerCase()
const targetArtist = (item.singer || item.artist || '').trim().toLowerCase()
const best =
list.find((it) => {
const nameL = (it.name || it.fileName || it.songname || '').toString().trim().toLowerCase()
const artistL = ((it.ar && it.ar[0]?.name) || it.singerName || it.singername || '')
.toString()
.trim()
.toLowerCase()
return nameL === targetL && (!targetArtist || artistL.includes(targetArtist))
}) || list[0]
const name = best.name || best.fileName || best.songname || item.title
const hash = best.hash || best.fileHash || ''
const albumId = best.album?.id ?? best.album_id ?? best.albumId ?? ''
const audioId = best.id ?? best.album_audio_id ?? best.albumAudioId ?? ''
return {
name,
hash,
albumId: String(albumId || ''),
audioId: String(audioId || ''),
raw: best,
}
}
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) throw createError({ statusCode: 401, message: '未登录' })
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role))
throw createError({ statusCode: 403, message: '仅管理员可同步排期' })
const body = await readBody(event)
const rawDates: (string | Date)[] = Array.isArray(body?.dates) && body.dates.length ? body.dates : []
const playlistUrlTemplate = body.playlistUrlTemplate || 'https://www.kugou.com/yy/special/single/{listid}.html'
if (!rawDates.length) throw createError({ statusCode: 400, message: '请选择至少一个排期日期' })
const settings = await readSettings()
await ensureLogin(settings)
const results: any[] = []
for (const d of rawDates) {
const dayStart = parseDate(d as string)
const key = dayKey(dayStart)
const playlistName = `BYW广播 ${key} (${WEEKDAY_ZH[dayStart.getDay()]})`
const scheduled = await fetchScheduleForDate(dayStart)
const totalSongs = scheduled.length
let playlistId = ''
let playlistUrl = ''
const missing: any[] = []
const addBatches: string[] = []
let added = 0
let status = 'pending'
let error = ''
let rawCreate: any = null
let rawAdd: any = null
try {
if (!totalSongs) {
status = 'skipped_empty'
error = '当日没有已发布排期的歌曲'
} else {
const createRes = await kuGouFetch<any>('/playlist/create', {
name: playlistName,
timestamp: Date.now(),
})
rawCreate = createRes
const createOk = Number((createRes as any)?.code ?? 0) === 200 || Number((createRes as any)?.status ?? 0) === 1
const listid =
(createRes as any)?.playlist?.id ||
(createRes as any)?.data?.id ||
(createRes as any)?.id ||
(createRes as any)?.listid ||
''
if (!createOk || !listid) {
status = 'failed_create'
error =
(createRes as any)?.message ||
(createRes as any)?.error ||
'歌单创建失败,请检查 sidecar / 酷狗账号权限'
} else {
playlistId = String(listid)
playlistUrl = playlistUrlTemplate.replace('{listid}', playlistId)
const tracks: string[] = []
for (const s of scheduled) {
// 先直接用 kgHash / musicId 构造 track;没有就搜索
const hash = s.kgHash || ''
const name = s.title
const albId = ''
const audioId = s.musicId || ''
if (hash) {
tracks.push([name, hash, albId, audioId].join('|'))
} else {
const hit = await searchKugouTrackFor(s)
if (hit && hit.hash) tracks.push([hit.name, hit.hash, hit.albumId, hit.audioId].join('|'))
else
missing.push({
songId: s.songId,
title: s.title,
singer: s.singer || s.artist,
reason: '酷狗未匹配到可加歌曲',
})
}
}
added = tracks.length
const CHUNK = 100
for (let i = 0; i < tracks.length; i += CHUNK) {
const data = tracks.slice(i, i + CHUNK).join(',')
const r = await kuGouFetch<any>('/playlist/tracks', {
op: 'add',
pid: playlistId,
trackIds: tracks[i],
data,
timestamp: Date.now(),
})
rawAdd = rawAdd || []
rawAdd.push(r)
const ok = Number((r as any)?.code ?? 0) === 200 || Number((r as any)?.status ?? 0) === 1
if (!ok) {
status = 'partial_add_failed'
error = (r as any)?.message || (r as any)?.error || '歌单添加歌曲失败'
break
}
addBatches.push(`${i + 1}-${Math.min(i + CHUNK, tracks.length)}`)
}
if (status === 'pending') status = missing.length ? 'partial' : 'success'
}
}
} catch (e: any) {
status = 'error'
error = e?.message || String(e)
}
const inserted = (
await db
.insert(kuGouPlaylistSyncs)
.values({
playDate: dayStart,
playlistName,
playlistId: playlistId || null,
playlistUrl: playlistUrl || null,
totalSongs,
addedSongs: added,
missingSongs: missing as any,
status,
error: error || null,
triggeredByUserId: Number(user.id) || null,
rawResponse: {
create: rawCreate,
add: rawAdd,
batches: addBatches,
} as any,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
)[0]
results.push({
id: inserted.id,
date: key,
playlistName,
playlistId,
playlistUrl,
totalSongs,
addedSongs: added,
missingSongs: missing,
status,
error,
})
}
return {
ok: true,
results,
}
})
+28
View File
@@ -0,0 +1,28 @@
import { eq } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { CacheService } from '~~/server/services/cacheService'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) throw createError({ statusCode: 401, message: '未登录' })
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role))
throw createError({ statusCode: 403, message: '仅管理员可解绑酷狗账号' })
const rows = await db.select().from(systemSettings).limit(1)
const patch: Record<string, any> = {
kugouAdminToken: null,
kugouAdminCookie: null,
kugouAdminUser: null,
kugouAdminBoundAt: null,
updatedAt: new Date(),
}
if (rows[0]) {
await db.update(systemSettings).set(patch).where(eq(systemSettings.id, rows[0].id))
}
try {
const cs = CacheService.getInstance()
if (rows[0]) await cs.setSystemSettings?.({ ...rows[0], ...patch })
} catch (_) {}
return { ok: true }
})
+102
View File
@@ -0,0 +1,102 @@
import { ofetch } from 'ofetch'
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { CacheService } from './cacheService'
export interface KuGouQrLoginStep1 {
status?: number
data?: {
qr_id?: string
login_id?: string
qrimg?: string
img?: string
extra?: any
}
error?: string
errcode?: number
}
export interface KuGouQrLoginStep2 {
status?: number
data?: {
status?: number
token?: string
cookie?: string
userinfo?: any
auth_params?: any
}
error?: string
errcode?: number
message?: string
}
let cacheService: CacheService | null = null
function getCache() {
if (!cacheService) cacheService = CacheService.getInstance()
return cacheService
}
async function readAdminSettings() {
try {
const c = getCache()
const cached = await c.getSystemSettings?.()
if (cached && (cached.kugouApiBaseUrl || cached.kugouAdminToken || cached.kugouAdminCookie)) {
return cached
}
} catch (_) {}
const rows = await db.select().from(systemSettings).limit(1)
return rows[0] || null
}
export interface KuGouCallOptions {
forceCookie?: string
forceToken?: string
}
export async function kuGouFetch<T = any>(
path: string,
query?: Record<string, any>,
options?: KuGouCallOptions,
): Promise<T> {
const settings = (await readAdminSettings()) as any
const base = (options?.forceToken ? null : settings?.kugouApiBaseUrl) || process.env.KUGOU_API_BASE || 'http://127.0.0.1:3000'
const headers: Record<string, string> = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128 Safari/537.36',
}
const cookie = options?.forceCookie || settings?.kugouAdminCookie
if (cookie) headers.Cookie = cookie
const url = base + path
return await ofetch<T>(url, {
query,
headers,
retry: 1,
timeout: 20000,
onRequest({ options }) {
const token = options?.forceToken || settings?.kugouAdminToken
if (token && options.query) {
;(options.query as any).token = token
}
},
})
}
export async function kuGouQrLoginCreate(platform: 'web' | 'android' | 'lite' = 'web') {
return await kuGouFetch<KuGouQrLoginStep1>(
platform === 'lite' ? '/login/qr/lite/create' : '/login/qr/create',
{ timestamp: Date.now() },
)
}
export async function kuGouQrLoginCheck(params: { qr_id?: string; login_id?: string; key?: string; cookie?: string }) {
return await kuGouFetch<KuGouQrLoginStep2>(
'/login/qr/check',
{
qr_id: params.qr_id,
login_id: params.login_id,
key: params.key,
timestamp: Date.now(),
},
{ forceCookie: params.cookie },
)
}
+5 -5
View File
@@ -22,11 +22,11 @@ try {
if (!ROLES.USER) {
ROLES = {
USER: { displayName: '学生(普通用户)', pages: [], canAccessAdmin: false },
SONG_ADMIN: { displayName: '工作人员', pages: ['schedule', 'songs', 'print'], canAccessAdmin: true },
VICE_MINISTER: { displayName: '副部', pages: ['overview', 'schedule', 'songs', 'print', 'users'], canAccessAdmin: true },
ADMIN: { displayName: '部长', pages: ['overview', 'schedule', 'songs', 'print', 'users', 'data-analysis', 'notifications', 'playtimes', 'semesters'], canAccessAdmin: true },
RESIDENT: { displayName: '常驻', pages: ['schedule', 'songs', 'print'], canAccessAdmin: true },
SUPER_ADMIN: { displayName: '技术运维', pages: ['overview', 'workflow-tutorial', 'schedule', 'songs', 'print', 'users', 'roles', 'blacklist', 'api-keys', 'notifications', 'smtp-config', 'playtimes', 'request-times', 'semesters', 'settings', 'site-config', 'database', 'data-analysis'], canAccessAdmin: true }
SONG_ADMIN: { displayName: '工作人员', pages: ['schedule', 'songs', 'print', 'playlist-card'], canAccessAdmin: true },
VICE_MINISTER: { displayName: '副部', pages: ['overview', 'schedule', 'songs', 'print', 'playlist-card', 'users'], canAccessAdmin: true },
ADMIN: { displayName: '部长', pages: ['overview', 'schedule', 'songs', 'print', 'playlist-card', 'users', 'data-analysis', 'notifications', 'playtimes', 'semesters'], canAccessAdmin: true },
RESIDENT: { displayName: '常驻', pages: ['schedule', 'songs', 'print', 'playlist-card'], canAccessAdmin: true },
SUPER_ADMIN: { displayName: '技术运维', pages: ['overview', 'workflow-tutorial', 'schedule', 'songs', 'print', 'playlist-card', 'users', 'roles', 'blacklist', 'api-keys', 'notifications', 'smtp-config', 'playtimes', 'request-times', 'semesters', 'settings', 'site-config', 'database', 'data-analysis'], canAccessAdmin: true }
}
}