563 lines
16 KiB
Vue
563 lines
16 KiB
Vue
<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>
|
||
<label class="field">
|
||
<span>标签</span>
|
||
<div class="tag-row">
|
||
<span v-for="t in tags" :key="t" class="tag-chip">
|
||
#{{ t }}
|
||
<button class="tag-x" @click="removeTag(t)">×</button>
|
||
</span>
|
||
<input
|
||
v-model="tagInput"
|
||
@keydown.enter.prevent="addTag"
|
||
@blur="addTag"
|
||
list="tag-suggest"
|
||
class="tag-input"
|
||
placeholder="输入 + 回车(流行/钢琴/吉他/卡拉OK/英文…)"
|
||
/>
|
||
<datalist id="tag-suggest">
|
||
<option v-for="t in allTags" :key="t.id" :value="t.name" />
|
||
</datalist>
|
||
</div>
|
||
</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 v-if="memberPlaylists.length === 0" class="hint-sub">这首歌还没在任何歌单里。</p>
|
||
<div v-else class="pl-row">
|
||
<span
|
||
v-for="plId in memberPlaylists"
|
||
:key="plId"
|
||
class="tag-chip"
|
||
>
|
||
{{ (playlists.find(x => x.id === plId) || {}).name || ('#' + plId) }}
|
||
<button class="tag-x" @click="leavePlaylist(plId)">×</button>
|
||
</span>
|
||
</div>
|
||
<div class="join-row">
|
||
<select v-model="addToPlaylistId">
|
||
<option value="">— 选歌单 —</option>
|
||
<option
|
||
v-for="pl in playlists.filter(p => !memberPlaylists.includes(p.id))"
|
||
:key="pl.id"
|
||
:value="pl.id"
|
||
>{{ pl.name }} ({{ pl.count }})</option>
|
||
</select>
|
||
<button class="btn-ghost" :disabled="!addToPlaylistId" @click="joinPlaylist">加入</button>
|
||
</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">和弦谱(数字级数 1/4/5)</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,
|
||
listTags,
|
||
listPlaylists,
|
||
playlistAddPiece,
|
||
playlistRemovePiece,
|
||
getPlaylist,
|
||
} 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 tags = ref([]) // 当前 piece 的 tags 数组
|
||
const allTags = ref([]) // 全局所有 tags(用于 datalist 提示)
|
||
const tagInput = ref('')
|
||
|
||
const playlists = ref([]) // 全部 playlists
|
||
const memberPlaylists = ref([]) // 当前 piece 在哪些 playlists 里(id 集合)
|
||
const addToPlaylistId = ref('')
|
||
|
||
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 || ''
|
||
tags.value = [...(p.tags || [])]
|
||
// 全局 tag 列表 + 我所属的 playlists
|
||
const [allT, allPl] = await Promise.all([listTags(), listPlaylists()])
|
||
allTags.value = allT || []
|
||
playlists.value = allPl || []
|
||
// 反查所属:每个 playlist 拿 detail,看 pieces 包含 props.id 否
|
||
const memberships = await Promise.all(
|
||
(allPl || []).map(pl =>
|
||
getPlaylist(pl.id)
|
||
.then(d => (d.pieces || []).some(x => x.id === props.id) ? pl.id : null)
|
||
.catch(() => null)
|
||
)
|
||
)
|
||
memberPlaylists.value = memberships.filter(x => x != null)
|
||
} catch (e) {
|
||
loadErr.value = e.message || String(e)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function addTag() {
|
||
const v = tagInput.value.trim()
|
||
if (!v) return
|
||
if (!tags.value.includes(v)) tags.value.push(v)
|
||
tagInput.value = ''
|
||
}
|
||
|
||
function removeTag(t) {
|
||
tags.value = tags.value.filter(x => x !== t)
|
||
}
|
||
|
||
async function joinPlaylist() {
|
||
const pid = parseInt(addToPlaylistId.value)
|
||
if (!pid) return
|
||
try {
|
||
await playlistAddPiece(pid, props.id)
|
||
addToPlaylistId.value = ''
|
||
await load()
|
||
} catch (e) {
|
||
alert(e.message || String(e))
|
||
}
|
||
}
|
||
|
||
async function leavePlaylist(plId) {
|
||
if (!confirm('从这个歌单移除?')) return
|
||
try {
|
||
await playlistRemovePiece(plId, props.id)
|
||
await load()
|
||
} catch (e) {
|
||
alert(e.message || String(e))
|
||
}
|
||
}
|
||
|
||
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,
|
||
tags: tags.value,
|
||
})
|
||
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; }
|
||
|
||
.tag-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
align-items: center;
|
||
background: var(--bg-elev);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 6px 8px;
|
||
}
|
||
.tag-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
background: rgba(192, 132, 252, 0.12);
|
||
color: var(--accent);
|
||
border-radius: 12px;
|
||
padding: 2px 4px 2px 10px;
|
||
font-size: 12px;
|
||
}
|
||
.tag-x {
|
||
background: none;
|
||
color: var(--accent);
|
||
font-size: 14px;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.tag-x:hover { background: rgba(192, 132, 252, 0.2); }
|
||
.tag-input {
|
||
flex: 1;
|
||
min-width: 200px;
|
||
background: transparent;
|
||
border: none;
|
||
font-size: 13px;
|
||
color: var(--text);
|
||
padding: 4px 6px;
|
||
}
|
||
.tag-input:focus { outline: none; }
|
||
|
||
.pl-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||
.join-row { display: flex; gap: 8px; align-items: center; }
|
||
.join-row select {
|
||
background: var(--bg-elev);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 6px 10px;
|
||
font-size: 13px;
|
||
color: var(--text);
|
||
min-width: 220px;
|
||
}
|
||
</style>
|