Files
cube/apps/karaoke/frontend/src/App.vue
T
Fam Zheng fbd6e3cb9c karaoke(app): port single-device playlist from partiverse + tests
点歌单本地管理 — 添加/上移/下移/置顶/删除 + 10 秒撤销倒计时 + YouTube 一键
搜,无 room / 无 ws。删掉了 partiverse 那套 yopu 和弦抓取 / LLM 聊天点歌 /
QR 码(依赖后端,对单机无意义)。logic 全 immutable,21 个 vitest 覆盖
边界(首位上移 noop / 末位下移 noop / 缺失 id / 不变性)。
2026-05-14 15:32:22 +01:00

180 lines
5.8 KiB
Vue

<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import AddSongModal from './components/AddSongModal.vue'
import { addSong, deleteSong, moveSong, youtubeSearchUrl, type Song } from './logic/playlist'
import { loadState, saveState } from './logic/storage'
const playlist = ref<Song[]>([])
const showAdd = ref(false)
const pendingDeletes = ref<Record<number, number>>({}) // songId -> timeoutId
const DELETE_DELAY_MS = 10_000
onMounted(() => {
playlist.value = loadState().playlist
})
watch(playlist, () => saveState({ playlist: playlist.value }), { deep: true })
onUnmounted(() => {
for (const id of Object.values(pendingDeletes.value)) clearTimeout(id)
})
function onAdd(payload: { singer: string; title: string }) {
playlist.value = addSong(playlist.value, payload.singer, payload.title)
showAdd.value = false
}
function onMove(songId: number, direction: 'up' | 'down' | 'first') {
playlist.value = moveSong(playlist.value, songId, direction)
}
function startDelete(songId: number) {
pendingDeletes.value[songId] = window.setTimeout(() => {
playlist.value = deleteSong(playlist.value, songId)
delete pendingDeletes.value[songId]
}, DELETE_DELAY_MS)
}
function cancelDelete(songId: number) {
const t = pendingDeletes.value[songId]
if (t !== undefined) {
clearTimeout(t)
delete pendingDeletes.value[songId]
}
}
function isPending(songId: number): boolean {
return pendingDeletes.value[songId] !== undefined
}
</script>
<template>
<main>
<header class="topbar">
<h1>🎤 Karaoke 点歌</h1>
<button class="primary" @click="showAdd = true">+ 添加歌曲</button>
</header>
<section v-if="playlist.length === 0" class="empty">
<p>点歌单空空如也</p>
<p class="dim">点击 "添加歌曲" 把歌排进队</p>
</section>
<section v-else class="list">
<article
v-for="(song, idx) in playlist"
:key="song.id"
:class="['item', { pending: isPending(song.id) }]"
>
<div class="meta">
<span class="idx">{{ idx + 1 }}</span>
<div class="text">
<div class="title">{{ song.title }}</div>
<div class="singer">{{ song.singer }}</div>
</div>
</div>
<div class="actions">
<a
:href="youtubeSearchUrl(song)"
target="_blank"
rel="noopener noreferrer"
class="action yt"
title="在 YouTube 搜索"
>YT</a>
<button class="action" :disabled="idx === 0 || isPending(song.id)" @click="onMove(song.id, 'first')" title="置顶"></button>
<button class="action" :disabled="idx === 0 || isPending(song.id)" @click="onMove(song.id, 'up')" title="上移"></button>
<button class="action" :disabled="idx === playlist.length - 1 || isPending(song.id)" @click="onMove(song.id, 'down')" title="下移"></button>
<button v-if="!isPending(song.id)" class="action danger" @click="startDelete(song.id)" title="删除"></button>
<button v-else class="action cancel-delete" @click="cancelDelete(song.id)">撤销</button>
</div>
<div v-if="isPending(song.id)" class="progress">
<div class="bar" />
</div>
</article>
</section>
<AddSongModal :show="showAdd" @close="showAdd = false" @add="onAdd" />
</main>
</template>
<style scoped>
main {
max-width: 720px;
margin: 0 auto;
padding: 16px 16px 40px;
}
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
h1 { margin: 0; font-size: 1.5rem; background: linear-gradient(135deg, #fff, var(--accent)); -webkit-background-clip: text; background-clip: text; color: transparent; }
button.primary { background: var(--accent); border: none; color: white; padding: 10px 16px; border-radius: 8px; font-weight: bold; }
.empty {
text-align: center;
background: var(--bg-soft);
border-radius: 12px;
padding: 60px 20px;
color: var(--fg-dim);
}
.empty .dim { font-size: 0.9rem; opacity: 0.7; margin-top: 6px; }
.list { display: flex; flex-direction: column; gap: 8px; }
.item {
position: relative;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.item.pending { opacity: 0.55; background: rgba(239, 68, 68, 0.1); }
.meta { display: flex; align-items: center; gap: 12px; min-width: 0; }
.idx {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(236, 72, 153, 0.2);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.85rem;
}
.text { min-width: 0; flex: 1; }
.title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.singer { color: var(--fg-dim); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.action {
min-width: 36px;
height: 36px;
border: 1px solid var(--border);
background: var(--bg-soft);
color: var(--fg);
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: bold;
padding: 0 8px;
text-decoration: none;
font-size: 0.9rem;
}
.action.yt { color: #ff4d4d; }
.action.danger { color: var(--danger); border-color: rgba(239, 68, 68, 0.4); }
.action.cancel-delete { background: var(--accent-2); border-color: var(--accent-2); color: #000; font-size: 0.8rem; }
.progress { height: 3px; background: rgba(239, 68, 68, 0.2); border-radius: 2px; overflow: hidden; }
.bar {
height: 100%;
background: var(--danger);
animation: shrink 10s linear forwards;
}
@keyframes shrink { from { width: 100%; } to { width: 0; } }
@media (max-width: 500px) {
.actions { gap: 4px; }
.action { min-width: 32px; height: 32px; font-size: 0.85rem; }
}
</style>