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:
@@ -12,6 +12,38 @@
|
||||
<router-link to="/upload" class="btn-add" title="新增曲目">+</router-link>
|
||||
</header>
|
||||
|
||||
<nav class="filterbar">
|
||||
<span class="fb-label">歌单</span>
|
||||
<button
|
||||
class="chip"
|
||||
:class="{ active: !activePlaylistId }"
|
||||
@click="setPlaylist(null)"
|
||||
>全部</button>
|
||||
<button
|
||||
v-for="pl in playlists"
|
||||
:key="pl.id"
|
||||
class="chip"
|
||||
:class="{ active: activePlaylistId === pl.id }"
|
||||
@click="setPlaylist(pl.id)"
|
||||
>{{ pl.name }}<span class="chip-n">{{ pl.count }}</span></button>
|
||||
<button class="chip new-chip" @click="promptNewPlaylist">+ 新歌单</button>
|
||||
|
||||
<span class="fb-sep">·</span>
|
||||
<span class="fb-label">标签</span>
|
||||
<button
|
||||
class="chip"
|
||||
:class="{ active: !activeTagName }"
|
||||
@click="setTag(null)"
|
||||
>全部</button>
|
||||
<button
|
||||
v-for="t in tagsList"
|
||||
:key="t.id"
|
||||
class="chip"
|
||||
:class="{ active: activeTagName === t.name }"
|
||||
@click="setTag(t.name)"
|
||||
>{{ t.name }}<span class="chip-n">{{ t.count }}</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="main">
|
||||
<aside class="sidebar" :class="{ 'has-selected': !!selected }">
|
||||
<div class="sort-bar">
|
||||
@@ -26,7 +58,7 @@
|
||||
<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>。
|
||||
空空如也<span v-if="activePlaylistId || activeTagName">(当前筛选下)</span>。
|
||||
</p>
|
||||
<div
|
||||
v-for="p in filtered"
|
||||
@@ -40,6 +72,7 @@
|
||||
<div class="row-meta">
|
||||
<span v-if="p.artist">{{ p.artist }}</span>
|
||||
<span v-if="p.category" class="cat">{{ p.category }}</span>
|
||||
<span v-for="tg in p.tags" :key="tg" class="tg">#{{ tg }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="badges">
|
||||
@@ -65,6 +98,9 @@
|
||||
<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>
|
||||
<div v-if="selected.tags && selected.tags.length" class="np-tags">
|
||||
<span v-for="tg in selected.tags" :key="tg" class="tg">#{{ tg }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav v-if="tabs.length" class="tabs">
|
||||
@@ -94,7 +130,7 @@
|
||||
>{{ 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)"
|
||||
@@ -103,7 +139,6 @@
|
||||
:alt="att.filename"
|
||||
class="sheet-img"
|
||||
/>
|
||||
<!-- 吉他谱专属:没图时给个自动抓取按钮 -->
|
||||
<div
|
||||
v-if="activeTab === 'chord' && roleAttachments('chord').length === 0"
|
||||
class="auto-fetch"
|
||||
@@ -149,6 +184,20 @@
|
||||
class="video-el"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 笔记(独立 tab) -->
|
||||
<div v-show="activeTab === 'notes'" class="notes-box">
|
||||
<div class="notes-head">
|
||||
<span>练琴心得 / 难点 / 备注</span>
|
||||
<span v-if="notesSavedFlash" class="saved">已保存</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="notesDraft"
|
||||
@input="onNotesInput"
|
||||
class="notes-area"
|
||||
placeholder="自动保存。任何想法都丢这里…"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="controls">
|
||||
@@ -181,16 +230,47 @@
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<aside v-if="selected" class="notes" :class="{ active: notesOpen }">
|
||||
<header @click="notesOpen = !notesOpen">
|
||||
<span>笔记</span>
|
||||
<span v-if="notesSavedFlash" class="saved">已保存</span>
|
||||
<!-- 右侧 LLM chat 边栏 -->
|
||||
<aside v-if="selected" class="chat">
|
||||
<header class="chat-head">
|
||||
<span>麻薯 · {{ selected.title.slice(0, 12) }}</span>
|
||||
<button class="chat-clear" @click="onChatClear" title="清空对话">清空</button>
|
||||
</header>
|
||||
<textarea
|
||||
v-model="notesDraft"
|
||||
@input="onNotesInput"
|
||||
placeholder="练琴心得 / chord 备注 / 难点…"
|
||||
/>
|
||||
<div ref="chatBodyEl" class="chat-body">
|
||||
<p v-if="chatLoading" class="chat-empty">载入对话…</p>
|
||||
<p v-else-if="chatMessages.length === 0 && !chatStreaming" class="chat-empty">
|
||||
随便聊点啥,比如:<br>
|
||||
「这首歌的副歌为啥用 6m 起?」<br>
|
||||
「我吉他扫弦节奏总不稳怎么办?」
|
||||
</p>
|
||||
<div
|
||||
v-for="(m, i) in chatMessages"
|
||||
:key="i"
|
||||
class="msg"
|
||||
:class="m.role"
|
||||
>
|
||||
<div class="msg-bubble">{{ m.content }}</div>
|
||||
</div>
|
||||
<div v-if="chatStreaming || chatStreamText" class="msg assistant">
|
||||
<div class="msg-bubble">{{ chatStreamText || '…' }}</div>
|
||||
</div>
|
||||
<p v-if="chatError" class="chat-err">{{ chatError }}</p>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<textarea
|
||||
v-model="chatDraft"
|
||||
@keydown.enter.exact.prevent="onChatSend"
|
||||
@keydown.enter.shift.exact="$event => null"
|
||||
:disabled="chatStreaming"
|
||||
placeholder="Enter 发送 · Shift+Enter 换行"
|
||||
rows="2"
|
||||
/>
|
||||
<button
|
||||
class="chat-send"
|
||||
:disabled="chatStreaming || !chatDraft.trim()"
|
||||
@click="onChatSend"
|
||||
>{{ chatStreaming ? '⏳' : '↑' }}</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,12 +281,18 @@ import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
listPieces,
|
||||
listPlaylists,
|
||||
listTags,
|
||||
createPlaylist,
|
||||
getPiece,
|
||||
patchPiece,
|
||||
recordPlay,
|
||||
attachmentUrl as attUrl,
|
||||
chordFetch,
|
||||
chordStatus,
|
||||
listChat,
|
||||
clearChat,
|
||||
streamChat,
|
||||
} from '../lib/api.js'
|
||||
import { parseLrc } from '../lib/lrc.js'
|
||||
|
||||
@@ -219,6 +305,11 @@ const loadError = ref('')
|
||||
const selected = ref(null)
|
||||
const selectedId = ref(null)
|
||||
|
||||
const playlists = ref([])
|
||||
const tagsList = ref([])
|
||||
const activePlaylistId = ref(null)
|
||||
const activeTagName = ref(null)
|
||||
|
||||
const search = ref('')
|
||||
const sortMode = ref(localStorage.getItem('music.sort') || 'name')
|
||||
const repeatOne = ref(false)
|
||||
@@ -229,19 +320,28 @@ 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
|
||||
|
||||
// chord auto-fetch state
|
||||
const chordState = ref('idle') // idle | pending | processing | completed | failed
|
||||
// chord
|
||||
const chordState = ref('idle')
|
||||
const chordError = ref('')
|
||||
let chordPollTimer = null
|
||||
let chordPollStarted = 0
|
||||
|
||||
// chat
|
||||
const chatBodyEl = ref(null)
|
||||
const chatMessages = ref([])
|
||||
const chatLoading = ref(false)
|
||||
const chatDraft = ref('')
|
||||
const chatStreaming = ref(false)
|
||||
const chatStreamText = ref('')
|
||||
const chatError = ref('')
|
||||
let chatAbort = null
|
||||
|
||||
const lyricsLines = computed(() => parseLrc(selected.value?.lyrics || ''))
|
||||
|
||||
const activeLyricIdx = computed(() => {
|
||||
@@ -271,7 +371,6 @@ const tabs = computed(() => {
|
||||
if (!selected.value) return []
|
||||
const list = []
|
||||
if (selected.value.lyrics) list.push({ key: 'lyrics', label: '歌词', count: 0 })
|
||||
// 吉他谱 tab 永远给(没图时显示自动抓取按钮)
|
||||
list.push({ key: 'chord', label: '吉他谱', count: roleAttachments('chord').length })
|
||||
const num = roleAttachments('numbered').length
|
||||
if (num) list.push({ key: 'numbered', label: '简谱', count: num })
|
||||
@@ -279,6 +378,7 @@ const tabs = computed(() => {
|
||||
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 })
|
||||
list.push({ key: 'notes', label: '笔记', count: 0 })
|
||||
return list
|
||||
})
|
||||
|
||||
@@ -287,7 +387,7 @@ const filtered = computed(() => {
|
||||
let arr = pieces.value
|
||||
if (q) {
|
||||
arr = arr.filter(p => {
|
||||
const hay = `${p.title} ${p.artist || ''} ${p.category || ''}`.toLowerCase()
|
||||
const hay = `${p.title} ${p.artist || ''} ${p.category || ''} ${(p.tags || []).join(' ')}`.toLowerCase()
|
||||
return hay.includes(q)
|
||||
})
|
||||
}
|
||||
@@ -307,8 +407,7 @@ const filtered = computed(() => {
|
||||
})
|
||||
break
|
||||
case 'random': {
|
||||
// stable random per session
|
||||
const seeded = arr.map((p, i) => ({ p, k: hash(p.id, randomSeed) }))
|
||||
const seeded = arr.map(p => ({ p, k: hash(p.id, randomSeed) }))
|
||||
seeded.sort((a, b) => a.k - b.k)
|
||||
arr = seeded.map(x => x.p)
|
||||
break
|
||||
@@ -320,7 +419,6 @@ const filtered = computed(() => {
|
||||
})
|
||||
|
||||
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
|
||||
@@ -337,7 +435,6 @@ function setSort(mode) {
|
||||
}
|
||||
|
||||
function iconKinds(kinds) {
|
||||
// 显示主要 kind 徽章;'image' / 'audio' / 'video' / 'pdf'
|
||||
const order = ['audio', 'video', 'pdf', 'image']
|
||||
return order.filter(k => kinds.includes(k))
|
||||
}
|
||||
@@ -350,7 +447,10 @@ async function loadPieces() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
pieces.value = await listPieces()
|
||||
pieces.value = await listPieces({
|
||||
tag: activeTagName.value,
|
||||
playlist: activePlaylistId.value,
|
||||
})
|
||||
} catch (e) {
|
||||
loadError.value = e.message || String(e)
|
||||
} finally {
|
||||
@@ -358,25 +458,56 @@ async function loadPieces() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlaylists() {
|
||||
try { playlists.value = await listPlaylists() } catch {}
|
||||
}
|
||||
async function loadTags() {
|
||||
try { tagsList.value = await listTags() } catch {}
|
||||
}
|
||||
|
||||
async function setPlaylist(id) {
|
||||
activePlaylistId.value = id
|
||||
if (id) activeTagName.value = null
|
||||
await loadPieces()
|
||||
}
|
||||
async function setTag(name) {
|
||||
activeTagName.value = name
|
||||
if (name) activePlaylistId.value = null
|
||||
await loadPieces()
|
||||
}
|
||||
|
||||
async function promptNewPlaylist() {
|
||||
const name = prompt('新歌单名(如:我喜欢的 / 儿子在练)')
|
||||
if (!name || !name.trim()) return
|
||||
try {
|
||||
const r = await createPlaylist(name.trim())
|
||||
await loadPlaylists()
|
||||
setPlaylist(r.id)
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPiece(id) {
|
||||
selected.value = null
|
||||
notesDraft.value = ''
|
||||
// 切歌时清空 chord state(避免 polling 漂到新曲目)
|
||||
stopChordPoll()
|
||||
chordState.value = 'idle'
|
||||
chordError.value = ''
|
||||
abortChat()
|
||||
chatMessages.value = []
|
||||
chatStreamText.value = ''
|
||||
chatError.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) {
|
||||
@@ -387,18 +518,31 @@ async function loadPiece(id) {
|
||||
audioEl.value.load()
|
||||
}
|
||||
lastReportedId = null
|
||||
// 加载 chat 历史
|
||||
loadChat(id)
|
||||
} catch (e) {
|
||||
loadError.value = e.message || String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChat(id) {
|
||||
chatLoading.value = true
|
||||
try {
|
||||
const arr = await listChat(id)
|
||||
if (selectedId.value === id) chatMessages.value = arr || []
|
||||
} catch (e) {
|
||||
chatError.value = e.message || String(e)
|
||||
} finally {
|
||||
chatLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -434,11 +578,9 @@ function seekBar(e) {
|
||||
|
||||
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) {
|
||||
@@ -447,7 +589,6 @@ function onTimeUpdate(e) {
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
// 自动滚歌词
|
||||
if (activeTab.value === 'lyrics' && lyricsBoxEl.value) {
|
||||
const idx = activeLyricIdx.value
|
||||
if (idx >= 0) {
|
||||
@@ -455,7 +596,6 @@ function onTimeUpdate(e) {
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}
|
||||
// 持久化最近播放进度
|
||||
if (selectedId.value) {
|
||||
localStorage.setItem('music.last', JSON.stringify({
|
||||
id: selectedId.value,
|
||||
@@ -493,6 +633,20 @@ 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)
|
||||
}
|
||||
|
||||
// chord
|
||||
async function startChordFetch() {
|
||||
if (!selectedId.value) return
|
||||
chordState.value = 'pending'
|
||||
@@ -500,7 +654,6 @@ async function startChordFetch() {
|
||||
try {
|
||||
const r = await chordFetch(selectedId.value)
|
||||
if (r.status === 'completed') {
|
||||
// 已经有谱(或刚 import):刷新 piece
|
||||
await reloadPiece()
|
||||
chordState.value = 'completed'
|
||||
return
|
||||
@@ -517,11 +670,10 @@ async function startChordFetch() {
|
||||
|
||||
async function pollChord() {
|
||||
if (!selectedId.value) { stopChordPoll(); return }
|
||||
// 90s 超时保护
|
||||
if (Date.now() - chordPollStarted > 90_000) {
|
||||
stopChordPoll()
|
||||
chordState.value = 'failed'
|
||||
chordError.value = '抓取超时(>90s),可能 yopu 限流或 selector 失效'
|
||||
chordError.value = '抓取超时'
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -535,7 +687,6 @@ async function pollChord() {
|
||||
stopChordPoll()
|
||||
}
|
||||
} catch (e) {
|
||||
// 暂时性错误就不立即放弃,下一轮再试
|
||||
chordError.value = e.message || String(e)
|
||||
}
|
||||
}
|
||||
@@ -551,22 +702,77 @@ async function reloadPiece() {
|
||||
if (!selectedId.value) return
|
||||
try {
|
||||
const fresh = await getPiece(selectedId.value)
|
||||
// 保留正在播的 audio.src 不动
|
||||
selected.value = fresh
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// chat
|
||||
async function onChatSend() {
|
||||
const msg = chatDraft.value.trim()
|
||||
if (!msg || chatStreaming.value || !selectedId.value) return
|
||||
// 乐观追加 user msg(assistant 流完整结束后会从 db 读最终 +1)
|
||||
chatMessages.value = [
|
||||
...chatMessages.value,
|
||||
{ role: 'user', content: msg, id: Date.now() },
|
||||
]
|
||||
chatDraft.value = ''
|
||||
chatStreamText.value = ''
|
||||
chatError.value = ''
|
||||
chatStreaming.value = true
|
||||
await nextTick(); scrollChatBottom()
|
||||
const ctrl = new AbortController()
|
||||
chatAbort = ctrl
|
||||
const pidAtStart = selectedId.value
|
||||
try {
|
||||
const result = await streamChat(pidAtStart, msg, (delta) => {
|
||||
if (selectedId.value !== pidAtStart) return
|
||||
chatStreamText.value += delta
|
||||
scrollChatBottom()
|
||||
}, ctrl.signal)
|
||||
if (selectedId.value !== pidAtStart) return
|
||||
if (result.ok) {
|
||||
chatMessages.value = [
|
||||
...chatMessages.value,
|
||||
{ role: 'assistant', content: chatStreamText.value, id: Date.now() + 1 },
|
||||
]
|
||||
} else {
|
||||
chatError.value = result.error || '出错'
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') chatError.value = e.message || String(e)
|
||||
} finally {
|
||||
chatStreamText.value = ''
|
||||
chatStreaming.value = false
|
||||
chatAbort = null
|
||||
await nextTick(); scrollChatBottom()
|
||||
}
|
||||
}
|
||||
|
||||
async function onChatClear() {
|
||||
if (!selectedId.value || !confirm('清空这首歌的对话历史?')) return
|
||||
abortChat()
|
||||
try {
|
||||
await clearChat(selectedId.value)
|
||||
chatMessages.value = []
|
||||
chatStreamText.value = ''
|
||||
chatError.value = ''
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
function abortChat() {
|
||||
if (chatAbort) {
|
||||
try { chatAbort.abort() } catch {}
|
||||
chatAbort = null
|
||||
}
|
||||
chatStreaming.value = false
|
||||
chatStreamText.value = ''
|
||||
}
|
||||
|
||||
function scrollChatBottom() {
|
||||
const el = chatBodyEl.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
// keyboard
|
||||
@@ -600,13 +806,12 @@ watch(() => route.params.id, async (idStr) => {
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
await loadPieces()
|
||||
await Promise.all([loadPieces(), loadPlaylists(), loadTags()])
|
||||
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)) {
|
||||
@@ -620,6 +825,7 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
if (notesTimer) clearTimeout(notesTimer)
|
||||
stopChordPoll()
|
||||
abortChat()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -660,10 +866,55 @@ onBeforeUnmount(() => {
|
||||
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; }
|
||||
|
||||
.filterbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 20px;
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.fb-label {
|
||||
color: var(--text-mute);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 10px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.fb-sep { color: var(--text-mute); margin: 0 4px; }
|
||||
.chip {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.chip:hover { color: var(--text); }
|
||||
.chip.active {
|
||||
background: var(--bg-active);
|
||||
border-color: var(--accent-strong);
|
||||
color: var(--accent);
|
||||
}
|
||||
.chip-n {
|
||||
font-size: 10px;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
.new-chip {
|
||||
border-style: dashed;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -672,8 +923,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 340px;
|
||||
min-width: 280px;
|
||||
width: 320px;
|
||||
min-width: 260px;
|
||||
border-right: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -694,7 +945,6 @@ onBeforeUnmount(() => {
|
||||
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; }
|
||||
@@ -734,6 +984,7 @@ onBeforeUnmount(() => {
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.row-meta .cat {
|
||||
color: var(--accent-cyan);
|
||||
@@ -741,6 +992,14 @@ onBeforeUnmount(() => {
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.tg {
|
||||
color: var(--accent);
|
||||
background: rgba(192, 132, 252, 0.1);
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.np-tags { display: flex; gap: 6px; justify-content: center; margin-top: 4px; flex-wrap: wrap; }
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
@@ -778,7 +1037,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.now-playing {
|
||||
padding: 18px 24px 8px;
|
||||
padding: 16px 24px 6px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -903,10 +1162,8 @@ onBeforeUnmount(() => {
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s, transform 0.05s;
|
||||
}
|
||||
.btn-fetch:hover:not(:disabled) { background: var(--accent); }
|
||||
.btn-fetch:active:not(:disabled) { transform: scale(0.97); }
|
||||
.btn-fetch .spin { display: inline-block; animation: spin-anim 1.5s linear infinite; }
|
||||
@keyframes spin-anim { to { transform: rotate(360deg); } }
|
||||
|
||||
@@ -928,6 +1185,35 @@ onBeforeUnmount(() => {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.notes-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
.notes-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-mute);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.notes-head .saved { color: var(--accent-green); }
|
||||
.notes-area {
|
||||
flex: 1;
|
||||
min-height: 240px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
.notes-area:focus { border-color: var(--accent-strong); }
|
||||
|
||||
.controls {
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-card);
|
||||
@@ -950,7 +1236,6 @@ onBeforeUnmount(() => {
|
||||
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 {
|
||||
@@ -1006,36 +1291,113 @@ onBeforeUnmount(() => {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notes {
|
||||
width: 260px;
|
||||
/* Chat sidebar */
|
||||
.chat {
|
||||
width: 320px;
|
||||
border-left: 1px solid var(--border-soft);
|
||||
background: var(--bg-elev);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notes header {
|
||||
padding: 12px 16px;
|
||||
@media (min-width: 1280px) {
|
||||
.chat { display: flex; }
|
||||
}
|
||||
.chat-head {
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-mute);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
}
|
||||
.notes header .saved { color: var(--accent-green); font-size: 10px; }
|
||||
.notes textarea {
|
||||
.chat-clear {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--accent-red);
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.chat-clear:hover { background: rgba(239, 68, 68, 0.2); }
|
||||
|
||||
.chat-body {
|
||||
flex: 1;
|
||||
padding: 12px 14px;
|
||||
overflow-y: auto;
|
||||
padding: 12px 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.chat-empty {
|
||||
color: var(--text-mute);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
padding: 30px 8px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.chat-err {
|
||||
color: var(--accent-red);
|
||||
font-size: 12px;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.msg {
|
||||
display: flex;
|
||||
}
|
||||
.msg.user { justify-content: flex-end; }
|
||||
.msg.assistant { justify-content: flex-start; }
|
||||
.msg-bubble {
|
||||
max-width: 84%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.msg.user .msg-bubble {
|
||||
background: var(--accent-strong);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
.msg.assistant .msg-bubble {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
border-top: 1px solid var(--border-soft);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
.chat-input textarea {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
color: var(--text-dim);
|
||||
color: var(--text);
|
||||
}
|
||||
.notes textarea::placeholder { color: var(--text-mute); }
|
||||
@media (min-width: 1200px) {
|
||||
.notes { display: flex; }
|
||||
.chat-input textarea:focus { border-color: var(--accent-strong); outline: none; }
|
||||
.chat-send {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-strong);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-send:hover:not(:disabled) { background: var(--accent); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main { flex-direction: column; }
|
||||
|
||||
Reference in New Issue
Block a user