Files
BYW-VoiceHub/app/components/Admin/KuGouImporter.client.vue
T
Henry_Du a0184fdeb0 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 覆盖。
2026-09-19 11:57:02 +08:00

631 lines
25 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-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>