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:
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user