music: 新建 music app,替换 piano-sheet
听歌 + 练琴曲目管理: - 数据: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:
@@ -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>
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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) {
|
||||
// 小随机 hash,sort 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]歌词第一行 [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>
|
||||
Reference in New Issue
Block a user