music: 加 LLM chat、笔记 tab 化、歌单/标签
deploy cube / build-and-deploy (push) Successful in 1m8s
deploy music / build-and-deploy (push) Successful in 2m15s
deploy simpleasm / build-and-deploy (push) Successful in 1m25s

chat(右边栏):
- chat_messages 表 per piece,OpenAI 兼容 /v1/chat/completions stream:true
- backend SSE forward delta,结束时落库 user + assistant
- system prompt 注入曲目 (title/artist/category/notes/lyrics 截 4KB)
- 网关同 mochi/config.yaml: gemma-4-31b-it on 3.135.65.204:8848,token 走 k8s Secret chat-creds
- reqwest client 去掉全局 timeout(chat 流可能跑很久),chord sidecar 调用改 per-request timeout

笔记: 从右 sidebar 移到独立 tab "笔记"

歌单 + tag:
- playlists / playlist_pieces / tags / piece_tags 表,CRUD API
- PATCH piece 接 tags 数组(按名字 upsert)
- list pieces 加 ?tag/?playlist 过滤 + 返回 tags 列表
- 顶 bar filterbar:歌单 + 标签 chip 切换;"+ 新歌单" prompt 创建
- EditView 加 tag 编辑(chip + 自动补全)+ 加入/移除歌单
This commit is contained in:
Fam Zheng
2026-05-10 14:51:53 +01:00
parent 9623e298b7
commit c0d6e37325
8 changed files with 1480 additions and 85 deletions
+162
View File
@@ -41,6 +41,26 @@
<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 ? '保存中' : '保存基本信息' }}
@@ -49,6 +69,32 @@
</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">
@@ -119,6 +165,11 @@ import {
deletePiece,
uploadAttachments,
deleteAttachment,
listTags,
listPlaylists,
playlistAddPiece,
playlistRemovePiece,
getPlaylist,
} from '../lib/api.js'
const props = defineProps({ id: { type: Number, required: true } })
@@ -138,6 +189,14 @@ const form = reactive({
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)
@@ -156,6 +215,20 @@ async function load() {
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 {
@@ -163,6 +236,39 @@ async function load() {
}
}
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 {
@@ -172,6 +278,7 @@ async function saveMeta() {
category: form.category.trim() || null,
lyrics: form.lyrics || null,
notes: form.notes || null,
tags: tags.value,
})
savedFlash.value = true
setTimeout(() => (savedFlash.value = false), 1500)
@@ -393,4 +500,59 @@ onMounted(load)
.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>