music: 加 LLM chat、笔记 tab 化、歌单/标签
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user