Files
BYW-VoiceHub/app/components/Admin/KuGouImporter.client.vue
T
Henry_Du 3eff710d5d feat(微信订阅): 接入歌曲排期模板并加固部署流程
订阅消息(小程序侧唯一能主动触达用户的通道):
- 新增 server/utils/wechatSubscribeFields.ts:按微信字段类型裁剪值(thing≤20、
  phrase 必须 5 个汉字、time/date/character_string 各自上限),空白压成一行,
  北京时间 time 由 playTime 的开始时间生成,解析不出退化为当天 00:00。
- wechatSubscribeService 内置 SONG_SELECTED 模板(站长新给的那个模板 ID),
  并转发上述纯函数;notificationService.createSongSelectedNotification 现在会带上
  playDate/playTime/sequence,三个排期接口(schedule / publish / bulk-publish)补传 sequence。
- 新增 test/wechatSubscribeFields.test.mjs(10 例,node --test 全绿)。
- test-subscribe 管理接口支持 vars 覆盖并返回真实发送值,方便站长核对字段名。

部署流程(Gitea Actions 容器里没有 buildx 插件,旧流程在此硬失败):
- 每个 job 各自安装/探测 docker-buildx(v0.19.3,走 gh-proxy 再退直连),
  不把 `buildx version` 当唯一依据;装不上时降级为普通 `docker build` 继续发布,
  不再因为缺插件整条流水线失败。

注意:SONG_SELECTED 的字段名(thing2/date4/time1/character_string3/phrase5)是按常见
命名推断的,需在公众平台后台核对;不一致时用环境变量覆盖,模板 ID 变更后老用户需重新授权。
2026-10-11 00:04:14 +08:00

877 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="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">
支持两种方式:自动同步(sidecar 一键建歌单加歌)/ 手动生成照片卡片后扫码导入
</p>
</div>
<!-- 模式切换 Tab -->
<div class="inline-flex self-start rounded-2xl border border-zinc-200 bg-zinc-50 p-1.5 gap-1.5">
<button
:class="[
'h-[40px] px-5 rounded-xl text-[12px] font-bold transition-all inline-flex items-center gap-1.5',
mode === 'auto'
? 'bg-indigo-600 text-white shadow-md shadow-indigo-900/10'
: 'text-zinc-600 hover:text-zinc-900 hover:bg-white',
]"
@click="mode = 'auto'"
>
<Icon name="upload-cloud" :size="14" /> 自动同步(推荐)
</button>
<button
:class="[
'h-[40px] px-5 rounded-xl text-[12px] font-bold transition-all inline-flex items-center gap-1.5',
mode === 'manual-card'
? 'bg-indigo-600 text-white shadow-md shadow-indigo-900/10'
: 'text-zinc-600 hover:text-zinc-900 hover:bg-white',
]"
@click="mode = 'manual-card'"
>
<Icon name="image" :size="14" /> 手动生成照片
</button>
</div>
<!-- ========== 模式一:自动同步 ========== -->
<div v-if="mode === 'auto'" 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-4">
<!-- baseUrl 配置 -->
<div class="rounded-xl border border-zinc-200 bg-zinc-50 p-4 space-y-3">
<div class="flex items-center justify-between">
<label class="text-[11px] font-black uppercase tracking-wider text-zinc-600">
KuGouMusicApi 地址
</label>
<span
v-if="baseUrlSaving"
class="text-[11px] text-zinc-500"
>保存中...</span>
<span
v-else-if="baseUrlSavedTick"
class="text-[11px] text-emerald-600 font-bold"
>已保存</span>
</div>
<input
v-model="baseUrlInput"
type="url"
placeholder="例:http://172.19.0.1:3000 或 https://bywmusic.bzw-hheb.top/kugou,留空则走环境变量 KUGOU_API_BASE"
class="w-full h-[42px] px-3 rounded-xl border border-zinc-200 bg-white text-[12px] text-zinc-900 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-400"
@keydown.enter="saveBaseUrl"
/>
<div class="flex flex-wrap gap-2">
<button
class="h-[34px] px-3 rounded-lg text-[11px] font-bold bg-white hover:bg-zinc-100 border border-zinc-200 text-zinc-700 transition-colors inline-flex items-center gap-1"
@click="baseUrlInput = 'http://172.19.0.1:3000'"
>
<Icon name="server" :size="13" /> 容器直连宿主 http://172.19.0.1:3000
</button>
<button
class="h-[34px] px-3 rounded-lg text-[11px] font-bold bg-white hover:bg-zinc-100 border border-zinc-200 text-zinc-700 transition-colors inline-flex items-center gap-1"
@click="baseUrlInput = 'https://bywmusic.bzw-hheb.top/kugou'"
>
<Icon name="globe" :size="13" /> 中继 https://bywmusic.bzw-hheb.top/kugou
</button>
<button
class="h-[34px] px-3 rounded-lg text-[11px] font-bold bg-zinc-900 hover:bg-zinc-800 text-white transition-colors inline-flex items-center gap-1"
@click="saveBaseUrl"
:disabled="baseUrlSaving"
>
<Icon name="save" :size="13" /> 保存并测试
</button>
</div>
<p v-if="status?.service?.baseUrl" class="text-[11px] text-zinc-500 leading-relaxed">
当前生效地址:<code class="px-1.5 py-0.5 rounded-md bg-white border border-zinc-200 text-zinc-700 font-mono">{{ status.service.baseUrl }}</code>
</p>
</div>
<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 端口(与宝一外校园点歌系统同一台机器即可)。</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 v-else class="flex flex-col gap-8">
<div class="w-full flex flex-col gap-6">
<div class="bg-zinc-50/40 border border-zinc-200 rounded-xl p-6 space-y-6 shadow-sm">
<h3 class="text-lg font-bold flex items-center gap-2 text-zinc-900 shrink-0">
<Icon name="layout" :size="18" class="text-blue-500" /> 选择日期
</h3>
<div class="flex flex-col gap-5 items-end">
<div class="space-y-2 flex-1 w-full">
<label class="text-[11px] font-black uppercase text-zinc-600 tracking-wider">选择要生成图片的日期(可多选)</label>
<AdminMultiDatePicker
v-model="cardSelectedDates"
@change="fetchCardSchedules"
/>
</div>
<button
:disabled="cardLoading || cardSelectedDates.length === 0"
class="w-full py-3 h-[54px] bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-md shadow-blue-900/10 transition-colors uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 shrink-0"
@click="fetchCardSchedules"
>
<Icon name="calendar" :size="16" />
{{ cardLoading ? '加载排期中...' : '读取所选日期排期' }}
</button>
</div>
</div>
</div>
<div class="w-full flex flex-col gap-4">
<template v-if="Object.keys(cardSchedulesByDate).length > 0">
<div
v-for="(schedules, date) in cardSchedulesByDate"
:key="String(date)"
class="bg-zinc-50/40 border border-zinc-200 rounded-xl p-6 shadow-sm mb-4"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-zinc-900">{{ date }} 排期预览 ({{ schedules.length }} 首)</h3>
<button
:disabled="schedules.length === 0"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold rounded-xl shadow-md shadow-emerald-900/10 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
@click="openGenerator(String(date), schedules)"
>
<Icon name="image" :size="14" />
生成该日图片
</button>
</div>
<div v-if="schedules.length > 0" class="space-y-3">
<div
v-for="(schedule, index) in schedules"
:key="schedule?.id || String(index)"
class="flex items-center gap-3 p-3 bg-white/70 border border-zinc-200 rounded-lg"
>
<span class="text-blue-500 font-bold w-6">{{ index + 1 }}.</span>
<div class="flex-1 overflow-hidden">
<p class="text-sm text-zinc-700 font-medium truncate">{{ schedule?.song?.title || schedule?.title }}</p>
<p class="text-xs text-zinc-500 truncate">{{ schedule?.song?.artist || schedule?.artist }}</p>
</div>
</div>
</div>
<div v-else class="text-center py-8 text-zinc-500">
该日期没有排期歌曲
</div>
</div>
</template>
<div
v-else-if="!cardLoading && cardSelectedDates.length > 0"
class="flex flex-col items-center justify-center py-20 bg-zinc-50/20 border border-zinc-200 rounded-xl border-dashed"
>
<Icon name="calendar" :size="44" class="text-zinc-700 mb-4" />
<p class="text-zinc-500">所选日期均没有排期歌曲</p>
</div>
</div>
<AdminPlaylistCardGenerator
:show="showCardModal"
:songs="currentGeneratorSongs"
:default-title="`${currentGeneratorDate} 校园广播歌单`"
@close="showCardModal = false"
/>
</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 AdminPlaylistCardGenerator from './PlaylistCardGenerator.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 mode = ref<'auto' | 'manual-card'>('auto')
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 baseUrlInput = ref('')
const baseUrlSaving = ref(false)
const baseUrlSavedTick = ref(0)
const cardSelectedDates = ref<string[]>([todayStr])
const cardSchedulesByDate = ref<Record<string, any[]>>({})
const cardLoading = ref(false)
const showCardModal = ref(false)
const currentGeneratorDate = ref('')
const currentGeneratorSongs = ref<any[]>([])
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
if (r?.service?.baseUrl && !baseUrlInput.value) {
baseUrlInput.value = r.service.baseUrl
}
} catch (e: any) {
toast.show?.(e?.message || '状态刷新失败', 'error')
}
}
async function saveBaseUrl() {
const value = (baseUrlInput.value || '').trim()
if (value && !/^https?:\/\//i.test(value)) {
toast.show?.('请填写合法的 http(s) URL,或留空走环境变量 KUGOU_API_BASE', 'error')
return
}
baseUrlSaving.value = true
baseUrlSavedTick.value = 0
try {
await $fetch('/api/admin/kugou/settings', {
method: 'POST',
...auth.getAuthConfig(),
body: { kugouApiBaseUrl: value || null },
})
toast.show?.('地址已保存', 'success')
baseUrlSavedTick.value = Date.now()
await reloadStatus()
} catch (e: any) {
toast.show?.(e?.message || '保存失败', 'error')
} finally {
baseUrlSaving.value = false
}
}
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
}
}
async function fetchCardSchedulesForDate(date: string) {
try {
const response: any = await $fetch('/api/admin/schedule/full', {
...auth.getAuthConfig(),
query: { date, includeDrafts: 'true' },
})
if (response?.success && response?.data?.schedules) {
return response.data.schedules
}
} catch (e: any) {
console.error(`Failed to fetch schedules for ${date}:`, e)
}
return []
}
async function fetchCardSchedules() {
if (cardSelectedDates.value.length === 0) {
cardSchedulesByDate.value = {}
return
}
cardLoading.value = true
const newSchedules: Record<string, any[]> = {}
try {
const sortedDates = [...cardSelectedDates.value].sort()
for (const d of sortedDates) {
const items = await fetchCardSchedulesForDate(d)
if (items.length > 0) newSchedules[d] = items
}
cardSchedulesByDate.value = newSchedules
if (Object.keys(newSchedules).length === 0) {
toast.show?.('所选日期没有排期歌曲', 'info')
}
} catch (e: any) {
toast.show?.(e?.message || '获取排期失败', 'error')
} finally {
cardLoading.value = false
}
}
function openGenerator(date: string, songs: any[]) {
currentGeneratorDate.value = date
currentGeneratorSongs.value = songs
showCardModal.value = true
}
watch(
() => selectedDates.value.slice().sort().join(','),
() => loadSchedules(),
)
watch(
() => cardSelectedDates.value.slice().sort().join(','),
() => fetchCardSchedules(),
)
onMounted(async () => {
await reloadStatus()
await Promise.all([loadSchedules(), loadSyncHistory(), fetchCardSchedules()])
})
</script>