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:
@@ -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);
|
||||||
@@ -354,8 +354,33 @@ export const systemSettings = pgTable('SystemSettings', {
|
|||||||
enabledThemes: text('enabledThemes').default('[\"System\",\"ClassicDark\",\"ClassicLight\",\"ModernLight\"]').notNull(),
|
enabledThemes: text('enabledThemes').default('[\"System\",\"ClassicDark\",\"ClassicLight\",\"ModernLight\"]').notNull(),
|
||||||
enabledPlatforms: text('enabledPlatforms'),
|
enabledPlatforms: text('enabledPlatforms'),
|
||||||
platformOrder: text('platformOrder'),
|
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', {
|
export const songBlacklists = pgTable('SongBlacklist', {
|
||||||
id: serial('id').primaryKey(),
|
id: serial('id').primaryKey(),
|
||||||
|
|||||||
@@ -102,12 +102,12 @@
|
|||||||
<LazyAdminSchedulePrinter />
|
<LazyAdminSchedulePrinter />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 歌单卡片图片 -->
|
<!-- 导入酷狗:账号扫码绑定 + 按排期自动创建/同步酷狗歌单 -->
|
||||||
<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"
|
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
|
||||||
>
|
>
|
||||||
<LazyAdminPlaylistCardPage />
|
<LazyAdminKuGouImporter />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 数据分析 -->
|
<!-- 数据分析 -->
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,11 +22,11 @@ try {
|
|||||||
if (!ROLES.USER) {
|
if (!ROLES.USER) {
|
||||||
ROLES = {
|
ROLES = {
|
||||||
USER: { displayName: '学生(普通用户)', pages: [], canAccessAdmin: false },
|
USER: { displayName: '学生(普通用户)', pages: [], canAccessAdmin: false },
|
||||||
SONG_ADMIN: { displayName: '工作人员', pages: ['schedule', 'songs', 'print'], canAccessAdmin: true },
|
SONG_ADMIN: { displayName: '工作人员', pages: ['schedule', 'songs', 'print', 'playlist-card'], canAccessAdmin: true },
|
||||||
VICE_MINISTER: { displayName: '副部', pages: ['overview', 'schedule', 'songs', 'print', 'users'], canAccessAdmin: true },
|
VICE_MINISTER: { displayName: '副部', pages: ['overview', 'schedule', 'songs', 'print', 'playlist-card', 'users'], canAccessAdmin: true },
|
||||||
ADMIN: { displayName: '部长', pages: ['overview', 'schedule', 'songs', 'print', 'users', 'data-analysis', 'notifications', 'playtimes', 'semesters'], 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'], canAccessAdmin: true },
|
RESIDENT: { displayName: '常驻', pages: ['schedule', 'songs', 'print', 'playlist-card'], 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 }
|
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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user