Files
cube/apps/music/frontend/src/views/EditView.vue
T
Fam Zheng 5674be1cfd
deploy music / build-and-deploy (push) Successful in 1m54s
music(ui): 简化只留「和弦谱」一个抓取 tab,简谱/字母版废弃
2026-05-10 21:32:49 +01:00

563 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>