KuGouImporter.client.vue 顶部新增模式切换 Tab(默认自动同步),切换到「手动生成照片」即复用原有 PlaylistCardPage 流程:AdminMultiDatePicker 多选日期 → 逐日出排期列表 → 每一日独立「生成该日图片」按钮 → PlaylistCardGenerator 弹窗下载 2:3 卡片 PNG。两套模式各自维护日期选择/排期读取状态,互不阻塞。 UI/Icon.vue 补 layout / image 两个 SVG 图标适配新入口。旧 PlaylistCardPage.client.vue 仍保留不作删除以免回退需求。
877 lines
36 KiB
Vue
877 lines
36 KiB
Vue
<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 端口(与 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 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>
|