music: 新建 music app,替换 piano-sheet
deploy cube / build-and-deploy (push) Successful in 1m10s
deploy music / build-and-deploy (push) Successful in 1m47s
deploy simpleasm / build-and-deploy (push) Successful in 1m20s

听歌 + 练琴曲目管理:
- 数据:piece (title/artist/category/lyrics/play_count/notes) + attachments (audio/video/pdf/image; image 带 role=chord/numbered/staff)
- 后端 axum + sqlite,附件流式落 PVC,ServeFile 支持 Range(视频拖动)
- 前端 guitar 风格 player:左 sidebar + tabs(歌词/吉他谱/简谱/五线谱/PDF/视频),LRC 同步、快捷键、笔记自动保存
- ns cube-music + music.famzheng.me + bodylimit 5GiB
- scripts/import_guitar.py 用于把 oci /data/guitar/ 旧曲库导入
This commit is contained in:
Fam Zheng
2026-05-09 22:36:14 +01:00
parent 58f344db85
commit 1a8f297302
30 changed files with 2683 additions and 1314 deletions
+65
View File
@@ -0,0 +1,65 @@
<template>
<router-view />
</template>
<style>
:root {
--bg: #0f0f0f;
--bg-elev: #161616;
--bg-card: #1a1a2e;
--bg-hover: #232342;
--bg-active: #2a1a3e;
--border: #2a2a3a;
--border-soft: #1f1f2a;
--text: #e0e0e0;
--text-dim: #a0a0a0;
--text-mute: #666;
--accent: #c084fc;
--accent-strong: #7c5cbf;
--accent-cyan: #06b6d4;
--accent-green: #4ade80;
--accent-amber: #f59e0b;
--accent-red: #ef4444;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
--radius: 8px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #app { height: 100%; }
body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
overscroll-behavior: none;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
button {
font-family: var(--font-sans);
cursor: pointer;
border: none;
background: none;
color: inherit;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
input, textarea {
font-family: var(--font-sans);
background: transparent;
border: none;
color: inherit;
outline: none;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-mute); }
</style>
+59
View File
@@ -0,0 +1,59 @@
// 薄薄一层 fetch 封装。错误统一抛 Error(message)。
async function jsonOrThrow(res) {
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `${res.status} ${res.statusText}`)
}
return res.json()
}
export function listPieces() {
return fetch('/api/pieces').then(jsonOrThrow)
}
export function getPiece(id) {
return fetch(`/api/pieces/${id}`).then(jsonOrThrow)
}
export function createPiece(body) {
return fetch('/api/pieces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(jsonOrThrow)
}
export function patchPiece(id, body) {
return fetch(`/api/pieces/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(jsonOrThrow)
}
export function deletePiece(id) {
return fetch(`/api/pieces/${id}`, { method: 'DELETE' }).then(jsonOrThrow)
}
export function recordPlay(id) {
return fetch(`/api/pieces/${id}/play`, { method: 'POST' }).then(jsonOrThrow)
}
// `role`: null | 'chord' | 'numbered' | 'staff'
export function uploadAttachments(pieceId, files, role) {
const fd = new FormData()
for (const f of files) fd.append('files', f, f.name)
const url = role
? `/api/pieces/${pieceId}/attachments?role=${encodeURIComponent(role)}`
: `/api/pieces/${pieceId}/attachments`
return fetch(url, { method: 'POST', body: fd }).then(jsonOrThrow)
}
export function deleteAttachment(id) {
return fetch(`/api/attachments/${id}`, { method: 'DELETE' }).then(jsonOrThrow)
}
export function attachmentUrl(id) {
return `/api/attachments/${id}`
}
+27
View File
@@ -0,0 +1,27 @@
// LRC parser: takes raw text → [{ time: seconds, text }] sorted by time.
export function parseLrc(text) {
if (!text) return []
const out = []
for (const raw of text.split(/\r?\n/)) {
// 一行可能有多个 time tag[00:12.34][00:30.00]歌词
const tags = []
let rest = raw
const re = /^\[(\d+):(\d+)(?:[.:](\d+))?\]/
while (true) {
const m = re.exec(rest)
if (!m) break
const min = parseInt(m[1], 10)
const sec = parseInt(m[2], 10)
const fracRaw = m[3] || '0'
const frac = parseInt(fracRaw, 10) / Math.pow(10, fracRaw.length)
tags.push(min * 60 + sec + frac)
rest = rest.slice(m[0].length)
}
const txt = rest.trim()
if (!txt || tags.length === 0) continue
for (const t of tags) out.push({ time: t, text: txt })
}
out.sort((a, b) => a.time - b.time)
return out
}
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router/index.js'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+31
View File
@@ -0,0 +1,31 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'player',
component: () => import('../views/PlayerView.vue'),
},
{
path: '/piece/:id',
name: 'piece',
component: () => import('../views/PlayerView.vue'),
props: (route) => ({ id: Number(route.params.id) }),
},
{
path: '/upload',
name: 'upload',
component: () => import('../views/UploadView.vue'),
},
{
path: '/piece/:id/edit',
name: 'edit',
component: () => import('../views/EditView.vue'),
props: (route) => ({ id: Number(route.params.id) }),
},
]
export default createRouter({
history: createWebHistory(),
routes,
})
+396
View File
@@ -0,0 +1,396 @@
<template>
<div class="page">
<header class="bar">
<router-link to="/" class="back"> 列表</router-link>
<router-link :to="{ name: 'piece', params: { id } }" class="back"> 播放</router-link>
<h1 v-if="piece">编辑{{ piece.title }}</h1>
<h1 v-else>编辑曲目</h1>
</header>
<main class="body">
<p v-if="loading" class="hint">加载中</p>
<p v-else-if="loadErr" class="err">{{ loadErr }}</p>
<template v-else-if="piece">
<section class="block">
<h2>基本信息</h2>
<label class="field">
<span>标题</span>
<input v-model="form.title" />
</label>
<label class="field">
<span>歌手 / 作者</span>
<input v-model="form.artist" />
</label>
<label class="field">
<span>分类</span>
<input v-model="form.category" list="cat-list" />
<datalist id="cat-list">
<option value="钢琴曲" />
<option value="流行" />
<option value="练习曲" />
<option value="古典" />
<option value="爵士" />
</datalist>
</label>
<label class="field">
<span>歌词</span>
<textarea v-model="form.lyrics" rows="8" />
</label>
<label class="field">
<span>笔记</span>
<textarea v-model="form.notes" rows="3" />
</label>
<div class="actions">
<button class="btn-primary" :disabled="savingMeta" @click="saveMeta">
{{ savingMeta ? '保存中' : '保存基本信息' }}
</button>
<span v-if="savedFlash" class="flash">已保存</span>
</div>
</section>
<section class="block">
<h2>添加附件</h2>
<p class="hint-sub">
一次可选多个文件video / audio / pdf / image 自动识别
图片需要选 <b>角色</b>吉他谱 / 简谱 / 五线谱其它类型角色无效
</p>
<div class="upload-row">
<label class="role-pick">
<span>角色</span>
<select v-model="uploadRole">
<option :value="null"> 自动 / 通用图 </option>
<option value="chord">吉他谱</option>
<option value="numbered">简谱</option>
<option value="staff">五线谱</option>
</select>
</label>
<label class="file-pick">
<input
ref="fileInputEl"
type="file"
multiple
@change="onFiles"
accept="audio/*,video/*,application/pdf,image/*"
/>
<span class="btn-ghost">{{ pendingFiles.length ? `已选 ${pendingFiles.length}` : '选择文件' }}</span>
</label>
<button
class="btn-primary"
:disabled="!pendingFiles.length || uploading"
@click="upload"
>
{{ uploading ? `上传中… ${uploadPct}%` : '上传' }}
</button>
</div>
<p v-if="uploadErr" class="err">{{ uploadErr }}</p>
</section>
<section class="block">
<h2>已有附件 ({{ piece.attachments.length }})</h2>
<p v-if="!piece.attachments.length" class="hint-sub">还没有附件</p>
<ul class="atts">
<li v-for="att in piece.attachments" :key="att.id">
<div class="att-info">
<span class="att-kind" :class="att.kind">{{ kindLabel(att.kind) }}{{ att.role ? '·' + roleLabel(att.role) : '' }}</span>
<a :href="`/api/attachments/${att.id}`" target="_blank">{{ att.filename }}</a>
<span class="att-size">{{ fmtSize(att.size_bytes) }}</span>
</div>
<button class="btn-danger" @click="removeAtt(att.id)">删除</button>
</li>
</ul>
</section>
<section class="block danger-block">
<h2>危险操作</h2>
<button class="btn-danger" @click="removePiece">删除整首曲目</button>
</section>
</template>
</main>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
getPiece,
patchPiece,
deletePiece,
uploadAttachments,
deleteAttachment,
} from '../lib/api.js'
const props = defineProps({ id: { type: Number, required: true } })
const router = useRouter()
const piece = ref(null)
const loading = ref(true)
const loadErr = ref('')
const form = reactive({
title: '',
artist: '',
category: '',
lyrics: '',
notes: '',
})
const savingMeta = ref(false)
const savedFlash = ref(false)
const fileInputEl = ref(null)
const pendingFiles = ref([])
const uploadRole = ref(null)
const uploading = ref(false)
const uploadPct = ref(0)
const uploadErr = ref('')
async function load() {
loading.value = true
loadErr.value = ''
try {
const p = await getPiece(props.id)
piece.value = p
form.title = p.title || ''
form.artist = p.artist || ''
form.category = p.category || ''
form.lyrics = p.lyrics || ''
form.notes = p.notes || ''
} catch (e) {
loadErr.value = e.message || String(e)
} finally {
loading.value = false
}
}
async function saveMeta() {
savingMeta.value = true
try {
await patchPiece(props.id, {
title: form.title.trim() || piece.value.title,
artist: form.artist.trim() || null,
category: form.category.trim() || null,
lyrics: form.lyrics || null,
notes: form.notes || null,
})
savedFlash.value = true
setTimeout(() => (savedFlash.value = false), 1500)
await load()
} catch (e) {
alert(e.message || String(e))
} finally {
savingMeta.value = false
}
}
function onFiles(e) {
pendingFiles.value = Array.from(e.target.files || [])
}
async function upload() {
if (!pendingFiles.value.length) return
uploading.value = true
uploadErr.value = ''
uploadPct.value = 0
try {
// 简单 fetch(无进度),如需进度可改 XHR
await uploadAttachments(props.id, pendingFiles.value, uploadRole.value || null)
pendingFiles.value = []
if (fileInputEl.value) fileInputEl.value.value = ''
await load()
} catch (e) {
uploadErr.value = e.message || String(e)
} finally {
uploading.value = false
}
}
async function removeAtt(id) {
if (!confirm('删除这个附件?')) return
try {
await deleteAttachment(id)
await load()
} catch (e) {
alert(e.message || String(e))
}
}
async function removePiece() {
if (!confirm(`确认删除 "${piece.value.title}" 及其全部附件?此操作不可逆。`)) return
try {
await deletePiece(props.id)
router.push({ name: 'player' })
} catch (e) {
alert(e.message || String(e))
}
}
function kindLabel(k) {
return ({ audio: '音频', video: '视频', pdf: 'PDF', image: '图片' })[k] || k
}
function roleLabel(r) {
return ({ chord: '吉他谱', numbered: '简谱', staff: '五线谱' })[r] || r
}
function fmtSize(b) {
if (b < 1024) return b + ' B'
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'
if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB'
return (b / 1024 / 1024 / 1024).toFixed(2) + ' GB'
}
onMounted(load)
</script>
<style scoped>
.page { min-height: 100%; display: flex; flex-direction: column; }
.bar {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 22px;
background: var(--bg-card);
border-bottom: 1px solid var(--border-soft);
}
.bar h1 { font-size: 18px; font-weight: 600; flex: 1; }
.back { color: var(--text-dim); font-size: 14px; }
.back:hover { color: var(--accent); text-decoration: none; }
.body { max-width: 760px; margin: 0 auto; padding: 24px 22px 100px; width: 100%; }
.hint { color: var(--text-mute); padding: 40px 0; text-align: center; }
.err { color: var(--accent-red); background: rgba(239,68,68,0.1); padding: 10px 12px; border-radius: 6px; margin-top: 8px; }
.block {
background: var(--bg-card);
border-radius: 10px;
padding: 18px 20px;
margin-bottom: 18px;
}
.block h2 {
font-size: 14px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 14px;
font-weight: 600;
}
.hint-sub { color: var(--text-mute); font-size: 13px; margin-bottom: 12px; line-height: 1.5; }
.hint-sub b { color: var(--text-dim); }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.field span {
font-size: 11px;
color: var(--text-mute);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.field input, .field textarea, .field select {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 14px;
color: var(--text);
}
.field input:focus, .field textarea:focus, .field select:focus { border-color: var(--accent-strong); outline: none; }
.field textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
resize: vertical;
line-height: 1.5;
}
.actions { display: flex; align-items: center; gap: 14px; margin-top: 4px; }
.flash { color: var(--accent-green); font-size: 12px; }
.btn-primary {
background: var(--accent-strong);
color: #fff;
border-radius: 6px;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
}
.btn-primary:hover:not(:disabled) { background: var(--accent); }
.upload-row {
display: flex;
gap: 12px;
align-items: end;
flex-wrap: wrap;
}
.role-pick { display: flex; flex-direction: column; gap: 6px; }
.role-pick span { font-size: 11px; color: var(--text-mute); text-transform: uppercase; }
.role-pick select {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
color: var(--text);
cursor: pointer;
}
.file-pick { position: relative; display: inline-block; }
.file-pick input[type=file] {
position: absolute;
opacity: 0;
inset: 0;
cursor: pointer;
}
.btn-ghost {
display: inline-block;
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.btn-ghost:hover { background: var(--bg-hover); }
.atts { list-style: none; display: flex; flex-direction: column; gap: 6px; }
.atts li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--bg-elev);
border-radius: 6px;
gap: 12px;
}
.att-info { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
.att-kind {
font-size: 11px;
padding: 2px 8px;
border-radius: 8px;
background: rgba(124, 92, 191, 0.15);
color: var(--accent);
white-space: nowrap;
flex-shrink: 0;
}
.att-kind.video { background: rgba(6, 182, 212, 0.15); color: var(--accent-cyan); }
.att-kind.pdf { background: rgba(245, 158, 11, 0.15); color: var(--accent-amber); }
.att-info a {
color: var(--text);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.att-info a:hover { color: var(--accent); }
.att-size { color: var(--text-mute); font-size: 11px; flex-shrink: 0; }
.btn-danger {
background: rgba(239, 68, 68, 0.15);
color: var(--accent-red);
border-radius: 6px;
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
}
.btn-danger:hover { background: rgba(239, 68, 68, 0.3); }
.danger-block { background: rgba(239, 68, 68, 0.05); }
.danger-block .btn-danger { padding: 8px 18px; font-size: 13px; }
</style>
@@ -0,0 +1,919 @@
<template>
<div class="root">
<header class="topbar">
<h1>🎵 Music</h1>
<input
class="search"
type="text"
v-model="search"
placeholder="搜索曲目 / 歌手"
/>
<span class="count">{{ filtered.length }} / {{ pieces.length }} </span>
<router-link to="/upload" class="btn-add" title="新增曲目"></router-link>
</header>
<div class="main">
<aside class="sidebar" :class="{ 'has-selected': !!selected }">
<div class="sort-bar">
<button :class="{ active: sortMode === 'name' }" @click="setSort('name')">名称</button>
<button :class="{ active: sortMode === 'hot' }" @click="setSort('hot')">最多播放</button>
<button :class="{ active: sortMode === 'least' }" @click="setSort('least')">最少播放</button>
<button :class="{ active: sortMode === 'recent' }" @click="setSort('recent')">最近</button>
<button :class="{ active: sortMode === 'random' }" @click="setSort('random')">随机</button>
</div>
<div class="playlist">
<p v-if="loading" class="hint">加载中</p>
<p v-else-if="loadError" class="hint err">{{ loadError }}</p>
<p v-else-if="filtered.length === 0" class="hint">
空空如也<router-link to="/upload">先加一首</router-link>
</p>
<div
v-for="p in filtered"
:key="p.id"
class="row"
:class="{ active: selectedId === p.id }"
@click="selectPiece(p.id)"
>
<div class="row-main">
<div class="row-title">{{ p.title }}</div>
<div class="row-meta">
<span v-if="p.artist">{{ p.artist }}</span>
<span v-if="p.category" class="cat">{{ p.category }}</span>
</div>
</div>
<div class="badges">
<span v-if="p.has_lyrics" class="badge" title="有歌词"></span>
<span v-for="k in iconKinds(p.kinds)" :key="k" class="badge" :title="k">{{ kindLabel(k) }}</span>
<span v-if="p.play_count > 0" class="play-count">{{ p.play_count }}</span>
</div>
</div>
</div>
</aside>
<section class="player-area">
<div v-if="!selected" class="empty">
<p>从左边挑一首吧 🎶</p>
</div>
<template v-else>
<header class="now-playing">
<h2>{{ selected.title }}</h2>
<div class="np-sub">
<span v-if="selected.artist">{{ selected.artist }}</span>
<span v-if="selected.category">· {{ selected.category }}</span>
<span v-if="selected.play_count">· 播放 {{ selected.play_count }} </span>
<router-link :to="{ name: 'edit', params: { id: selected.id } }" class="edit-link">编辑</router-link>
</div>
</header>
<nav v-if="tabs.length" class="tabs">
<button
v-for="t in tabs"
:key="t.key"
:class="{ active: activeTab === t.key }"
@click="setTab(t.key)"
>{{ t.label }}<span v-if="t.count > 1" class="tab-n">{{ t.count }}</span></button>
</nav>
<main class="content">
<!-- 歌词 -->
<div v-show="activeTab === 'lyrics'" class="lyrics-box" ref="lyricsBoxEl">
<div v-if="lyricsLines.length === 0" class="lyrics-none">
<span v-if="selected.lyrics">这首歌的歌词不是 LRC 格式</span>
<span v-else>暂无歌词用心感受 🎶</span>
<pre v-if="selected.lyrics" class="lyrics-raw">{{ selected.lyrics }}</pre>
</div>
<div
v-for="(line, i) in lyricsLines"
:key="i"
class="lyrics-line"
:class="{ active: i === activeLyricIdx }"
:data-i="i"
@click="seek(line.time)"
>{{ line.text }}</div>
</div>
<!-- 谱面chord / numbered / staff -->
<div v-show="['chord', 'numbered', 'staff'].includes(activeTab)" class="sheet-box">
<img
v-for="att in roleAttachments(activeTab)"
:key="att.id"
:src="attachmentUrl(att.id)"
:alt="att.filename"
class="sheet-img"
/>
</div>
<!-- PDF -->
<div v-show="activeTab === 'pdf'" class="pdf-box">
<iframe
v-for="att in pdfAttachments"
:key="att.id"
:src="attachmentUrl(att.id)"
:title="att.filename"
class="pdf-frame"
/>
</div>
<!-- 视频 -->
<div v-show="activeTab === 'video'" class="video-box">
<video
v-for="att in videoAttachments"
:key="att.id"
:src="attachmentUrl(att.id)"
controls
class="video-el"
/>
</div>
</main>
<footer class="controls">
<div class="ctrl-row">
<label class="repeat" :class="{ on: repeatOne }" @click="repeatOne = !repeatOne">
<span>循环</span>
<span class="track"><span class="thumb"></span></span>
</label>
<button @click="prev" class="btn-icon" title="上一首"></button>
<button @click="togglePlay" class="btn-icon big" :title="playing ? '暂停' : '播放'">
{{ playing ? '⏸' : '▶' }}
</button>
<button @click="next" class="btn-icon" title="下一首"></button>
<span class="time">{{ fmtTime(currentTime) }}</span>
<div class="bar" @click="seekBar">
<div class="fill" :style="{ width: progressPct + '%' }"></div>
</div>
<span class="time">{{ fmtTime(duration) }}</span>
</div>
</footer>
<audio
ref="audioEl"
@timeupdate="onTimeUpdate"
@loadedmetadata="onLoaded"
@ended="onEnded"
@play="playing = true"
@pause="playing = false"
/>
</template>
</section>
<aside v-if="selected" class="notes" :class="{ active: notesOpen }">
<header @click="notesOpen = !notesOpen">
<span>笔记</span>
<span v-if="notesSavedFlash" class="saved">已保存</span>
</header>
<textarea
v-model="notesDraft"
@input="onNotesInput"
placeholder="练琴心得 / chord 备注 / 难点…"
/>
</aside>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
listPieces,
getPiece,
patchPiece,
recordPlay,
attachmentUrl as attUrl,
} from '../lib/api.js'
import { parseLrc } from '../lib/lrc.js'
const route = useRoute()
const router = useRouter()
const pieces = ref([])
const loading = ref(true)
const loadError = ref('')
const selected = ref(null)
const selectedId = ref(null)
const search = ref('')
const sortMode = ref(localStorage.getItem('music.sort') || 'name')
const repeatOne = ref(false)
const audioEl = ref(null)
const lyricsBoxEl = ref(null)
const playing = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const activeTab = ref('lyrics')
const notesOpen = ref(false)
const notesDraft = ref('')
const notesSavedFlash = ref(false)
let notesTimer = null
let randomSeed = Math.random()
let lastReportedId = null
const lyricsLines = computed(() => parseLrc(selected.value?.lyrics || ''))
const activeLyricIdx = computed(() => {
const lines = lyricsLines.value
if (!lines.length) return -1
let idx = -1
for (let i = lines.length - 1; i >= 0; i--) {
if (currentTime.value >= lines[i].time) { idx = i; break }
}
return idx
})
const audioAttachments = computed(() =>
(selected.value?.attachments || []).filter(a => a.kind === 'audio'))
const videoAttachments = computed(() =>
(selected.value?.attachments || []).filter(a => a.kind === 'video'))
const pdfAttachments = computed(() =>
(selected.value?.attachments || []).filter(a => a.kind === 'pdf'))
function roleAttachments(role) {
return (selected.value?.attachments || []).filter(
a => a.kind === 'image' && a.role === role,
)
}
const tabs = computed(() => {
if (!selected.value) return []
const list = []
if (selected.value.lyrics) list.push({ key: 'lyrics', label: '歌词', count: 0 })
const chord = roleAttachments('chord').length
if (chord) list.push({ key: 'chord', label: '吉他谱', count: chord })
const num = roleAttachments('numbered').length
if (num) list.push({ key: 'numbered', label: '简谱', count: num })
const staff = roleAttachments('staff').length
if (staff) list.push({ key: 'staff', label: '五线谱', count: staff })
if (pdfAttachments.value.length) list.push({ key: 'pdf', label: '乐谱 PDF', count: pdfAttachments.value.length })
if (videoAttachments.value.length) list.push({ key: 'video', label: '视频', count: videoAttachments.value.length })
// 没歌词也至少给一个 fallback tab
if (list.length === 0) list.push({ key: 'lyrics', label: '歌词', count: 0 })
return list
})
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
let arr = pieces.value
if (q) {
arr = arr.filter(p => {
const hay = `${p.title} ${p.artist || ''} ${p.category || ''}`.toLowerCase()
return hay.includes(q)
})
}
arr = [...arr]
switch (sortMode.value) {
case 'hot':
arr.sort((a, b) => b.play_count - a.play_count || a.title.localeCompare(b.title, 'zh'))
break
case 'least':
arr.sort((a, b) => a.play_count - b.play_count || a.title.localeCompare(b.title, 'zh'))
break
case 'recent':
arr.sort((a, b) => {
const ta = a.last_played_at || ''
const tb = b.last_played_at || ''
return tb.localeCompare(ta) || a.title.localeCompare(b.title, 'zh')
})
break
case 'random': {
// stable random per session
const seeded = arr.map((p, i) => ({ p, k: hash(p.id, randomSeed) }))
seeded.sort((a, b) => a.k - b.k)
arr = seeded.map(x => x.p)
break
}
default:
arr.sort((a, b) => a.title.localeCompare(b.title, 'zh'))
}
return arr
})
function hash(id, seed) {
// 小随机 hashsort key 稳定
let x = (id ^ Math.floor(seed * 1e9)) >>> 0
x = (x ^ (x << 13)) >>> 0
x = (x ^ (x >>> 17)) >>> 0
x = (x ^ (x << 5)) >>> 0
return x
}
function setSort(mode) {
if (mode === 'random' && sortMode.value === 'random') {
randomSeed = Math.random()
}
sortMode.value = mode
localStorage.setItem('music.sort', mode)
}
function iconKinds(kinds) {
// 显示主要 kind 徽章;'image' / 'audio' / 'video' / 'pdf'
const order = ['audio', 'video', 'pdf', 'image']
return order.filter(k => kinds.includes(k))
}
function kindLabel(k) {
return ({ audio: '音', video: '视', pdf: 'PDF', image: '谱' })[k] || k
}
async function loadPieces() {
loading.value = true
loadError.value = ''
try {
pieces.value = await listPieces()
} catch (e) {
loadError.value = e.message || String(e)
} finally {
loading.value = false
}
}
async function loadPiece(id) {
selected.value = null
notesDraft.value = ''
if (!id) return
try {
const p = await getPiece(id)
selected.value = p
notesDraft.value = p.notes || ''
selectedId.value = p.id
// 默认 tab:有歌词进 lyrics,否则进第一个 tab
const t = tabs.value
if (!t.find(x => x.key === activeTab.value)) {
activeTab.value = t[0]?.key || 'lyrics'
}
// 自动开播放(如果有 audio)
await nextTick()
const first = audioAttachments.value[0]
if (first && audioEl.value) {
audioEl.value.src = attUrl(first.id)
audioEl.value.play().catch(() => {})
} else if (audioEl.value) {
audioEl.value.removeAttribute('src')
audioEl.value.load()
}
lastReportedId = null
} catch (e) {
loadError.value = e.message || String(e)
}
}
function selectPiece(id) {
router.push({ name: 'piece', params: { id } })
}
function attachmentUrl(id) { return attUrl(id) }
// player controls
function togglePlay() {
if (!audioEl.value || !audioEl.value.src) return
if (audioEl.value.paused) audioEl.value.play()
else audioEl.value.pause()
}
function next() {
if (!filtered.value.length) return
const idx = filtered.value.findIndex(p => p.id === selectedId.value)
const nextIdx = (idx + 1) % filtered.value.length
selectPiece(filtered.value[nextIdx].id)
}
function prev() {
if (!filtered.value.length) return
const idx = filtered.value.findIndex(p => p.id === selectedId.value)
const prevIdx = (idx - 1 + filtered.value.length) % filtered.value.length
selectPiece(filtered.value[prevIdx].id)
}
function seek(t) {
if (!audioEl.value) return
audioEl.value.currentTime = t
if (audioEl.value.paused) audioEl.value.play().catch(() => {})
}
function seekBar(e) {
if (!audioEl.value || !duration.value) return
const rect = e.currentTarget.getBoundingClientRect()
const ratio = (e.clientX - rect.left) / rect.width
audioEl.value.currentTime = Math.max(0, Math.min(1, ratio)) * duration.value
}
function onTimeUpdate(e) {
currentTime.value = e.target.currentTime
// 上报播放(≥10s 时)
if (selectedId.value && lastReportedId !== selectedId.value && currentTime.value >= 10) {
lastReportedId = selectedId.value
recordPlay(selectedId.value).then(d => {
// 同步本地 + list
if (selected.value) selected.value.play_count = d.play_count
const inList = pieces.value.find(p => p.id === selectedId.value)
if (inList) {
inList.play_count = d.play_count
inList.last_played_at = new Date().toISOString().replace('T', ' ').slice(0, 19)
}
}).catch(() => {})
}
// 自动滚歌词
if (activeTab.value === 'lyrics' && lyricsBoxEl.value) {
const idx = activeLyricIdx.value
if (idx >= 0) {
const el = lyricsBoxEl.value.querySelector(`.lyrics-line[data-i="${idx}"]`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
// 持久化最近播放进度
if (selectedId.value) {
localStorage.setItem('music.last', JSON.stringify({
id: selectedId.value,
time: currentTime.value,
}))
}
}
function onLoaded(e) {
duration.value = e.target.duration || 0
}
function onEnded() {
if (repeatOne.value && audioEl.value) {
audioEl.value.currentTime = 0
audioEl.value.play().catch(() => {})
} else {
next()
}
}
const progressPct = computed(() => {
if (!duration.value) return 0
return Math.max(0, Math.min(100, (currentTime.value / duration.value) * 100))
})
function fmtTime(s) {
if (!s || isNaN(s)) return '0:00'
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return m + ':' + (sec < 10 ? '0' : '') + sec
}
function setTab(k) {
activeTab.value = k
}
// notes auto-save
function onNotesInput() {
if (!selectedId.value) return
if (notesTimer) clearTimeout(notesTimer)
notesTimer = setTimeout(async () => {
try {
await patchPiece(selectedId.value, { notes: notesDraft.value || null })
notesSavedFlash.value = true
setTimeout(() => (notesSavedFlash.value = false), 1500)
} catch {}
}, 600)
}
// keyboard
function onKeyDown(e) {
const tag = (e.target.tagName || '').toLowerCase()
if (tag === 'input' || tag === 'textarea') return
if (e.code === 'Space') { e.preventDefault(); togglePlay() }
else if (e.code === 'ArrowRight') {
if (audioEl.value) audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, duration.value)
}
else if (e.code === 'ArrowLeft') {
if (audioEl.value) audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0)
}
else if (e.key === 'Tab') {
e.preventDefault()
const idx = tabs.value.findIndex(t => t.key === activeTab.value)
const nx = tabs.value[(idx + 1) % tabs.value.length]
if (nx) activeTab.value = nx.key
}
}
// route → selected
watch(() => route.params.id, async (idStr) => {
const id = idStr ? Number(idStr) : null
if (id !== selectedId.value) {
selectedId.value = id
if (id) await loadPiece(id)
else selected.value = null
}
}, { immediate: false })
onMounted(async () => {
document.addEventListener('keydown', onKeyDown)
await loadPieces()
const id = route.params.id ? Number(route.params.id) : null
if (id) {
selectedId.value = id
await loadPiece(id)
} else {
// 无路由 id:恢复 last
try {
const last = JSON.parse(localStorage.getItem('music.last') || 'null')
if (last && pieces.value.find(p => p.id === last.id)) {
router.replace({ name: 'piece', params: { id: last.id } })
}
} catch {}
}
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
if (notesTimer) clearTimeout(notesTimer)
})
</script>
<style scoped>
.root {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg);
}
.topbar {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 20px;
background: var(--bg-card);
border-bottom: 1px solid var(--border-soft);
flex-shrink: 0;
}
.topbar h1 { font-size: 18px; font-weight: 600; white-space: nowrap; }
.topbar .search {
flex: 1;
max-width: 380px;
padding: 8px 14px;
border-radius: 20px;
border: 1px solid var(--border);
background: var(--bg);
font-size: 14px;
}
.topbar .search:focus { border-color: var(--accent-strong); }
.topbar .count { color: var(--text-mute); font-size: 12px; white-space: nowrap; }
.topbar .btn-add {
width: 36px; height: 36px;
border-radius: 50%;
background: var(--accent-strong);
color: #fff;
font-size: 22px; font-weight: 600;
display: inline-flex; align-items: center; justify-content: center;
text-decoration: none;
transition: background 0.15s;
}
.topbar .btn-add:hover { background: var(--accent); text-decoration: none; }
.main {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
.sidebar {
width: 340px;
min-width: 280px;
border-right: 1px solid var(--border-soft);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sort-bar {
display: flex;
padding: 6px 8px;
gap: 0;
border-bottom: 1px solid var(--border-soft);
flex-wrap: wrap;
}
.sort-bar button {
font-size: 11px;
padding: 5px 10px;
color: var(--text-mute);
border: 1px solid var(--border);
background: var(--bg-elev);
border-right-width: 0;
transition: all 0.15s;
}
.sort-bar button:first-child { border-radius: 4px 0 0 4px; }
.sort-bar button:last-child { border-radius: 0 4px 4px 0; border-right-width: 1px; }
.sort-bar button:hover { color: var(--text); }
.sort-bar button.active {
background: var(--bg-active);
border-color: var(--accent-strong);
color: var(--accent);
}
.playlist { flex: 1; overflow-y: auto; }
.hint { padding: 40px 20px; text-align: center; color: var(--text-mute); font-size: 14px; }
.hint.err { color: var(--accent-red); }
.row {
display: flex;
padding: 10px 14px;
border-bottom: 1px solid var(--border-soft);
cursor: pointer;
align-items: center;
gap: 10px;
}
.row:hover { background: var(--bg-card); }
.row.active { background: var(--bg-active); }
.row.active .row-title { color: var(--accent); }
.row-main { flex: 1; min-width: 0; }
.row-title {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-meta {
font-size: 11px;
color: var(--text-mute);
margin-top: 2px;
display: flex;
gap: 6px;
}
.row-meta .cat {
color: var(--accent-cyan);
background: rgba(6, 182, 212, 0.1);
padding: 0 6px;
border-radius: 8px;
}
.badges {
display: flex;
gap: 4px;
align-items: center;
flex-shrink: 0;
}
.badge {
font-size: 10px;
color: var(--accent-strong);
background: rgba(124, 92, 191, 0.12);
padding: 1px 6px;
border-radius: 8px;
}
.play-count {
font-size: 10px;
color: var(--text-mute);
}
.player-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-mute);
font-size: 16px;
}
.now-playing {
padding: 18px 24px 8px;
text-align: center;
flex-shrink: 0;
}
.now-playing h2 {
font-size: 22px;
color: var(--accent);
margin-bottom: 4px;
}
.np-sub {
color: var(--text-dim);
font-size: 13px;
display: flex;
gap: 6px;
justify-content: center;
flex-wrap: wrap;
}
.edit-link {
margin-left: 6px;
color: var(--text-mute);
font-size: 12px;
}
.edit-link:hover { color: var(--accent); }
.tabs {
display: flex;
gap: 0;
padding: 0 24px;
border-bottom: 1px solid var(--border-soft);
flex-shrink: 0;
overflow-x: auto;
}
.tabs button {
background: none;
color: var(--text-mute);
border-bottom: 2px solid transparent;
padding: 10px 16px;
font-size: 14px;
white-space: nowrap;
transition: all 0.2s;
}
.tabs button:hover { color: var(--text-dim); }
.tabs button.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tabs .tab-n {
font-size: 10px;
color: var(--text-mute);
margin-left: 4px;
}
.content {
flex: 1;
overflow-y: auto;
padding: 12px 24px 80px;
min-height: 0;
}
.lyrics-box .lyrics-line {
padding: 8px 0;
font-size: 16px;
text-align: center;
color: var(--text-mute);
line-height: 1.6;
cursor: pointer;
transition: color 0.3s, font-size 0.3s;
}
.lyrics-box .lyrics-line.active {
color: var(--text);
font-size: 19px;
font-weight: 500;
}
.lyrics-none {
text-align: center;
color: var(--text-mute);
margin-top: 60px;
font-size: 14px;
}
.lyrics-raw {
margin-top: 20px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
text-align: left;
white-space: pre-wrap;
word-break: break-all;
font-size: 12px;
background: var(--bg-elev);
padding: 12px;
border-radius: 6px;
}
.sheet-box {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
}
.sheet-img {
max-width: 100%;
border-radius: 8px;
background: #fff;
}
.pdf-box { display: flex; flex-direction: column; gap: 16px; }
.pdf-frame {
width: 100%;
height: 90vh;
border: none;
background: #fff;
border-radius: 6px;
}
.video-box { display: flex; flex-direction: column; gap: 16px; align-items: center; }
.video-el {
max-width: 100%;
width: 100%;
max-height: 60vh;
background: #000;
border-radius: 8px;
}
.controls {
flex-shrink: 0;
background: var(--bg-card);
padding: 12px 20px;
border-top: 1px solid var(--border);
}
.ctrl-row {
display: flex;
align-items: center;
gap: 12px;
max-width: 900px;
margin: 0 auto;
}
.btn-icon {
font-size: 22px;
color: var(--text-dim);
width: 40px;
height: 40px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
}
.btn-icon:hover { background: rgba(255,255,255,0.06); color: var(--text); }
.btn-icon.big {
font-size: 26px;
background: var(--accent-strong);
color: #fff;
}
.btn-icon.big:hover { background: var(--accent); color: #fff; }
.repeat {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
color: var(--text-mute);
font-size: 11px;
}
.repeat .track {
width: 30px; height: 16px;
border-radius: 8px;
background: var(--border);
position: relative;
transition: background 0.2s;
}
.repeat .thumb {
position: absolute;
top: 2px; left: 2px;
width: 12px; height: 12px;
border-radius: 50%;
background: var(--text-mute);
transition: transform 0.2s, background 0.2s;
}
.repeat.on .track { background: var(--accent-strong); }
.repeat.on .thumb { transform: translateX(14px); background: #fff; }
.bar {
flex: 1;
height: 4px;
background: var(--border);
border-radius: 2px;
cursor: pointer;
position: relative;
}
.fill {
height: 100%;
background: var(--accent-strong);
border-radius: 2px;
}
.time {
font-size: 12px;
color: var(--text-dim);
min-width: 40px;
text-align: center;
}
.notes {
width: 260px;
border-left: 1px solid var(--border-soft);
background: var(--bg-elev);
display: none;
flex-direction: column;
flex-shrink: 0;
}
.notes header {
padding: 12px 16px;
font-size: 12px;
color: var(--text-mute);
border-bottom: 1px solid var(--border-soft);
display: flex;
justify-content: space-between;
cursor: pointer;
}
.notes header .saved { color: var(--accent-green); font-size: 10px; }
.notes textarea {
flex: 1;
padding: 12px 14px;
font-size: 13px;
line-height: 1.6;
resize: none;
color: var(--text-dim);
}
.notes textarea::placeholder { color: var(--text-mute); }
@media (min-width: 1200px) {
.notes { display: flex; }
}
@media (max-width: 768px) {
.main { flex-direction: column; }
.sidebar { width: 100%; height: 38vh; min-height: 200px; border-right: none; border-bottom: 1px solid var(--border-soft); }
.sidebar.has-selected { height: 30vh; }
}
</style>
@@ -0,0 +1,157 @@
<template>
<div class="page">
<header class="bar">
<router-link to="/" class="back"> 返回</router-link>
<h1>新增曲目</h1>
</header>
<main class="form">
<label class="field">
<span>标题<i>*</i></span>
<input v-model="title" placeholder="例:月光奏鸣曲 / Yesterday" />
</label>
<label class="field">
<span>歌手 / 作者</span>
<input v-model="artist" placeholder="例:Beatles / Beethoven" />
</label>
<label class="field">
<span>分类</span>
<input v-model="category" placeholder="例:流行 / 钢琴曲 / 练习曲" list="cat-list" />
<datalist id="cat-list">
<option value="钢琴曲" />
<option value="流行" />
<option value="练习曲" />
<option value="古典" />
<option value="爵士" />
</datalist>
</label>
<label class="field">
<span>歌词LRC 格式最佳</span>
<textarea v-model="lyrics" rows="6" placeholder="[00:12.34]歌词第一行&#10;[00:18.50]歌词第二行" />
</label>
<label class="field">
<span>笔记</span>
<textarea v-model="notes" rows="3" placeholder="练琴心得、曲谱说明…" />
</label>
<p v-if="error" class="err">{{ error }}</p>
<div class="actions">
<button class="btn-primary" :disabled="saving || !title.trim()" @click="submit">
{{ saving ? '创建中' : '创建' }}
</button>
</div>
<p class="tip">创建后进入编辑页给曲目挂 mp3 / 视频 / PDF / 谱子图片</p>
</main>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { createPiece } from '../lib/api.js'
const router = useRouter()
const title = ref('')
const artist = ref('')
const category = ref('')
const lyrics = ref('')
const notes = ref('')
const saving = ref(false)
const error = ref('')
async function submit() {
if (!title.value.trim()) return
saving.value = true
error.value = ''
try {
const r = await createPiece({
title: title.value.trim(),
artist: artist.value.trim() || null,
category: category.value.trim() || null,
lyrics: lyrics.value || null,
notes: notes.value || null,
})
router.push({ name: 'edit', params: { id: r.id } })
} catch (e) {
error.value = e.message || String(e)
} finally {
saving.value = false
}
}
</script>
<style scoped>
.page {
min-height: 100%;
display: flex;
flex-direction: column;
}
.bar {
display: flex;
align-items: center;
gap: 18px;
padding: 14px 22px;
background: var(--bg-card);
border-bottom: 1px solid var(--border-soft);
}
.bar h1 { font-size: 18px; font-weight: 600; }
.back { color: var(--text-dim); font-size: 14px; }
.back:hover { color: var(--accent); text-decoration: none; }
.form {
max-width: 640px;
margin: 0 auto;
padding: 24px 22px 80px;
width: 100%;
display: flex;
flex-direction: column;
gap: 18px;
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field span {
font-size: 12px;
color: var(--text-dim);
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.field span i { color: var(--accent-red); margin-left: 2px; font-style: normal; }
.field input, .field textarea {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
font-size: 14px;
color: var(--text);
width: 100%;
}
.field input:focus, .field textarea:focus { border-color: var(--accent-strong); }
.field textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
resize: vertical;
line-height: 1.5;
}
.err {
background: rgba(239, 68, 68, 0.1);
color: var(--accent-red);
padding: 10px 12px;
border-radius: 6px;
font-size: 13px;
}
.actions { display: flex; gap: 12px; }
.btn-primary {
background: var(--accent-strong);
color: #fff;
border-radius: 6px;
padding: 10px 22px;
font-size: 14px;
font-weight: 600;
}
.btn-primary:hover:not(:disabled) { background: var(--accent); }
.btn-primary:active { transform: scale(0.98); }
.tip { font-size: 12px; color: var(--text-mute); }
</style>