karaoke(app): port single-device playlist from partiverse + tests
点歌单本地管理 — 添加/上移/下移/置顶/删除 + 10 秒撤销倒计时 + YouTube 一键 搜,无 room / 无 ws。删掉了 partiverse 那套 yopu 和弦抓取 / LLM 聊天点歌 / QR 码(依赖后端,对单机无意义)。logic 全 immutable,21 个 vitest 覆盖 边界(首位上移 noop / 末位下移 noop / 缺失 id / 不变性)。
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user