Files
Thing/MUSIC_MODULE_REVIEW.md
2026-09-12 15:47:16 +08:00

466 lines
47 KiB
Markdown
Raw Permalink 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.
# 音乐模块全链路审查报告
> 审查范围:数据请求(搜索/歌单解析/下载/解析真实链接)、播放控制(试听、播放器内核、队列、歌词、缓存)、状态管理(musicStore / feiniuStore)、UI 交互与视觉。
> 覆盖文件(前端 11 个 + Rust 4 个):
> `src/stores/musicStore.ts`、`src/stores/feiniuStore.ts`、`src/modules/music/MusicModule.vue`、`components/PlayerBar.vue`、`components/PlayerFull.vue`、`components/MyMusicLibrary.vue`、`components/TrackItem.vue`、`components/settings/ConnectionsSettings.vue`、`components/settings/CacheSettings.vue`、`SourcePicker.vue`、`sources.ts`、`index.ts`、`src/App.vue`、`src/components/layout/ModuleContainer.vue`、`src-tauri/src/music/{mod,commands}.rs`、`src-tauri/src/music/feiniu/{mod,proxy}.rs`
---
## 0. 结论摘要
整体架构是清晰的:**搜索/下载走 Python 桥接(musicdl****播放走飞牛 NAS 本地流代理(Rust axum,支持 Range 透传)**,两个 store 职责分离,流代理与缓存设计是对的。问题集中在三类:
| 类别 | 数量 | 代表问题 |
|---|---|---|
| P0 功能性失效 | 3 | 飞牛曲库搜索框完全无效;重启后播放键无响应;两个音频引擎可同时发声 |
| P1 逻辑/状态缺陷 | 12 | shuffle 自动切歌不随机;缓存开关状态不同步;队列被单曲播放重置;任务持久化写入 `rawSearch` 撑爆 localStorage |
| P2 性能瓶颈 | 9 | 拖动进度条即发 Range 请求;歌词/队列无虚拟滚动;全屏模糊背景;每行 2 个 Tooltip |
| P3 界面与交互缺陷 | 25 | 播放条未贴底且离开模块即消失;左右内边距不对称;三套切换控件;徽标 5 色抢戏;列表变卡片堆 |
**最需要优先决策的一件事**:把 `PlayerBar` / `PlayerFull``MusicModule.vue` 提升到 `App.vue` 全局层。当前它们挂载在音乐模块内部,而模块容器是 `:key="activeModule"` 重挂载(`App.vue:36-41`)——**切到任何其他模块,播放条和"正在播放"大屏整体卸载,但音频仍在后台播放,用户完全失去控制入口**。这是当前体验上最严重的问题。
---
## 1. P0 功能性失效(必须修)
### 1.1 飞牛曲库搜索框完全无效
**现象**:输入关键词、回车,曲库列表纹丝不动。
**根因**`MyMusicLibrary.vue:21` 定义了本地 `keywordInput``onSearchInput`:24-29)只做了防抖后调用 `store.loadTracks(1)`,而 `loadTracks` 读的是 **store 里的 `keyword`**`feiniuStore.ts:381`)。检查全仓:`store.keyword` **从未被任何 UI 赋值**
```ts
// feiniuStore.ts:375-382
const data = await invoke('feiniu_list_tracks', {
page: p, size: 50,
keyword: keyword.value.trim() || null // ← keyword 永远是 ''
})
```
**修复**:把输入直接接到 store 字段,去掉中间层。
```vue
<!-- MyMusicLibrary.vue -->
<Input v-model="store.keyword" placeholder="搜索飞牛曲库(歌名 / 歌手)"
@input="onSearchInput" @keydown.enter="onSearchInput" />
```
```ts
// onSearchInput 里补一句:清空关键词时立即回全量
function onSearchInput() {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => store.loadTracks(1).catch(e => toast.error(String(e))), 400)
}
```
---
### 1.2 应用重启后,播放键点了没反应
**现象**:重启应用,底部播放条显示着上次的歌,点播放键没任何反应,也没有提示。
**根因**`restoreQueue()``feiniuStore.ts:644-655`)只恢复了 `queue` / `queueIndex`**没有给 audio 设置 `src`**。此时点击播放走 `toggle()`:564-568):
```ts
function toggle() {
const a = ensureAudio()
if (a.paused) a.play().catch(() => {}) // ← 无 srcplay() 直接 reject,被静默吞掉
else a.pause()
}
```
`ensureAudio()` 新建的 `Audio()` 没有 `src``play()``NotSupportedError`,被空 catch 吞掉。点「上一首/下一首」反而能用(走 `playCurrent()` 会设 src),所以表现为"只有播放键坏了"。
**修复**`toggle()` 检测无 src 时先走一次 `playCurrent()`;同时给失败加可见提示。
```ts
function toggle() {
const a = ensureAudio()
if (!a.src) { // 恢复队列后首次播放
if (current.value) playCurrent()
return
}
if (a.paused) a.play().catch(err => { playing.value = false; logger.error(`播放失败: ${err}`) })
else a.pause()
}
```
另外建议 `startPlayback` 的 catch 里落一条 `toast.error('播放失败:' + err.message)`,当前 `:558-561` 是先 catch 再静默,用户拿不到任何信息。
---
### 1.3 两个独立音频引擎,可同时发声
**现象**:在「发现音乐」点某首歌试听,再回到播放器播另一首 → 两首歌同时响;或者试听中点播放器播放,两路声音叠加。
**根因**:存在两个互不知晓的 `<audio>`
- `MusicModule.vue:1413` —— 试听用的模板 `<audio ref="audioEl">``togglePlay` :372-406 驱动)
- `feiniuStore.ts:109-126` —— 播放器内核 `new Audio()`
二者没有互斥,`playingKey``store.playing` 彼此独立。附带问题:试听音频在**切换 tab / 卸载模块时不会停止**(模板 audio 被移除,引用置空,媒体仍可能继续播到自然结束)。
**修复(推荐)**:废弃模块内试听 audio,试听直接走同一内核——`playQueue([{...}], 0)`source 用 `'preview'`,播完即止。若短期不想动内核,至少在 `togglePlay` 之前 `feiniu.clearPlayback()`,并在 `onBeforeUnmount``audioEl.value?.pause()`
---
## 2. P1 逻辑与状态缺陷
| # | 问题 | 位置 | 说明与改法 |
|---|---|---|---|
| 2.1 | **shuffle 自动切歌不随机** | `feiniuStore.ts:570-586` | `next(manual)` 的随机分支带 `manual` 条件,`onEnded → next(false)` 在 shuffle 下走的是顺序 `++`。用户开了随机,一首播完却是下一首。改:把随机逻辑与 `manual` 解耦,`shuffle` 时无论手动/自动都随机(并记录已播集合避免短期重复)。 |
| 2.2 | **prev 在 shuffle 下也是顺序** | `feiniuStore.ts:598-607` | 同上;随机模式应回退到历史栈。 |
| 2.3 | **切缓存模式/清缓存会中断播放** | `feiniuStore.ts:266-280` | `setCacheMode('stream')``clearCache()` 都调 `clearPlayback()`;而 `CacheSettings.setEnabled(false)` 又会触发 `setCacheMode('stream')`。改设置就把歌停了,属于误伤。应只在必要(如清掉正在播的那首缓存)时停止,或改为"下一首生效"。 |
| 2.4 | **缓存开关与真实模式不同步** | `CacheSettings.vue:13-23,32-35` | `cacheEnabled` 初始恒为 `false``onMounted` 只读了 `cacheMode` 没回填开关;若历史已是 cache 模式,打开设置页看到的是"关",但实际在缓存。另外 `cacheMax = ref(5)` 是死代码(从未使用,后端对应字段是 `feiniu_cache_max_gb`),`cacheStatus` 在 onMounted 也没刷新。 |
| 2.5 | **单曲播放会重置整个队列** | `feiniuStore.ts:500-511` | `playItem` 未命中队列时 `queue.value = [{...item}]`——用户从 2000 首的曲库点播放,队列被替换成 1 首,播完就停。播放器语义应是"以所在列表为上下文播放"。改:`playItem(item, contextList?)`,命中则跳转,未命中则以 `contextList` 建队列。 |
| 2.6 | **歌单内的曲目无法暂停 / 无法整单播放** | `MyMusicLibrary.vue:228-233` | 歌单里的 `TrackItem` 既没有 `@dblclick`,播放按钮又被 `TrackItem.vue:71``:disabled="store.playing && store.current?.guid === item.guid"` 禁用——正在播这首时按钮变灰,**没有任何方式暂停**。且点击播放会走 2.5 的"单曲替换队列"。 |
| 2.7 | **`init()` 被重复调用,会覆盖内存中的队列** | `feiniuStore.ts:129-157` | `MyMusicLibrary.onMounted`:64-69)与 `CacheSettings.onMounted`:16-20)都调 `store.init()``init` 内含 `restoreQueue()`,会在用户切到设置页时用 localStorage 的旧值覆盖当前队列/索引。应加 `inited` 标志位做幂等,或把 `restoreQueue` 只放在应用启动路径。 |
| 2.8 | **任务持久化把 `rawSearch` 一起写进 localStorage** | `musicStore.ts:79-93,228-234,439` | `songsData: songs` 保留了每首歌的 `rawSearch`(上游 API 原始响应)。一个 500 首歌单任务 → 序列化后极易超过 5MB 配额,`persistTasks` 的 catch 会静默失败,历史记录随机丢失。改:持久化前剥离 `rawSearch` / `defaultDownloadHeaders`,只留重下所需字段。 |
| 2.9 | **`pendingUploadOnDone` 泄漏,造成误上传** | `MusicModule.vue:60,74-82,483-500` | 该标志在下发下载请求时置 `true`,只有"检测到有任务 done"才清 `false`。若下载报错/被取消、或用户在选择音质弹窗点了取消,标志会残留,之后**任意一个无关任务完成**都会触发一次上传。`autoUpload` 打开时则是每个任务完成都重复全量上传一次。建议改为按 `taskId` 记录"待上传任务集合",完成时精确消费。 |
| 2.10 | **Rust 引擎下载后跳到空的任务页** | `MusicModule.vue:460` | `doDownload` 无条件 `activeTab.value = 'tasks'`,但 `downloadEngine === 'rust'` 时任务被交给「下载器」模块(`musicStore.ts:391-430` 不创建本模块任务),本模块任务列表是空的,用户看到"暂无下载任务"。且此时 `downloadTarget === 'feiniu'` 的自动上传链路也永远不会触发。应在 rust 引擎下改为提示"已交给下载器模块"并跳转到对应模块。 |
| 2.11 | **`duration` 未知时拖动进度条无效** | `feiniuStore.ts:609-613`(配合 `PlayerBar.vue:18-20` | `onProgress``store.duration` 换算秒数;若流没有返回 `Content-Length``loadedmetadata``duration``NaN``a.currentTime = NaN` 被忽略,进度条要么不动要么跳回 0。应在 `seek` 内做 `if (!isFinite(a.duration) || a.duration <= 0) return` 并给出"当前音频不支持跳转"的提示。 |
| 2.12 | **`nowPlayingOpen` 状态跨模块残留** | `feiniuStore.ts:105` + `PlayerFull.vue:43` | 大屏开着时切走模块,Dialog 随模块卸载消失,但 `nowPlayingOpen` 仍是 `true`;回到音乐模块会突兀地自动弹出。建议改为由组件本地 `ref` 承载,或在 `onMounted` 时重置。 |
其它可在后续清理的小项:`doneCount` 在重复 `done` 事件下会虚增(`musicStore.ts:320`);`redownloadTask` 用的是**当前默认音质**而非原任务音质(`musicStore.ts:523`);音质弹窗取消后 `selectedKeys` 已被清空(`MusicModule.vue:505-509`);`installRuntime` 的进度监听清理逻辑正确,但 `installError` 与最后一条进度事件之间缺少"失败即清进度"的处理(注释已提到,代码未做)。
---
## 3. 性能瓶颈与优化点
### 3.1 拖动进度条 = 连续发起 Range 请求(最该改的一项)
`PlayerBar.vue:104-110` / `PlayerFull.vue:75-80` 把 Slider 的 `update:model-value` 直接接到 `store.seek()`,而 `seek` 立刻写 `a.currentTime`。飞牛流代理是**真流式透传 Range**(`proxy.rs:131-133`),所以拖动一次会触发几十次"中断旧请求 → 发新 Range 请求"
- 占用带宽、NAS 侧反复建连
- 拖动过程中出现爆音/卡顿
- 拖到底后又从新位置重新缓冲
**推荐**:拖动期间只更新本地"预览位置",`pointerup` / `change` 时提交 `seek`
```ts
// 父组件维护 dragging + previewValue
const dragging = ref(false)
const preview = ref(0)
function onProgressInput(v?: number[]) { dragging.value = true; preview.value = v?.[0] ?? 0 }
function onProgressCommit(v?: number[]) {
dragging.value = false
store.seek(((v?.[0] ?? 0) / 100) * store.duration)
}
```
```vue
<Slider :model-value="[dragging ? preview : store.progress]" @update:model-value="onProgressInput" @value-commit="onProgressCommit" />
```
另外 `:step="0.5"`(200 档)对 4 分钟歌 ≈ 1.2s 粒度尚可,但对播客/长音频(1 小时以上)单档 ≈ 18s,建议改为按 `duration` 动态计算 step(如 `100 / duration`)。
### 3.2 进度时间轴以 4Hz 驱动整棵组件树
`timeupdate` 约每 250ms 一次写 `position.value`,凡是读它的组件都会重渲染。当前影响面:
- `PlayerBar` + `PlayerFull` **同时挂载**(大屏打开时)→ 双份重渲染
- `PlayerFull``currentLine()``feiniuStore.ts:710-717`)是**线性扫描**,每次重算 O(歌词行数)。改:缓存上次命中的索引,只在 `position > 下一行时间``idx++`(歌词已排序,单调推进),或二分查找。
- `PlayerFull.vue:33-39``watchEffect` 每次行切换都 `querySelectorAll('[data-line]')` 全量查询后再 `scrollIntoView({behavior:'smooth'})`。改:用 `ref` 数组或在 v-for 里收集 DOM,仅对目标行滚动;并给滚动加 `prefers-reduced-motion` 降级。
```ts
let lastLineIdx = -1
function currentLine(): number {
const lines = lyricLines.value
if (lastLineIdx >= 0 && lastLineIdx < lines.length
&& (lastLineIdx + 1 >= lines.length || position.value < lines[lastLineIdx + 1].t)) {
while (lastLineIdx > 0 && position.value < lines[lastLineIdx].t) lastLineIdx--
while (lastLineIdx + 1 < lines.length && lines[lastLineIdx + 1].t <= position.value) lastLineIdx++
return lastLineIdx
}
lastLineIdx = -1
for (let k = 0; k < lines.length; k++) if (lines[k].t <= position.value) lastLineIdx = k; else break
return lastLineIdx
}
```
同时建议歌词/队列上**虚拟滚动**:LRC 常有 80~200 行,长播放列表可上千行,当前是全量 DOM。
### 3.3 大屏背景的整图高斯模糊
`PlayerFull.vue:50-52``<img class="h-full w-full scale-110 object-cover blur-2xl opacity-30">` 铺满整个弹窗(`min-h-[70vh]`,实际常超 900×700)。一张全尺寸图叠加 `blur-2xl` + `scale-110`,在打开期间持续占用合成/光栅资源,低端机上会明显掉帧(拖进度条时最明显)。
**推荐**:改为一次性低分辨率方案——用同图 `size=96` 缩略图 + CSS 渐变遮罩,或直接用取色(后端返回主色)生成柔和纯色/线性渐变背景。收益远大于视觉损失。
### 3.4 搜索结果行每行 2 个 Tooltip 组件
`MusicModule.vue:928-965`:每首歌行包了 2 个 `Tooltip`(reka-ui 组件 + 上下文)。首批 80 行 = 160 个 Tooltip 实例,滚动加载到 500 行 = 1000 个。挂载/更新成本高,且 Tooltip 会随列表 diff 频繁重建。
**推荐**:列表行用原生 `title` 属性承载提示(无需组件);只对"需要富文本提示"的 `+N` 音质档位保留一个 Tooltip。
### 3.5 其它
- **下载任务聚合计算**`taskAggDone/taskAggTotal``MusicModule.vue:547-548`)每次渲染都遍历全部 `songs`。一个 500 首歌的任务,每来一次进度事件就重算 500×2。改:在 `handleDownloadEvent` 里维护 `task.doneBytes/totalBytes` 增量字段。
- **`displayItems` / `groupKeys` / `groupAllSelected`**:模板中对每个源分组头调用 `groupAllSelected`,内部 `filteredResults.filter(...)` 是 O(n);结果集大时是 O(n × 分组数)。改:用一次 `computed` 预生成 `Map<source, keys[]>`
- **飞牛曲库无分页**`loadTracks` 固定 `size: 50``page/total` 暴露了却没有 UI。曲库 > 50 首时后面的歌**完全访问不到**(`playAll` 也只能播前 50 首)。应加无限滚动或分页。
- **本地扫描无上限**`scanLocal` 一次性返回全部条目,超大音乐目录下会长时间占住主线程做映射与渲染。
- **缓存模式首播延迟**`feiniu_cache_fetch``commands.rs:421-429``cache_fetch`)是"**下载完整文件后返回路径**",即 `cache` 模式下首次播放要等整首下完;而 `loadingPlay` 这个状态在 UI 上**从未被使用**(`PlayerBar`/`PlayerFull` 都没读),用户看到的是"点了没反应"。要么改用边下边播的双源策略,要么至少把 `loadingPlay` 接上 spinner。
---
## 4. 交互体验问题(按操作路径)
**播放控制**
1. 播放/暂停按钮 `disabled` 时无视觉降级(`PlayerBar.vue:76-84`),看起来可点却没反应。
2. 音量图标(`PlayerBar.vue:117`)不可点击,没有"一键静音";音量条只有 80px,长按/滚轮调节都没有。
3. 切歌没有 crossfade,也没有间奏/淡出,切歌瞬间是硬切。
4. 播放模式按钮图标随状态变(Repeat/Repeat1/Shuffle),初看像三个不同功能,且没有文字提示(只有 Tooltip)。
5. 队列入口用 `ListMusic` 图标 + 数字角标,但点击是**平铺开关**(`store.queueVisible = !store.queueVisible`),而队列实际渲染在 `PlayerFull` 的右栏里——在大屏没打开时点这个图标看不到任何反馈(因为它只切了右栏的 tab,而弹窗没开)。
6. 上一首/下一首没有"按住快进/快退"(老播放器的常见预期)。
**进度条**
7. 没有 hover 时间气泡、没有缓冲进度层、没有"拖动时显示预览时间"。当前拖动时只能看两侧的小字。
8. 轨道 6px + 16px 白色圆点滑块,`Slider.vue:40` 硬编码 `bg-white`,在浅色主题下白滑块白描边几乎隐形。
**列表切换**
9. 发现音乐页切到「我的音乐」再切回,搜索关键词和结果**全部丢失**(模块内 tab 用 `Tabs` 非 keep-alive`flatResults` 被重算但 store 里 `results` 还在——实际是因为 `MusicModule` 未卸载所以结果还在,但 `visibleCount` 会被 `watch(filteredResults)` 重置,滚动位置丢失)。
10. 列表里双击行不会播放(只有点小图标才播),不符合音乐软件的肌肉记忆。
11. 键盘:无空格播放/暂停、无 ↑↓ 选行、无 Enter 播放;所有列表行都不可聚焦。
12. 多选操作只有"下载所选",缺"加入歌单""加入队列""下一首播放"。
---
## 5. 界面缺陷清单(具体位置 + 改法)
### 5.1 结构与布局
| # | 缺陷 | 位置 | 改法 |
|---|---|---|---|
| L1 | **播放条没贴底**:被模块根 `p-6` 包住,`border-t` 只在左右各缩进 24px 的宽度上出现,视觉上像一张"飘着的卡片"而非底部坞站 | `MusicModule.vue:688,1416` | 把 `PlayerBar` 提升到 `App.vue`,作为 `flex-col` 壳层的最后一行(全宽、`border-t``h-[72px]`);音乐模块内容区 `pb-[72px]` 让位 |
| L2 | **左右内边距不对称**:模块左 24px + `px-1`(4px) = 28px;右 24px + `pr-3`(12px) + `px-1` = 40px,右侧被滚动条再吃一层,卡片右边距明显窄于左边 | `MusicModule.vue:688,706-707,987-988` | 统一成 `px-6`,滚动区改成 `[&>[data-slot=scroll-area-viewport]]:pr-6` 或让 ScrollArea 用 `scrollbar-gutter: stable`,避免内边距被滚动条挤压 |
| L3 | **三套切换控件**:模块级 `TabsList`(半透明覆盖)、`MyMusicLibrary` 自绘分段按钮(:89-114)、`PlayerFull` 的裸文字切换(:113-129)——三种视觉语言表达同一件事 | 三处 | 统一为一种 segmented 控件(建议用现有 `Tabs`/`TabsList`,或统一样式的 `ButtonGroup`),并用同一套"选中=主色底/浅底 + 中粗字"的规则 |
| L4 | **`Empty` 组件误用**:直接塞裸 children,继承 `gap-6`(24px) 与 `md:p-12`(48px),图标与文案间距过大且垂直被撑高;`border-dashed` 没有宽度声明,虚线框实际不显示 | `MusicModule.vue:801-809, 977-980``MyMusicLibrary.vue:76-83,135-140` | 用组件既定的子结构:`EmptyMedia / EmptyTitle / EmptyDescription`;或给容器加 `border` + `border-dashed``gap-3``p-8` |
| L5 | **横向 Tab 与子 Tab 层级混乱**:顶层 4 个 tab 里,「我的音乐」内部又横排 3 个子 tab,「设置」内部又竖排 5 张卡;导航成本高 | `MusicModule.vue:691-696``MyMusicLibrary.vue:88-114` | 见 §6 的"左侧来源树 + 右侧主区"结构,把两级 tab 压成一级 |
| L6 | **首屏被低价值信息占据**:「下载到 本地/飞牛」占一整个卡片行(`MusicModule.vue:709-726`),说明文字是 12px 灰字却放在最显眼位置;真正的搜索框被挤到第二行 | `MusicModule.vue:708-794` | 把"下载目标"收进搜索工具栏右侧的 `Select`(与源选择器同排),或放进设置页;首行只留 搜索输入 + 源选择 + 搜索按钮 |
| L7 | **歌单详情页底部孤零零一个"移除最后一首"按钮** | `MyMusicLibrary.vue:234-243` | 语义错误,删掉。改为:行 hover 显示「⋯」菜单(从歌单移除 / 上移 / 下移),或支持多选后批量移除 |
| L8 | **歌单左栏无滚动**`grid-cols-[220px_1fr]`,左栏是普通 `div`,歌单多了会撑高整页 | `MyMusicLibrary.vue:189-205` | 左栏用 `ScrollArea` 并固定高度(`h-[calc(100vh-260px)]`),或整体改三栏布局 |
| L9 | **设置页信息密度过低 + 语义重复**:「飞牛音乐连接」管连接,「上传到飞牛曲库」又要求用户回头去前一张卡点云朵登录;5 张卡片纵向拉得很长 | `MusicModule.vue:1123-1371` | 合并为「连接与存储」一张卡:连接列表 + NAS 文件服务登录 + 曲库目录 + 自动上传;「播放缓存」并入「播放」分组;「环境检查」做成折叠的"诊断"面板 |
### 5.2 视觉与色彩
| # | 缺陷 | 位置 | 改法 |
|---|---|---|---|
| V1 | **音质徽标 5 色抢戏**Hi-Res=琥珀、无损=紫、320K=天蓝、192K=橙、128K=锌灰,一行里 2 个高饱和徽标 + 1 个 `+N`,视觉噪音压过歌名 | `MusicModule.vue:336-355, 879-905` | 降为**两档**:无损/高解析用实心浅色徽标(一个色),其余用 `outline` 中性色;颜色不承担"档位"信息,档位靠文字。或改为歌名后的一个小图标 + Tooltip 详情 |
| V2 | **列表变成卡片堆**:每行 `border` + `rounded-lg` + `p-2.5` + `space-y-2`80 行 = 80 张卡,行间 8px 空隙,扫描效率低 | `MusicModule.vue:845-859` | 改紧凑单行:行高 44~48px、无独立边框、`border-b border-border/60` 或干脆无分隔、hover `bg-accent/60`;表头一行(`# / 标题 / 专辑 / 时长 / 操作` |
| V3 | **源分组头与大卡片行不匹配**:分组头只是 12px 小字,紧贴一堆 56px 卡片 | `MusicModule.vue:848-857` | 分组头改 `sticky top-0 z-10 bg-background/95 backdrop-blur` + 底部分隔线,字号 13px medium;或改用左侧来源 Tab 过滤,取消组内嵌头 |
| V4 | **当前播放项用"选中态"表达**`border-primary bg-primary/10` 在浅色主题下是"近黑边框 + 10% 黑底",像复选框选中而非"正在播放" | `TrackItem.vue:53-54` | 改为:左侧 3px 主色竖条 + 歌名主色 + 一个 12px 的跳动均衡器图标;hover 与 active 语义分离 |
| V5 | **`PlayerFull` 硬编码白色边框**`border-l border-white/10`,浅色主题下完全不可见 | `PlayerFull.vue:112` | 改用 `border-border` |
| V6 | **`PlayerFull` 背景被"洗白"**:整图 `opacity-30` + 白→白渐变,浅色下糊成一片;同时是持续 GPU 开销 | `PlayerFull.vue:50-53` | 改为缩略图小尺寸模糊,或纯色/双色线性渐变(从封面取色);浅色主题下用极浅的封面色调,深色主题下用暗化封面色 |
| V7 | **滑块基本不可见**`Slider.vue:40` 硬编码 `bg-white`,16px 白色滑块 + 白色描边叠在浅底上;轨道仅 6px | `Slider.vue:36-41` | 滑块改 `bg-background` + 主色描边(或直接用 `bg-primary`);静息轨道 4px、hover/拖动时平滑增到 8px;加 `transition-[height]` |
| V8 | **`PlayerFull` 歌词行区分度不足**:非当前行 `text-muted-foreground/60`,当前行仅 `font-medium text-foreground`,没有位置指示 | `PlayerFull.vue:136-145` | 当前行放大到 17~18px + `text-foreground` + 左侧或下方主色指示;两侧行逐级降透明度(60/40/25)形成景深;点击区域扩大到整行并加 hover 背景 |
| V9 | **`PlayerFull` 进度时间只在下方一行小字**,且控制按钮组与进度条分属两个块,视觉不聚合 | `PlayerFull.vue:74-108` | 进度条与控制键合并为一块:`[时间] ──●──── [时间]` 上、`[模式][上一首][播放][下一首][队列]` 下的居中控制簇 |
| V10 | **「词」按钮用单个汉字**当歌词开关 | `PlayerBar.vue:125-132` | 换成 `Lyrics` 图标(`FileText`/`MicVocal`),与两侧图标风格一致 |
| V11 | **下载任务标题行过载**:状态标签 + 歌名 + 歌手 + 音质标签 + 已下载/总量 + 最多 4 个图标按钮挤在一行;且 `Card``gap-0 py-0` + `CardContent py-2` 覆盖,内边距与模块内其它卡片不一致 | `MusicModule.vue:995-1060` | 拆两行:第一行 `[状态] 歌名 · 歌手 ……… [音质] [⋯菜单]`;第二行进度条 + 大小 + "打开目录/取消/重下"收进 `⋯` 菜单。`Card` 统一 `p-0` + 内部 `p-3` |
| V12 | **多曲目任务行进度条只有 56px**`w-14`),几乎读不出进度;错误信息被截断到 112px | `MusicModule.vue:1086-1097` | 进度条改占满剩余宽度(去掉固定宽度,放歌名下方一行);错误信息用 Tooltip 或点击展开 |
| V13 | **`TrackItem` 悬停才出现的播放按钮占 40px 固定宽度**,未悬停时标题左侧留出空白列,触屏/键盘用户无法触发 | `TrackItem.vue:68-75` | 改为:封面左下角 24px 半透明播放浮层(hover 显现),或整行 hover 时标题左侧淡入小图标;并支持双击整行播放 |
| V14 | **飞牛曲库行/本地曲库行/歌单行的"来源"标识不统一**:本地用行内小标签「本地」,飞牛没有标识,歌单混排时无法区分 | `TrackItem.vue:81` | 统一在行尾或封面角标显示来源(NAS 云图标 / 本机图标),并在歌单详情里按来源分组或加筛选 |
| V15 | **搜索历史下拉不适合键盘操作**`@focusout` 关闭 + `@mousedown.prevent` 的组合同样让 Tab 导航无法进入历史项 | `MusicModule.vue:738-760` | 改用 `Combobox`/`Popover` + `Command` 组件(仓库已有 `components/ui/combobox`),支持 ↑↓ 选择、Enter 确认、Esc 关闭 |
| V16 | **`+N` 音质档位靠 Tooltip**,触屏不可达;`visibleTiers` 固定展示 2 个,长歌名下把歌名挤窄 | `MusicModule.vue:888-904` | 行内只留 1 个"最高音质"徽标,全部档位收进右侧 `⋯` 菜单或点击弹出 popover |
| V17 | **`PlayerBar` 缺少播放态反馈**`loadingPlay` 未接 UI;NAS 慢时点播放毫无反应 | `PlayerBar.vue:76-84` | 播放键上叠加 spinner`loadingPlay` 时),或封面加旋转/脉冲 |
| V18 | **`PlayerBar` 三段留白失衡**:左侧信息 `max-w-44`,中间控制区 `flex-1` 里塞了模式/上下首/播放/队列 + 进度条,右侧音量+词+最大化;窄窗口下中间进度条被压到很短 | `PlayerBar.vue:34-141` | 采用标准三段:左 `w-[240px]` 固定、中 `flex-1 min-w-0`(控制 + 进度)、右 `w-[200px]`(音量/词/大屏);窄容器下用 `container query` 渐进隐藏音量条与歌词按钮 |
| V19 | **`TabsList` 同时带 `grid w-full max-w-md` 与组件默认 `inline-flex w-fit`**,并靠 `!p-0 !bg-transparent !shadow-none` 强行覆盖 | `MusicModule.vue:691` | 不要用覆盖式改写;把"模块级分段导航"抽成一个 `SegmentedNav` 组件(`grid grid-cols-N``bg-muted p-0.5`、激活项 `bg-background shadow-sm` |
| V20 | **封面圆角/阴影规格不统一**:列表 40px `rounded-md`、播放条 44px `rounded-md shadow`、大屏 256px `rounded-xl shadow-2xl` | 多处 | 定规格:列表 6px 无阴影、播放条 6px 无阴影、大屏 12px + `ring-1 ring-border/60` 无重阴影 |
| V21 | **下载任务空态文案指向错误**:「去"搜索"页选择歌曲下载」,但 tab 已改名为「发现音乐」 | `MusicModule.vue:991` | 改为"去「发现音乐」页选择歌曲下载" |
| V22 | **`PlayerFull` 未提供音量、队列收起、下一首信息**;队列项没有播放态指示(只有 `bg-primary/10` | `PlayerFull.vue:147-163` | 补:右上角音量滑条;队列项用序号↔均衡器切换表示当前项,hover 显示"下一首播放/移除" |
| V23 | **`ConnectionsSettings` 图标按钮语义不明**`Power` 既表示"激活"又表示"登出",两个含义相同的图标相邻 | `ConnectionsSettings.vue:157-171` | 激活用 `CircleCheck`/`RadioButton`,登出用 `LogOut`,删除保留 `Trash2`;并为所有 icon-only 按钮补 `Tooltip` + `aria-label` |
| V24 | **歌单/曲库的「播放全部」用文字 `▶ 播放全部`**,与其他按钮的图标风格不统一 | `MyMusicLibrary.vue:115-117,214-216` | 统一为图标 + 文字:`<Play class="size-4"/> 播放全部` |
| V25 | **`MusicModule` 里两处搜索框文案不一致**("输入歌名/歌手/专辑关键词" vs "粘贴歌单链接")且歌单解析行的 Input 靠 `border-0 bg-transparent` 融进容器,缺少"这是一行输入"的暗示 | `MusicModule.vue:730-782` | 歌单解析改为折叠的"粘贴歌单链接"次级入口(默认收起),避免与主搜索竞争注意力 |
---
## 6. 重设计方案
### 6.1 参考基准
- **Spotify / Apple Music**:左侧来源树 + 中间列表 + 底部全宽播放条 + 右侧可折叠"正在播放"。
- **网易云音乐 / QQ 音乐**:紧凑表格行(`# / 标题 / 歌手 / 专辑 / 时长`)、行内 hover 操作、双击播放、底部播放条 + 播放列表面板。
- **foobar2000 / MusicBee**:信息密度高、可排序表头、键盘优先。
本项目的定位是"NAS 曲库 + 本地曲库 + 多平台搜索下载"的**工具型播放器**,建议取 **Spotify 的骨架 + 网易云的列表密度**,避免做成消费级流媒体的强视觉风格。
### 6.2 目标信息架构(三级 tab → 一级导航)
```
音乐
├── 曲库
│ ├── 飞牛曲库 (NAS 曲目,分页/无限滚动 + 搜索 + 排序)
│ ├── 本地曲库 (扫描目录,按文件夹浏览)
│ └── 我的歌单 (歌单列表 → 歌单详情,可拖拽排序)
├── 发现音乐 (多平台搜索 + 歌单解析 + 下载)
├── 下载任务 (进行中 / 已完成 分组)
└── 设置 (连接与存储 / 播放 / 下载 / 诊断,用左侧锚点或分段)
```
把「我的音乐」内部的 3 个子 tab 提升为导航的一级子项(来源树),「设置」内部的 5 张卡片改为分组锚点。
### 6.3 布局结构(目标)
```
┌───────────────────────────────────────────────────────────────────────┐
│ TitleBar(含模块浮动切换器) │
├──────────┬──────────────┬─────────────────────────────────────────────┤
│ Sidebar │ 来源树 │ 内容主区 │
│ (模块) │ 240px │ ┌ 固定工具栏 ────────────────────────────┐ │
│ │ │ │ 搜索 来源筛 音质筛 排序 多选操作 │ │
│ │ 曲库 │ ├ 表头 (# 标题 歌手 专辑 时长 操作) ──────┤ │
│ │ · 飞牛曲库 │ │ 44px 紧凑行,hover 显示操作 │ │
│ │ · 本地曲库 │ │ 播放中:主色竖条 + 跳动的均衡器 │ │
│ │ · 我的歌单 │ │ ... │ │
│ │ 发现音乐 │ └────────────────────────────────────────┘ │
│ │ 下载任务 │ │
│ │ 设置 │ (可选右栏 320px:正在播放/歌词/队列) │
├──────────┴──────────────┴─────────────────────────────────────────────┤
│ MiniPlayer 72px(全宽坞站:左曲目 中控制+进度 右音量/歌词/大屏) │
└───────────────────────────────────────────────────────────────────────┘
```
要点:
1. **MiniPlayer 提升到 `App.vue`**,全宽贴底、不参与任何模块内边距;音乐模块内容区 `pb-[72px]`。这样在任何模块都能控制播放。
2. **`PlayerFull` 也提升到 `App.vue`**(全局 Dialog/Overlay),解决跨模块残留问题。
3. **去掉"模块级横向 Tab"**,音乐模块内部用来源树;`useModuleTabs` 仍可保留,用于把来源树的第一级注册给 TitleBar 浮动切换器(保持现有能力)。
4. **右栏"正在播放"可折叠**:窄窗口默认收起,宽窗口(≥1280px)默认展开,用 `container query``useMediaQuery` 决定。
### 6.4 视觉规范(建议写入项目 Design Token
| 项目 | 规范 |
|---|---|
| 主色使用 | 只用于:主操作按钮、播放中指示、"正在播放"高亮。**不用于**徽标分类、分组头 |
| 中性色阶 | 文本 `foreground` / 次文本 `muted-foreground` / 三级文本 `muted-foreground/70`;分隔线 `border/60`hover 面 `accent/60` |
| 圆角 | 行/输入 6px`radius-md`)、卡片 10px`radius-lg`)、大图 12px、头像/封面圆形仅用于单曲大图 |
| 阴影 | 列表与播放条**不用阴影**,靠 `border` 分层;只有 Dialog/Popover 用 `shadow-lg` |
| 行高 | 列表行 44px(紧凑)/ 48px(标准);表头 32px |
| 字号 | 歌名 14px/500,歌手·专辑 12px/400,时长时间数字 12px `tabular-nums` |
| 徽标 | 一类语义一个样式:状态用实心浅底,属性(音质/格式)用 `outline` 中性色 + 文字;不超过 2 个 |
| 动效 | 仅 `opacity` / `transform`120~180ms `ease-out`;进度条高度过渡 150ms;尊重 `prefers-reduced-motion` |
| 明暗主题 | **禁止硬编码** `bg-white` / `border-white/10` / `text-white`;一律走 token |
| 空状态 | 统一 `EmptyMedia(32px 图标) → EmptyTitle(14px) → EmptyDescription(12px)``gap-3``p-10`、无边框 |
### 6.5 关键组件规格
**MiniPlayer**
```
高度 72px | 全宽 | border-t | bg-background/85 backdrop-blur
左 240px :封面 48px(6px 圆角) + 歌名 14px/500 截断 + 歌手 12px 截断 + ♥
中 flex-1:① [模式][上一首][播放 40px 圆形主色][下一首][队列] 居中
② [时间 00:00] ──进度条(hover 4→8px,含缓冲层)── [总时长] 居中 max-w-[560px]
右 200px :音量图标(可点静音) + 音量条 96px | 歌词 | 大屏 | (窄屏只留大屏)
播放中:封面右侧叠加 3 条均衡器动效;`loadingPlay` 时播放键显示 spinner
```
**列表行(TrackItem 重写)**
```
[#/均衡器 24px] [封面 40px] [标题 14px/500 + 歌手 12px] [专辑 12px 可隐藏] [时长 12px tabular] [⋯ hover 显示]
高度 44px | 无边框 | border-b border-border/50 | hover:bg-accent/60 | 当前项:左侧 3px 主色条 + 标题主色
双击整行播放;⋯ 菜单:下一首播放 / 加入队列 / 加入歌单 / 查看文件位置 / 下载
```
**进度条交互**
```
拖动中:只更新预览位置 + 显示时间气泡;轨道加粗;不写 currentTime
释放时:一次性 seek;若 duration 未知则禁用拖动并提示"该音频不支持跳转"
缓冲层:在轨道里用 bg-muted-foreground/25 画已缓冲区间(audio.buffered
```
---
## 7. 落地路径(按性价比排序)
| 阶段 | 内容 | 影响面 | 预估 |
|---|---|---|---|
| **S1 修 Bug(不做设计变更)** | 1.1 搜索框绑定 store.keyword1.2 `toggle()` 无 src 兜底;1.3 试听与播放器互斥(或合并内核);2.1/2.2 shuffle 自动切歌;2.4 缓存开关回填;2.7 `init` 幂等;2.8 持久化剥离 `rawSearch` | 仅改逻辑,风险低 | 半天 |
| **S2 播放器骨架调整** | 3.1 拖动提交式 seek;3.2 歌词行索引缓存;3.3 大屏背景降级;4.1 播放键 loading 态;L1/V18 MiniPlayer 全宽贴底;V5/V7 主题 token 修正 | 播放条 + 大屏 | 1~2 天 |
| **S3 列表与信息架构** | V2 紧凑列表;V1 徽标降噪;V3 分组头;V13/V4 TrackItem 重写;L6 首屏工具栏重构;2.5 上下文队列;2.6 歌单播放/暂停 | 发现音乐 + 曲库 | 3~4 天 |
| **S4 结构性重构** | §6.2/6.3 左侧来源树 + 全局播放条;PlayerBar/PlayerFull 提升到 App.vue;右栏正在播放;设置页分组 | 模块外壳 | 1 周 |
| **S5 体验补全** | 键盘快捷键、虚拟滚动、分页/无限滚动、拖拽排序歌单、音质档位 popover、缓冲层、桌面歌词 | — | 按需 |
**建议先做 S1 + S2**:S1 全是低风险的逻辑修复,S2 集中在两个组件文件里,能立刻解决"卡顿 + 没有控制感 + 拖动难用"的主观感受;S4 的结构调整等到前面稳定后再动,避免一次改动面过大。
---
## 8. 附:值得保留的设计
- 流代理走 `127.0.0.1` 动态端口 + `Cookie` 注入 + **Range 透传**`proxy.rs`)——这是正确做法,seek 能真正生效,不要改成前端直连。
- 桥接进程按需拉起、禁用模块时停止(`music/index.ts:34-45`)——避免了常驻 Python 进程。
- `tasksSignature` 轻量签名代替深度 watch`musicStore.ts:257-266`)——思路正确(只是 payload 需要瘦身)。
- 搜索结果**懒解析**(搜索阶段拿档位、播放/下载前才解析真实链接)+ `rawSearch` 支持隔夜链接重解析(`musicStore.ts:355-370,458`)——设计合理。
- 发现音乐列表的**增量渲染 + IntersectionObserver 哨兵**`MusicModule.vue:265-305`)——方向正确,可平滑迁移为新列表的基础。
---
## 9. 重构落地记录(本次已实施)
### 9.1 新增文件
| 文件 | 作用 |
|---|---|
| `src/components/layout/TitleBarMusic.vue` | 标题栏音乐栏:唱片图标 + 歌名,点击弹出**方形播放控制窗** |
| `src/modules/music/components/PlaylistEditorDialog.vue` | 歌单音乐编辑器:从全部音乐勾选加入 / 批量移出 |
| `src/components/layout/NowPlayingDialog.vue` | 「正在播放」大屏(封面 + 控制 + 歌词 / 队列),由方形控制窗的「歌词 / 队列」入口打开 |
| `src/components/common/ScrubBar.vue` | 通用拖拽进度条:**拖动只预览、松手才 seek**,含缓冲层与时间气泡 |
| `src/components/common/SegmentedNav.vue` | 分段导航(现由 `NowPlayingDialog` 的「歌词 / 队列」切换使用) |
`PlayerBar.vue` / `PlayerFull.vue` 已删除;`GlobalPlayerBar.vue` 曾短暂作为全局坞站播放条存在,现已被标题栏音乐栏取代并删除。
**播放器入口最终形态**:标题栏搜索框左侧的音乐栏(`TitleBarMusic`)——未播放过时只显示唱片图标;有曲目后图标替换为封面并随播放旋转,右侧显示截断歌名。点击弹出约 300×340 的方形控制窗(封面 / 曲目信息 / 进度 / 循环·上下首·播放·队列 / 音量 / 歌词·队列入口),点击封面或「歌词 / 队列」进入大屏。
### 9.2 修复的缺陷
**P0**
1. 飞牛曲库搜索框:直接 `v-model="store.searchKeyword"` 并新增 `loadTracks(page, append)` + `loadMoreTracks()`,搜索真正生效且支持分页续拉。
2. 重启后播放键失效:`toggle()``audio` 无 src 时先按当前条目装载(试听或队列当前曲),失败原因经 `playError` 冒泡成 toast。
3. 双音频引擎:删除模块内试听 `<audio>`,试听改走 `feiniu.playPreview()`,与队列播放**共用同一个 audio 实例**,天然互斥。
**P1**
4. shuffle 自动切歌:`next()` 不再区分手动/自动,随机模式下都从「待播池」抽取;新增切歌历史栈,`prev()` 在随机模式下真正回退。播放模式持久化。
5. 缓存模式:`setCacheMode` / `clearCache` 不再调用 `clearPlayback`,改设置不中断播放。
6. 缓存开关状态:`CacheSettings` 的开关改为 `cacheMode` 的双向 computed,删除死代码 `cacheMax`,补 `ensureCacheStatus` 刷新。
7. `init()` 幂等化(`inited` 标志),不会再覆盖内存队列。
8. 任务持久化:超过 1.5MB 时自动剥离 `rawSearch` / `defaultDownloadHeaders` 后重试。
9. 上传触发:`pendingUploadOnDone` 布尔标志改为 `uploadOnDoneTaskIds` 任务集合 + `uploadedTaskIds` 去重表,每个任务最多触发一次;启动时的历史任务不触发重传。
10. Rust 引擎下载:`startDownload` 返回 `{ engine, skipped, taskId }`;Rust 引擎不再跳到空的任务页,改为提示到「下载器」查看;「下载到飞牛 + Rust 引擎」组合直接给出可读的错误提示并阻止。
11. 队列语义:`playItem(item, context?)` 支持以所在列表为播放上下文,解决「从大曲库点一首就只剩一首」;新增 `playNext` / `addToQueue` / `removeFromQueue`
12. `done` 事件幂等,`doneCount` 不再虚增;`redownloadTask` 沿用原任务音质。
**性能**
13. 提交式 seek`ScrubBar`),拖动不再触发几十次 Range 请求中断重建。
14. 歌词行索引由 O(n) 全量扫描改为「命中缓存微调 + 未命中二分」,并缓存上次索引。
15. 大屏背景从「整图 `blur-2xl scale-110`」改为主题色径向渐变,去掉持续 GPU 开销。
16. 列表行去掉 reka Tooltip(改用原生 `title` + 单个 Popover 展示音质档位),500 行不再产生 1000 个组件实例。
17. 歌词滚动用函数 ref 收集节点,不再每次 `querySelectorAll`
18. 播放队列增量渲染(每批 200 行)。
19. 曲库分页 200/页 + 滚动续拉;任务明细进度条由固定 56px 改为占满剩余宽度。
**交互与视觉**
20. 播放入口改到**标题栏**:移除模块内播放条与全局坞站播放条,在自定义标题栏搜索框左侧新增音乐栏(唱片图标 + 歌名,未播放过时隐藏歌名);点击弹出方形控制窗;播放中图标替换为封面并旋转。
21. 音乐模块顶部 tabs 改回与其余模块完全一致的写法:`<Tabs><div ref="tabsListRef"><TabsList class="grid w-full max-w-md grid-cols-4 !bg-transparent !p-0 !shadow-none">` + `TabsTrigger` + `TabsContent class="mt-4 min-h-0 flex-1 ... tab-animate"`,根节点恢复为 `h-full p-6 overflow-hidden flex flex-col`
22. 三级 tab 压成「模块 tab → 曲库内左侧来源树」两级。左栏由 `w-[210px]` 收窄到 `w-[168px]`,来源项的选中态对齐顶部 Tabs`rounded-lg bg-muted` 容器 + `bg-background shadow-sm` 胶囊),歌单子树用 `border-l` 缩进 + 展开动画;右栏内容切换加 `tab-animate``:key="view"` 重挂载触发),不再生硬。
23. 首屏工具栏:搜索框回到首行,下载目标 / 来源筛选 / 音质筛选收到筛选条,歌单解析收进 Popover。
24. 列表改 44px 紧凑行:无独立边框、hover 高亮、双击整行播放、序号列悬停原位切换为播放按钮(不再出现 40px 空白列);播放中行用 `music-eq` 跳动均衡器 + 主色文字。
25. 音质徽标由 5 色降为单枚中性 outline 徽标 + Popover 详情。
26. `Empty` 全部改为组件既定结构(`EmptyMedia` / `EmptyTitle` / `EmptyDescription`)。
27. 歌单:删除语义奇怪的「移除最后一首」,改为行内「⋯」菜单的支持上移 / 下移 / 移除;新增重命名;左侧歌单列表可独立滚动。
28. **歌单页新增「编辑音乐」**`components/PlaylistEditorDialog.vue`):双 Tab 编辑器——「全部音乐」按来源(全部 / 飞牛 / 本地)勾选加入,已在歌单的曲目置灰标记「已加入」不可重复勾选;「已在歌单」批量勾选移出。含搜索、全选当前列表、增量渲染(每批 120)、飞牛曲库续拉、本地未扫描时的内联扫描入口。
29. 源分组头去掉 `sticky` 与白色底色(原 `bg-background/95 backdrop-blur` 会在浅色背景上形成白色横条)。
30. **发现音乐每行新增「下载到飞牛」**(试听 / 下载右侧):走新增的 Rust 命令 `feiniu_upload_from_url`,由后端边拉音源边传给 NAS,**本地不落地文件**;懒解析歌曲会先按默认音质解析真实链接,并合并 `defaultDownloadHeaders` + `defaultDownloadCookies`(过滤非 ASCII 头,避免 reqwest panic)。
31. 设置页从「5 张卡片」改为「连接 / 存储与上传 / 播放 / 搜索与下载 / 环境与诊断(折叠)」分组,并把「NAS 文件服务登录」并入存储分组。
32. 主题一致性:删除 `border-white/10``bg-white` 等硬编码;滑块、均衡器、进度条全部走 `--primary` / `--muted` / `--background` token。
33. 连接管理图标语义修正(激活用 `CircleCheck`、登出用 `LogOut`),全部 icon-only 按钮补齐 `title` + `aria-label`
34. 搜索历史下拉改为 `focusin/focusout` 容器判定,Tab 键可以进入历史项。
### 9.2.1 飞牛上传链路:fnOS 文件服务 → WebDAV(最终形态)
> 演进:multipart over WS 的 fnOS 文件服务存在会话易失、握手/登录协议联调困难等问题(三轮修复:
> WS 读写分离与 Origin → 加密登录体补 `req`/`reqid` → 登录结果双通道等待),最终整体替换为 **WebDAV**。
| 结论 | 说明 |
|---|---|
| **协议选型** | fnOS 提供的 SMB / WebDAV / FTP / NFS 中选 **WebDAV**:纯 HTTP 语义、reqwest 即可实现、Basic 认证随请求携带**无会话**(重启免登录);SMB 的 Rust 客户端生态差、FTP 明文且被动模式易出问题、NFS 无 Windows 客户端方案 |
| 后端实现 | `src-tauri/src/music/feiniu/webdav.rs``fnos.rs` 已删除):`test`PROPFIND Depth 0/ `upload_file`(本地文件 128KB 分块流式 PUT)/ `delete`;上传前逐级 MKCOL 补目录 |
| 「下载到飞牛」机制 | **与自动上传同链路**:正常走 musicdl 下载(引擎侧解析/代理/音质)→ 任务完成后扫描下载目录(下界=任务创建时刻)→ WebDAV 上传 → 删除本地音频及同名歌词/封面(`feiniu_delete_local`,扩展名白名单)→ 移除任务记录。失败时保留本地文件与任务记录供排查。早期的「URL 拉流直传」(`webdav_upload_from_url`)因可靠性不足已移除 |
| 前端配置 | `feiniuStore.webdav = { url, username, password, dir }` 持久化 localStorage`thing.music.feiniu.webdav`);`webdavReady` 四项齐备才放行上传;设置页「存储与上传」分组内配置 + 「测试连接」 |
| fnOS 侧设置 | 系统设置 → 文件服务 → WebDAV 开启(HTTP 5005 / HTTPS 5006);目标文件夹在「可见文件夹范围」内;团队文件夹需勾选「允许通过文件共享协议挂载」;账号需读写权限(已写成设置页折叠提示) |
| 路径语义 | 目标目录是 **WebDAV 根下的相对路径**(不是 NAS 内部绝对路径),URL 逐段百分号编码 |
| 错误可读化 | 401 认证失败 / 403 无权限 / 404 目录不存在或不在可见范围 / 409 父目录不存在,均带上下文 |
### 9.3 校验
```
vue-tsc --noEmit → exit 00 error
vite build → exit 011.5s
cargo check → exit 0
```
### 9.4 仍未做的部分(建议后续)
- 键盘全局快捷键(空格播放/暂停、↑↓ 选行)——需接入项目既有快捷键体系,避免与输入框冲突。
- 列表虚拟滚动(当前为增量渲染,千级以上仍有优化空间)。
- 歌单拖拽排序(`vue-draggable-plus` 已在依赖中,可直接接入)。
- 本地文件的时长 / 内嵌封面解析(当前时长列退化为文件大小)。
- 缓存模式改为「边下边播」的双源策略(当前 `cache` 模式首播仍需等整首下完)。