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
+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>