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,89 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
add: [{ singer: string; title: string }]
|
||||
}>()
|
||||
|
||||
const singer = ref('')
|
||||
const title = ref('')
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(s) => {
|
||||
if (s) {
|
||||
singer.value = ''
|
||||
title.value = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const canAdd = () => singer.value.trim() !== '' && title.value.trim() !== ''
|
||||
|
||||
function submit() {
|
||||
if (!canAdd()) return
|
||||
emit('add', { singer: singer.value.trim(), title: title.value.trim() })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="show" class="overlay" @click.self="$emit('close')">
|
||||
<div class="modal">
|
||||
<header>
|
||||
<h2>添加歌曲</h2>
|
||||
<button class="x" @click="$emit('close')">✕</button>
|
||||
</header>
|
||||
<section>
|
||||
<label>歌手</label>
|
||||
<input v-model="singer" type="text" placeholder="周杰伦" @keydown.enter="submit" />
|
||||
</section>
|
||||
<section>
|
||||
<label>歌名</label>
|
||||
<input v-model="title" type="text" placeholder="七里香" @keydown.enter="submit" />
|
||||
</section>
|
||||
<footer>
|
||||
<button class="cancel" @click="$emit('close')">取消</button>
|
||||
<button class="ok" :disabled="!canAdd()" @click="submit">添加</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1500; padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: #232336;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
width: 100%; max-width: 480px;
|
||||
padding: 16px;
|
||||
}
|
||||
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
h2 { margin: 0; font-size: 1.3rem; }
|
||||
section { margin-bottom: 16px; }
|
||||
label { display: block; margin-bottom: 6px; color: var(--fg-dim); font-weight: 500; font-size: 0.9rem; }
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-soft);
|
||||
color: var(--fg);
|
||||
font-size: 1rem;
|
||||
}
|
||||
input:focus { outline: 2px solid var(--accent); }
|
||||
footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||
button.x { width: 32px; height: 32px; border-radius: 6px; background: transparent; color: var(--fg-dim); border: 1px solid var(--border); }
|
||||
button.cancel { background: var(--bg-soft); border: 1px solid var(--border); color: var(--fg); padding: 10px 18px; border-radius: 8px; }
|
||||
button.ok { background: var(--accent); border: none; color: white; padding: 10px 18px; border-radius: 8px; font-weight: bold; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user