Files
BYW-VoiceHub/app/components/Admin/KuGouImporter.client.vue
T
Henry_Du 381f3debf1 fix(admin+server): 酷狗 baseUrl 默认值移除 127.0.0.1 + 新增后台配置入口与推荐快捷地址
问题:容器内 SystemSettings.kugouApiBaseUrl 默认 http://127.0.0.1:3000 指向自己,sidecar 不可达。

修复:1) schema + migration 移除 kugouApiBaseUrl 默认值;kugouService + status API 兜底改空串(不再假设有 localhost),优先级 env(KUGOU_API_BASE) > DB。

2) 新增 POST /api/admin/kugou/settings:ADMIN+/SUPER 即时写 kugouApiBaseUrl 入库+刷新 CacheService。

3) KuGouImporter.client.vue 左栏新增 baseUrl 输入框(回车即存)+ 保存并测试按钮 + 两个推荐快捷按钮(容器宿主 http://172.19.0.1:3000、公网中继 https://bywmusic.bzw-hheb.top/kugou)+ 当前生效地址显式提示。

4) UI/Icon.vue 新增 server / globe / save 三个 SVG 图标与上述入口配套。
2026-09-19 13:40:59 +08:00

709 lines
29 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">
绑定管理员酷狗账号后,可一键根据排期日期自动创建歌单并同步歌曲到酷狗歌单
</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-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 端口(与 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 baseUrlInput = ref('')
const baseUrlSaving = ref(false)
const baseUrlSavedTick = ref(0)
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
}
}
watch(
() => selectedDates.value.slice().sort().join(','),
() => loadSchedules(),
)
onMounted(async () => {
await reloadStatus()
await Promise.all([loadSchedules(), loadSyncHistory()])
})
</script>