feat(admin): 导入酷狗页同时保留「自动同步」与「手动生成照片」两种模式

KuGouImporter.client.vue 顶部新增模式切换 Tab(默认自动同步),切换到「手动生成照片」即复用原有 PlaylistCardPage 流程:AdminMultiDatePicker 多选日期 → 逐日出排期列表 → 每一日独立「生成该日图片」按钮 → PlaylistCardGenerator 弹窗下载 2:3 卡片 PNG。两套模式各自维护日期选择/排期读取状态,互不阻塞。

UI/Icon.vue 补 layout / image 两个 SVG 图标适配新入口。旧 PlaylistCardPage.client.vue 仍保留不作删除以免回退需求。
This commit is contained in:
2026-09-19 14:15:45 +08:00
parent 381f3debf1
commit 6f5aa625e6
2 changed files with 185 additions and 3 deletions
+171 -3
View File
@@ -3,11 +3,38 @@
<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>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- 模式切换 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">
@@ -395,6 +422,88 @@ PORT=3000 npm start</code></pre>
</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>
@@ -403,6 +512,7 @@ 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'
@@ -412,6 +522,8 @@ 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)
@@ -431,6 +543,13 @@ 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),
)
@@ -696,13 +815,62 @@ async function loadSyncHistory() {
}
}
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()])
await Promise.all([loadSchedules(), loadSyncHistory(), fetchCardSchedules()])
})
</script>
+14
View File
@@ -399,6 +399,20 @@
<polyline points="7 3 7 8 15 8" />
</g>
<!-- 布局图标 (layout) -->
<g v-else-if="name === 'layout'">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="3" x2="21" y1="9" y2="9" />
<line x1="9" x2="9" y1="21" y2="9" />
</g>
<!-- 图片图标 (image) -->
<g v-else-if="name === 'image'">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-5-5L5 21" />
</g>
<!-- 搜索图标 -->
<g v-else-if="name === 'search'">
<circle cx="11" cy="11" r="8" />