docs: 添加 AGENTS.md 文件提供项目开发指导

新增 AGENTS.md 文件,为 Codex AI 提供项目开发指南,包含:
- 项目概述(Tauri 2 + Vue 3 + TypeScript 桌面音乐播放器)
- 开发命令(安装依赖、运行开发服务器、构建等)
- 架构说明(前端 Vue 3、后端 Tauri/Rust、自动导入配置)
- API 请求模式和状态管理说明
- 关键文件列表和 TypeScript 配置
- 前端变更测试指导
This commit is contained in:
Yuhang Wu 2026-08-14 16:24:47 +08:00
parent d94d859128
commit 07d36496bc
20 changed files with 2987 additions and 523 deletions

100
AGENTS.md Normal file
View File

@ -0,0 +1,100 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview
YwYMusic is a desktop music player built with Tauri 2 + Vue 3 + TypeScript. The app uses Tauri's Rust backend for native OS integration while the UI is built with Vue 3 and Naive UI components.
## Development Commands
```bash
# Install dependencies (uses pnpm workspaces)
pnpm install
# Run frontend dev server only (Vite on port 1420)
pnpm dev
# Run Tauri desktop app in dev mode (recommended)
pnpm tauri dev
# Build frontend (with TypeScript checking)
pnpm build
# Build Tauri desktop app for production
pnpm tauri build
# Preview production build
pnpm preview
```
## Architecture
### Frontend (Vue 3 + TypeScript)
- **Router**: Uses `createWebHashHistory` (required for Tauri file:// protocol)
- **State**: Lightweight reactive store pattern in `/src/stores/player.ts` (not Pinia)
- **UI Library**: Naive UI with auto-import resolver
- **Path Alias**: `@` maps to `./src`
### Backend (Tauri/Rust)
- Minimal Rust code in `/src-tauri/src/`
- Tauri handles native OS integration, window management, and system APIs
### Auto-Import Setup
**Critical**: This project uses unplugin-auto-import and unplugin-vue-components:
- Vue APIs (`ref`, `reactive`, `computed`, etc.) are auto-imported—no need to import from "vue"
- Naive UI hooks (`useMessage`, `useDialog`, etc.) are auto-imported
- Naive UI components are auto-imported—no need to register or import them
- TypeScript declarations are auto-generated in `src/types/auto-imports.d.ts` and `src/types/components.d.ts`
When writing Vue components, do not add manual imports for these APIs/components unless the auto-import fails.
### API Request Pattern
Centralized in `src/utils/request.ts`:
- Base URL from `VITE_BASE_API_URL` environment variable (see `.env`)
- Token stored in localStorage with key `"token"`
- Request interceptor adds `Authorization: Bearer <token>` header
- Response interceptor handles success (200) and unauthorized (401) codes
- Use exported functions: `get()`, `post()`, `put()`, `del()`, or generic `request()`
- All responses follow `ApiResponse<T>` type: `{ code: number, msg: string, data: T }`
### State Management
Player state lives in `src/stores/player.ts` using Vue's `reactive()`:
```typescript
playerState.current // Current song
playerState.queue // Play queue
playerState.isPlaying // Playing state
playerState.currentTime // Current playback position
playerState.volume // Volume level (0-100)
```
Use `playSong(song, queue?)` to update current song and queue.
## Key Files
- `vite.config.ts` - Vite configuration with auto-import plugins
- `src/main.ts` - Vue app entry point
- `src/router/index.ts` - Route definitions
- `src/utils/request.ts` - Axios instance with interceptors
- `src/types/global.ts` - Global TypeScript types
- `src-tauri/tauri.conf.json` - Tauri app configuration
- `src-tauri/src/lib.rs` - Tauri backend entry point
## TypeScript
- Base config: `tsconfig.json`
- Node config: `tsconfig.node.json`
- Auto-generated types in `src/types/` should not be manually edited
- Use type imports from `@/types/global.ts` for API responses
## Testing Frontend Changes
Always run `pnpm tauri dev` to test in the actual desktop environment, as Tauri apps behave differently from web apps (file protocol, window APIs, etc.).

File diff suppressed because it is too large Load Diff

413
docs/music-platform-apis.md Normal file
View File

@ -0,0 +1,413 @@
# Museek 音乐平台内容获取实现解析
本文档解析 Museek 如何通过各音乐平台(网易云 wy、酷狗 kg、酷我 kw、QQ 音乐 tx、咪咕 mg的公开接口获取**搜索、歌单、专辑、排行榜、热搜**等内容。所有实现均移植自 lx-music-desktop 的 `musicSdk/<platform>/`,代码位于 `src/lib/` 下按功能分目录组织。
> 平台缩写对照:`wy` = 网易云音乐,`kg` = 酷狗音乐,`kw` = 酷我音乐,`tx` = QQ 音乐(腾讯),`mg` = 咪咕音乐。
---
## 目录
1. [通用基础设施](#1-通用基础设施)
2. [平台 SDK 桶与签名算法](#2-平台-sdk-桶与签名算法)
3. [歌曲搜索5 平台)](#3-歌曲搜索)
4. [热搜关键词5 平台)](#4-热搜关键词)
5. [排行榜5 平台)](#5-排行榜)
6. [歌单5 平台)](#6-歌单)
7. [歌单搜索](#7-歌单搜索)
8. [歌单链接解析](#8-歌单链接解析)
9. [专辑5 平台)](#9-专辑)
10. [专辑搜索](#10-专辑搜索)
11. [附:播放 URL / 歌词获取](#11-附播放-url--歌词获取)
---
## 1. 通用基础设施
### 1.1 HTTP 层 — `src/lib/http.ts`
所有第三方请求统一经过 `httpFetch(input, init)`
- **Tauri 环境**`__TAURI_INTERNALS__ in window`):走 `@tauri-apps/plugin-http` 原生 HTTP绕过浏览器 CORS 限制。
- **浏览器/预览环境**:路由到 Vite 开发代理 `/__proxy?target=<url>`,并将浏览器禁止 JS 设置的请求头(`user-agent`、`referer`、`origin`、`cookie`、`host`)通过 `x-pxy-*` 前缀中继给代理还原。
### 1.2 缓存层 — `src/lib/cache.ts`
`createAsyncCache(ttlMs, max)` 提供进程内 TTL 缓存 + Promise 级去重(并发相同请求共享一次网络调用),失败不缓存。各模块用它包裹只读接口。
| 模块 | 缓存键 | TTL |
|------|--------|-----|
| 歌曲搜索 | `platform:query:page` | 3 min |
| 歌单搜索 | `source:query:page` | 3 min |
| 专辑搜索 | `v4:source:query:page:limit` | 3 min |
| 歌单(热门/标签/详情) | `source:page:tag` / `source:tags` / `source:id:page` | 5 min |
| 专辑(热门/标签/详情) | `v2:source:page:tag` 等 | 510 min |
| 排行榜歌曲 | `source:boardId:page` | 5 min |
| 热搜 | `source` | 10 min |
| 歌词 | `source:songId:version` | 30 min |
### 1.3 统一数据模型 — `src/types/music.ts`
所有平台返回统一归一化为 `MusicInfo`
```ts
interface MusicInfo {
id: string; // "<source>_<songId>",如 "wy_12345"
name: string;
singer: string;
source: Source; // "kw" | "kg" | "tx" | "wy" | "mg" | "local"
interval: string; // 时长 "m:ss"
albumName: string;
meta: MusicInfoMeta; // 平台特有字段 + 音质列表
}
```
- `MusicInfoMeta.songId` 是平台原始歌曲 ID网易云是数字 idQQ 是 songmid酷狗是 album_audio_id 等)。
- `MusicInfoMeta.qualitys` / `_qualitys``src/lib/quality.ts``indexQualitySizes` 归一化,音质枚举:`128k` / `320k` / `flac` / `flac24bit`
- 各平台特有字段:`hash`(酷狗 FileHash、`strMediaMid`QQ media_mid、`copyrightId`(咪咕)。
---
## 2. 平台 SDK 桶与签名算法
`src/lib/platforms/index.ts` 是各平台的 SDK 聚合桶(`export * as wy/kg/tx/mg/kw`),各平台 index 仅做 re-export签名/加密逻辑集中在各自文件内。
### 2.1 网易云 eapi 签名 — `src/lib/platforms/wy/eapi.ts`
```ts
eapi(url, object) → { params: string }
eapiParams(url, object) → string
```
算法(移植自 wy/utils/crypto.js
1. `text = JSON.stringify(object)`
2. `message = "nobody" + url + "use" + text + "md5forencrypt"`
3. `digest = md5(message)`
4. `data = url + "-36cd479b6b5-" + text + "-36cd479b6b5-" + digest`
5. 用密钥 `e82ckenh8dichen8`**AES-128-ECB + PKCS7** 加密,输出大写十六进制。
请求体为 `application/x-www-form-urlencoded``params=...`POST 到 `http://interface.music.163.com/eapi/batch`(或用 `eapi<path>` 直连网关)。
### 2.2 QQ 音乐 zzcSign 签名 — `src/lib/search/txDesktop.ts`
用于 `musics.fcg` 桌面搜索search_type 0/2/3
1. 对请求体 JSON 求 **SHA-1**
2. 按 `PART_1_INDEXES=[23,14,6,36,16,40,7,19]`、`PART_2_INDEXES=[16,1,32,12,19,27,8,5]` 抽取散列字符;
3. `SCRAMBLE_VALUES`20 个固定字节)与散列逐字节异或后 Base64去掉 `/+`
4. 签名 = `zzc + part1 + b64 + part2`(小写);
5. URL 形如 `https://u.y.qq.com/cgi-bin/musics.fcg?sign=<sign>`body 为 `comm` + `music.search.SearchCgiService.DoSearchForQQMusicDesktop` 协议包,含随机 `searchid`32 位 hex + 5 位数字)。
### 2.3 QQ 音乐 musicu.fcg无 zzcSign
排行榜/热搜/歌单/专辑使用的 `u.y.qq.com/cgi-bin/musicu.fcg` 使用传统 `comm` 块(`uin/format/ct/cv`**不需要** zzcSign。请求体 `data` 参数为 `encodeURIComponent(JSON.stringify(body))`,或 POST JSON。
### 2.4 酷我 wbdCrypto — `src/lib/charts/kw.ts`
排行榜请求签名:
- AES key 固定 16 字节 `[112,87,39,61,199,250,41,191,57,68,45,114,221,94,140,228]`
- `appId = "y67sprxhhpws"`
- `encodeData = base64(AES-128-ECB(JSON))``sign = MD5(appId + encodeData + time).toUpperCase()`
- 最终 query`data=<encodeData>&time=<time>&appId=<appId>&sign=<sign>`
- 响应体同样用该 AES key 解密。
### 2.5 酷狗签名 — `src/lib/playlists/kg.ts`
`signatureParams(params, platform, body)``MD5(key + sorted(params) + body + key)`。
- web key = `NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt`
- android key = `OIlwieks28dk2k092lksi2UIkp`
### 2.6 咪咕签名 — `src/lib/search/mg.ts` / `albums/search.ts` / `playlists/search.ts`
```ts
deviceId = "963B7AA0D21511ED807EE5846EC87D20" // 固定
signatureMd5 = "6cdc72a439cef99a3418d2a78aa28c73"
sign = md5(str + signatureMd5 + "yyapp2d16148780a1dcc7408e06336b98cfd50" + deviceId + time)
```
通过请求头 `deviceId / timestamp / sign / uiVersion / channel` 携带。
---
## 3. 歌曲搜索
入口:`src/stores/searchStore.ts` 的 `searchFns` 分发;`scope = "song"`。各实现返回 `SearchResult { list, total, page, allPage, limit }`
### 3.1 网易云 `searchWangyi``src/lib/search/wy.ts`
- **接口**`POST http://interface.music.163.com/eapi/batch`
- **apiPath**`/api/search/song/list/page`
- **签名**eapi见 2.1
- **请求 payload**`{ keyword, needCorrect:"1", channel:"typing", offset, scene:"normal", total: page===1, limit }`
- **响应解析**`data.resources[].baseInfo.simpleSongData` → `normalizeWySong`(音质由 `privilege.maxBrLevel/maxbr``hr/sq/h/l` 文件对象判定)。
- **songId**:数字 id。
### 3.2 酷狗 `searchKugou``src/lib/search/kg.ts`
- **接口**`GET https://songsearch.kugou.com/song_search_v2`
- **query**`keyword,page,pagesize,userid=0,platform=WebFilter,filter=2,iscorrection=1,privilege_filter=0,area_code=1`
- **headers**`Referer: https://www.kugou.com/`
- **无签名**
- **响应解析**`data.lists[]`(含 `Grp[]` 子组展开去重)→ 音质由 `FileSize/HQFileSize/SQFileSize/ResFileSize` 判定。
- **songId**`Audioid`album_audio_id`hash` = `FileHash`
### 3.3 酷我 `searchKuwo``src/lib/search/kuwo.ts`
- **接口**`GET http://search.kuwo.cn/r.s`
- **query**`client=kt,all=<q>,pn,rn,uid=794762570,ver=kwplayer_ar_9.2.2.1,vipver=1,show_copyright_off=1,newver=1,ft=music,cluster=0,strategy=2012,encoding=utf8,rformat=json,mobi=1`
- **无签名**
- **响应解析**`abslist[]`;音质由 `N_MINFO` 正则 `level:\w+,bitrate:(\d+),format:\w+,size:([\w.]+)` 解析bitrate 4000/2000/320/128 → flac24bit/flac/320k/128k
- **songId**`MUSICRID` 去掉 `MUSIC_` 前缀。
### 3.4 QQ `searchTx``src/lib/search/tx.ts` + `txDesktop.ts`
- **接口**`POST https://u.y.qq.com/cgi-bin/musics.fcg?sign=<sign>`zzcSignsearch_type=0
- **响应解析**`body.song.list`(桌面)或 `body.item_song`(旧移动端);`meta.sum/estimate_sum` 为总数。
- **音质**`file.size_128mp3/size_320mp3/size_flac/size_hires`。
- **songId**`mid`songmid`strMediaMid` = `file.media_mid`
- 带重试(最多 5 次,处理风控/异常信封)。
### 3.5 咪咕 `searchMigu``src/lib/search/mg.ts`
主路径 + 兜底(见 decisionsjadeite 可能 403退回旧接口
- **主接口**`GET https://jadeite.migu.cn/music_search/v3/search/searchAll`
- query`isCorrect=0,isCopyright=1,searchSwitch=<song=1...>,pageSize,text,pageNo,sort=0,sid=USS`
- 签名头:`uiVersion/deviceId/timestamp/sign/channel`2.6
- **兜底接口**`GET https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/search_all.do`
- query`isCopyright=1,isCorrect=1,pageNo,pageSize,searchSwitch,sort=0,text`
- **响应解析**`songResultData.resultList[][]`,按 `copyrightId` 去重;音质由 `audioFormats[].formatType`PQ/HQ/SQ/ZQ/ZQ24判定。
- **songId**`songId``copyrightId` 独立字段。
---
## 4. 热搜关键词
入口:`src/lib/hotSearch/index.ts` 的 `getHotSearch(source)`,缓存 10 min最多 30 条,返回 `HotKeyword[] { keyword, rank }`
### 4.1 网易云 `getWyHotSearch``hotSearch/wy.ts`
- `POST http://interface.music.163.com/eapi/batch`apiPath `/api/search/chart/detail`payload `{ id: "HOT_SEARCH_SONG#@#" }`eapi 签名)。
- 解析 `data.itemList[].searchWord`
### 4.2 酷狗 `getKgHotSearch``hotSearch/kg.ts`
- `GET http://gateway.kugou.com/api/v3/search/hot_tab?signature=ee44edb9d7155821412d220bcaf509dd&appid=1005&clientver=10026&plat=0`
- 自定义头:`dfid/mid/clienttime/x-router: msearch.kugou.com/user-agent(Android)/kg-rc:1`
- 解析 `data.list[].keywords[].keyword`
### 4.3 酷我 `getKwHotSearch``hotSearch/kw.ts`
- `GET http://hotword.kuwo.cn/hotword.s?prod=kwplayer_ar_9.3.0.1&corp=kuwo&newver=2&...&tabid=1`UADalvik/Android
- 解析 `tagvalue[].key`
### 4.4 QQ `getTxHotSearch``hotSearch/tx.ts`
- `POST https://u.y.qq.com/cgi-bin/musicu.fcg`
- 协议:`comm` + `hotkey: tencent_musicsoso_hotkey.HotkeyService.GetHotkeyForQQMusicPC`
- 解析 `hotkey.data.vec_hotkey[].query`
### 4.5 咪咕 `getMgHotSearch``hotSearch/mg.ts`
- 主 `https://jadeite.migu.cn/music_search/v3/search/hotword`,兜底 `http://jadeite.migu.cn:7090/music_search/v3/search/hotword`channel/uiVersion
- 解析 `data.hotwords[].hotwordList[]`,优先 `resourceType === "song"`
---
## 5. 排行榜
入口:`src/lib/charts/index.ts`。静态榜单元数据 `ALL_BOARDS`(每个平台 `xxBoards: ChartBoard[] { id: "<source>__<bangid>", name }``getBoardSongs(source, boardId, page)` 缓存 5 min 并分发到 `getXxBoardSongs`
### 5.1 网易云 `getWyBoardSongs``charts/wy.ts`
- **接口**`POST http://interface.music.163.com/eapi/batch`apiPath `/api/v3/playlist/detail`payload `{ id: bangid, n: 100000, s: 0 }`eapi
- 榜单本质是网易云官方歌单,`playlist.tracks` 一次性返回全量(`page` 忽略)。
- 榜单列表:飙升榜/新歌榜/原创榜/热歌榜/说唱榜/古典榜/电音榜/黑胶VIP爱听榜/ACG榜/韩语榜/国电榜/欧美热歌榜。
### 5.2 酷狗 `getKgBoardSongs``charts/kg.ts`
- **接口**`GET http://mobilecdnbj.kugou.com/api/v3/rank/song`
- query`version=9108,ranktype=1,plat=0,pagesize=100,area_code=1,page,rankid,with_res_tag=0,show_portrait_mv=1`
- 无签名;解析 `data.info[]`(音质 `filesize/320filesize/sqfilesize/filesize_high`)。
- 榜单列表TOP500/酷狗飙升榜/网络红歌榜/抖音热歌榜/分享榜/内地榜/粤语金曲榜/欧美金曲榜/电音榜/DJ热歌榜/古风新歌榜。
### 5.3 酷我 `getKwBoardSongs``charts/kw.ts`
- **接口**`GET https://wbd.kuwo.cn/api/bd/bang/bang_info?<wbdCrypto 签名>`
- 请求体 `{ uid:"", devId:"", sFrom:"kuwo_sdk", user_type:"AP", carSource, id: bangid, pn: page-1, rn: 100 }`wbdCrypto 加密,见 2.4)。
- **响应体 AES 解密后解析** `data.musiclist[]`(音质 `n_minfo`)。
- 榜单列表:飙升榜/热歌榜/新歌榜/抖音热歌榜/会员畅听榜/流行趋势榜/经典怀旧榜/华语榜/粤语榜/欧美榜/韩语榜/日语榜。
### 5.4 QQ `getTxBoardSongs``charts/tx.ts`
- **接口**`POST https://u.y.qq.com/cgi-bin/musicu.fcg`
- 协议:`toplist: musicToplist.ToplistInfoServer.GetDetail`param `{ topid, num: 300 }`(省略 `period` 取最新一期)。
- 解析 `toplist.data.songInfoList[]``file` 音质)。
- 榜单列表:热歌榜/流行指数榜/新歌榜/飙升榜/网络歌曲榜/抖快榜/内地榜/香港地区榜/台湾地区榜/欧美榜/韩国榜/日本榜。
### 5.5 咪咕 `getMgBoardSongs``charts/mg.ts`
- **接口**`GET https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/querycontentbyId.do?columnId=<bangid>&needAll=0`
- 无签名;解析 `columnInfo.contents[].objectInfo`(音质 `newRateFormats[].formatType`ZQ→flac24bit`length` 尾部 `mm:ss` 取时长。
- 榜单列表:新歌榜/热歌榜/原创榜/音乐风向榜/彩铃分贝榜/会员臻爱榜/港台榜/内地榜/欧美榜/国风金曲榜。
---
## 6. 歌单
入口:`src/lib/playlists/index.ts`。三组接口:热门歌单 `getHotPlaylists(source, page, tagId?)`、分类标签 `getPlaylistTags(source)`、歌单详情 `getPlaylistDetail(source, id, page)`,均缓存 5 min。`Playlist { id, name, img, playCount?, author?, publishTime?, songCount?, source, kind? }``PlaylistDetail { info, list: MusicInfo[] }`。
### 6.1 网易云 — `playlists/wy.ts`
- **标签** `getWyPlaylistTags`eapi `/api/playlist/hottags`payload `{}`),解析 `tags[].name`
- **热门** `getWyHotPlaylists`eapi `/api/playlist/list`payload `{ cat, order:"hot", limit:30, offset, total:true }`
- **详情** `getWyPlaylistDetail`eapi `/api/v3/playlist/detail``{ id, n:100000, s:0 }`)。
- `playlist.tracks` 仅内联前 ~10 首,剩余通过 `/api/v3/song/detail`payload `{ c: JSON.stringify([{id}...]) }`,每 500 首分块,最多 1000 首)扩展并合并 `privileges` 音质信息。
### 6.2 酷狗 — `playlists/kg.ts`(多路径,最复杂)
- **标签** `getKgPlaylistTags``GET http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&cdn=cdn`,解析 `data.hotTag.data{}`
- **热门** `getKgHotPlaylists``GET http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_ajax=1&cdn=cdn&t=5&c=<tag>&p=<page>`,解析 `special_db[]`
- **详情** `getKgPlaylistDetail(id)`:先 `parseKgPlaylistId` 判定 id 形态,再分流:
1. **link**http 链接)→ `getDetailFromLink`(解析 rank / global_collection_id / gcid / chain必要时抓 HTML
2. **rank**`getDetailByRankId`(复用榜单 `getKgBoardSongs`)。
3. **gcid**`decodeGcid``POST https://t.kugou.com/v1/songlist/batch_decode`android 签名)→ `getDetailByGlobalId`
4. **global**`collection_`/`global_collection_id`)→ `getDetailByGlobalId``GET https://mobiles.kugou.com/api/v5/special/info_v2`web 签名)取元信息 + `GET https://mobiles.kugou.com/api/v5/special/song_v2`web 签名300 首/页分页)取歌曲。
5. **chain**`getDetailFromShareChain``GET http://m.kugou.com/schain/transfer`)。
6. **code**(酷狗码/纯数字)→ `getDetailByCode``POST http://t.kugou.com/command/`JSON `{appid,clientver,mid,clienttime,key,data}`type=2 且无 global id 时回退 `special/song`
7. **special**`id_<n>`)→ `getDetailBySpecialIdWithFallback``GET http://mobilecdn.kugou.com/api/v3/special/song``plat=0&pagesize=-1`+ `special/info` → 失败则 `special/info``global_specialid` 走 global → 再失败抓 HTML`yueku/v9/special/single/<id>-5-9999.html`)解析 `global.data = [...]``POST http://gateway.kugou.com/v2/album_audio/audio``KG-THash/KG-RC/KG-Fake/KG-RF/x-router` 头)批量 hash→歌曲。
- **songId**`album_audio_id/audio_id`hash 来自 `hash`
### 6.3 酷我 — `playlists/kw.ts`
- **标签** `getKwPlaylistTags``GET http://wapi.kuwo.cn/api/pc/classify/playlist/getRcmTagList?loginUid=0&loginSid=0&appUid=76039576`,筛 `digest === "10000"`
- **热门** `getKwHotPlaylists`
- 有 tag`GET .../getTagPlayList?...&id=<tag>&order=hot`
- 无 tag`GET .../getRcmPlayList?...&order=hot`rn=36
- **详情** `getKwPlaylistDetail``GET http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=<id>&pn=<page-1>&rn=1000&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1`,解析 `musiclist[]``N_MINFO` 音质)。
### 6.4 QQ — `playlists/tx.ts`
- **标签** `getTxPlaylistTags``GET musicu.fcg`,协议 `tags: playlist.PlaylistAllCategoriesServer.get_all_categories`
- **热门** `getTxHotPlaylists`
- 有 tag`musicu.fcg` 协议 `playlist.PlayListCategoryServer.get_category_content`
- 无 tag`musicu.fcg` 协议 `playlist.PlayListPlazaServer.get_playlist_by_tag``id:10000000, order:5 最热`)。
- **详情** `getTxPlaylistDetail``GET https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg?type=1&json=1&utf8=1&onlysong=0&new_format=1&disstid=<id>&...`,解析 `cdlist[0].songlist[]`;非 0 code 时重试最多 3 次(退避)。
### 6.5 咪咕 — `playlists/mg.ts`
- **标签** `getMgPlaylistTags``GET https://app.c.nf.migu.cn/pc/v1.0/template/musiclistplaza-taglist/release`,解析 `data[0].content[].texts[]`
- **热门** `getMgHotPlaylists`
- 有 tag`GET .../pc/v1.0/template/musiclistplaza-listbytag/release?pageNumber=&templateVersion=2&tagId=`
- 无 tag`GET https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=30&start=<page>&templateVersion=5&type=1`(解析 `contentItemList[].itemList[]`,含 `barList[0].title` 播放量;回退 `contents[]``resType==='2021'`)。
- **详情** `getMgPlaylistDetail``GET https://app.c.nf.migu.cn/MIGUM3.0/resource/playlist/song/v2.0?pageNo=&pageSize=50&playlistId=` + 并行 `GET https://c.musicapp.migu.cn/MIGUM3.0/resource/playlist/v2.0?playlistId=`(取 title/imgItem/ownerName
---
## 7. 歌单搜索
入口:`src/lib/playlists/search.ts` 的 `searchPlaylists(source, query, page, limit)`(缓存 3 min软失败返回 `[]`)。所有平台走统一签名/端点,归一化为 `Playlist[]`
| 平台 | 端点 / 方法 |
|------|-------------|
| wy | eapi `/api/cloudsearch/pc``type:1000`(歌单);额外 `type:1002` 搜用户昵称,命中精确昵称时再 `eapi /api/user/playlist``{ uid, limit:50, includeVideo:true }`)拉该用户歌单(应对"我喜欢的音乐"这种通用名) |
| tx | `qqDesktopSearch(query, page, limit, 3)`search_type=3解析 `body.songlist.list[]` |
| kw | `GET http://search.kuwo.cn/r.s?ft=playlist&...`,解析 `abslist[]` |
| kg | `GET http://msearchretry.kugou.com/api/v3/search/special?keyword=&page=&pagesize=&showtype=10&filter=0&version=7910&sver=2`,解析 `data.info[]`id 前缀 `id_` |
| mg | 签名 `GET https://jadeite.migu.cn/music_search/v3/search/searchAll``searchSwitch songlist:1`),解析 `songListResultData.result[]` |
---
## 8. 歌单链接解析
`src/lib/playlists/openLink.ts``parsePlaylistLink(source, raw)` 把用户粘贴的歌单 URL / 纯 ID 转成各平台 `getXxPlaylistDetail` 可识别的 id 形态:
- **tx**`/playlist/(\d+)` 或 `[?&]id=(\d+)`
- **kw**`/playlist(?:_detail)?/(\d+)`。
- **kg**`/special/single/(\d+)`(前缀 `id_`);额外 `matchKgPlaylistId` 识别 `rank_`/`global_collection_id`/`gcid_`/`chain_`/songlist/share URL纯数字视作酷狗码短链先 `resolveRedirect`(手动 redirect最多 3 跳)再匹配。
- **wy**`[?&]id=(\d+)` 或 `/playlist/(\d+)/\d+`
- **mg**`/playlist/(\d+)`。
---
## 9. 专辑
入口:`src/lib/albums/index.ts`。四组接口:专辑搜索 `searchAlbums`、详情 `getAlbumDetail(source, id, page)`、新碟/热门 `getHotAlbums(source, page, tagId?)`、分类 `getAlbumTags(source)`。`Album { id, name, img, author?, publishTime?, songCount?, source }`。
### 9.1 网易云 — `albums/wy.ts`
- **详情** `getWyAlbumDetail`
- 主eapi `/api/v1/album/{id}`payload `{}`code 200/502 且含 `songs||album` 即用);
- 兜底eapi `/api/album/v3/detail``{ id }`)。
- 封面 `https` 化并补 `param=240y240`
- **标签** `getWyAlbumTags`:静态地区 `[华语 ZH, 欧美 EA, 韩国 KR, 日本 JP]`
- **热门** `getWyHotAlbums`eapi `/api/album/new`payload `{ area, limit:30, offset, total:true }`
### 9.2 酷狗 — `albums/kg.ts`
- **详情** `getKgAlbumDetail`
- 歌曲:`GET http://mobiles.kugou.com/api/v3/album/song?version=9108&albumid=&plat=0&pagesize=200&area_code=0&page=&with_res_tag=0`
- 若仅返回 hash`POST http://gateway.kugou.com/v2/album_audio/audio`(同 6.2)批量解析;
- 信息:`POST http://kmrserviceretry.kugou.com/container/v1/album`JSON 含 `data:[{album_id}]` 与固定 `key/mid`),解析 `album_name/sizable_cover/author_name`
- **标签** `getKgAlbumTags`:静态语言 `[华语 1, 欧美 2, 日语 3, 韩语 4, 其他 5]`
- **热门** `getKgHotAlbums``GET http://www2.kugou.kugou.com/yueku/v9/album/index?is_ajax=1&cdn=cdn&p=&s=30&l=<lang>&c=&t=0`,解析 `data.data/data.info[]`
### 9.3 酷我 — `albums/kw.ts`
- **详情** `getKwAlbumDetail``GET http://search.kuwo.cn/r.s?pn=&rn=1000&stype=albuminfo&albumid=&show_copyright_off=0&encoding=utf&vipver=MUSIC_9.1.0`(重试最多 3 次body 可能是单引号 JSON 字符串,用 `objStr2JSON` 修复;音质 `formats` 字段 `MP3128/MP3H/ALFLAC/HIRFLAC`)。
- **标签** `getKwAlbumTags`:静态榜单 `[新歌榜 17, 热歌榜 16, 飙升榜 93]`
- **热门** `getKwHotAlbums`:酷我没有稳定专辑广场,**由榜单歌曲反推去重专辑**`getKwBoardSongs` → 按 `meta.albumId` 去重)。
### 9.4 QQ — `albums/tx.ts`
- **详情** `getTxAlbumDetail``GET https://c.y.qq.com/v8/fcg-bin/fcg_v8_album_info_cp.fcg?albummid=&platform=yqq&format=json&...`(重试 3 次),兼容 `data`/顶层、`songlist`/`list` 两种形态。
- **标签** `getTxAlbumTags``GET musicu.fcg` 协议 `req_1: music.web_album_library.get_album_by_tags``get_tags:1`),解析 `req_1.data.tags.area`;失败用静态地区兜底。
- **热门** `getTxHotAlbums``musicu.fcg` 协议 `get_album_by_tags``sort:2, area, start, num:30`),解析 `req_1.data.list/albumlist`
### 9.5 咪咕 — `albums/mg.ts`
- **详情** `getMgAlbumDetail`
- 歌曲:`GET http://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/queryAlbumSong?albumId=&pageNo=``newRateFormats/artists/length` 形态);
- 信息:`GET https://app.c.nf.migu.cn/MIGUM3.0/resource/album/v2.0?albumId=`title/imgItems/singer
- **标签** `getMgAlbumTags``GET https://app.c.nf.migu.cn/pc/v1.0/template/get-new-cd-list-header`,从 `actionUrl` 提取 `columnId`;失败用静态兜底。
- **热门** `getMgHotAlbums``GET https://app.c.nf.migu.cn/MIGUM3.0/v1.0/template/get-new-cd-list-data?templateVersion=1&columnId=&start=&count=30`,解析 `contentItemList[].itemList[]``template === "disk_grid"`),从 `actionUrl` 提取 `album-info?id=`
---
## 10. 专辑搜索
入口:`src/lib/albums/search.ts` 的 `searchAlbums(source, query, page, limit)`(缓存 3 min软失败返回 `[]`)。
| 平台 | 端点 / 方法 | 说明 |
|------|-------------|------|
| wy | eapi `/api/cloudsearch/pc``type:10` | 解析 `result.albums[]` |
| tx | `qqDesktopSearch(query, page, limit, 2)`search_type=2 | 解析 `body.album.list[]` |
| kw | `GET http://search.kuwo.cn/r.s?ft=album&...` | 解析 `albumlist/abslist[]`(小写字段) |
| kg | `GET http://msearchretry.kugou.com/api/v3/search/album?keyword=&page=&pagesize=&sver=2&with_res_tag=0` | 解析 `data.info[]` |
| mg | 签名 `GET .../searchAll``searchSwitch song:1 album:1` | **合并歌曲命中与官方专辑结果**,按标题/歌曲名相关度打分排序:歌曲命中 `1000*歌名匹配 + 专辑名加成 + 顺序加成 - live/remix 惩罚`;官方专辑 `200*标题匹配 + ...`;过滤"单曲发行"/id=2 的垃圾专辑 |
---
## 11. 附:播放 URL / 歌词获取
(不属于歌单/专辑/榜单/搜索,但同属"内容获取",简述链路。)
### 11.1 播放 URL
- **内置网易云**`src/lib/playlists/wyUrl.ts` `getWyBuiltinMusicUrl(songId, quality)``GET https://music.163.com/api/song/enhance/player/url?id=&ids=[id]&br=<bitrate>`,作为导入 lx 源失败时的兜底。
- **主路径**`src/lib/sourceRunner.ts` 调用用户导入的 lx-music 兼容源脚本(`musicUrl` 动作Museek 不内置分发内容(见 decisions/constraints
### 11.2 歌词
`src/lib/lyric/loadLyric.ts` `loadLyric(song)` 链路:磁盘/内存缓存 → 平台内置歌词(`src/lib/lyric/`)→ 源脚本 `lyric` 动作 → 解析(`lxlyric`/`lyric`/`tlyric`)并应用逐词卡拉 OK 时序(`src/lib/lyrics/timing.ts`,支持 wy YRC / tx QRC / kw lyricx / kg KRC / mg MRC 及内联 `lxlyric`)。
---
## 附:平台 → 代码文件速查
| 平台 | 搜索 | 热搜 | 排行榜 | 歌单 | 歌单搜索 | 专辑 | 专辑搜索 |
|------|------|------|--------|------|----------|------|----------|
| 网易云 wy | `search/wy.ts` | `hotSearch/wy.ts` | `charts/wy.ts` | `playlists/wy.ts` | `playlists/search.ts` | `albums/wy.ts` | `albums/search.ts` |
| 酷狗 kg | `search/kg.ts` | `hotSearch/kg.ts` | `charts/kg.ts` | `playlists/kg.ts` | `playlists/search.ts` | `albums/kg.ts` | `albums/search.ts` |
| 酷我 kw | `search/kuwo.ts` | `hotSearch/kw.ts` | `charts/kw.ts` | `playlists/kw.ts` | `playlists/search.ts` | `albums/kw.ts` | `albums/search.ts` |
| QQ tx | `search/tx.ts` + `search/txDesktop.ts` | `hotSearch/tx.ts` | `charts/tx.ts` | `playlists/tx.ts` | `playlists/search.ts` | `albums/tx.ts` | `albums/search.ts` |
| 咪咕 mg | `search/mg.ts` | `hotSearch/mg.ts` | `charts/mg.ts` | `playlists/mg.ts` | `playlists/search.ts` | `albums/mg.ts` | `albums/search.ts` |

View File

@ -26,6 +26,15 @@
"@tauri-apps/cli": "^2",
"@types/node": "^26.2.0",
"@types/qrcode": "^1.5.6",
"@vicons/antd": "^0.13.0",
"@vicons/carbon": "^0.13.0",
"@vicons/fa": "^0.13.0",
"@vicons/fluent": "^0.13.0",
"@vicons/ionicons4": "^0.13.0",
"@vicons/ionicons5": "^0.13.0",
"@vicons/material": "^0.13.0",
"@vicons/tabler": "^0.13.0",
"@vicons/utils": "^0.1.4",
"@vitejs/plugin-vue": "^5.2.1",
"sass": "^1.102.0",
"typescript": "~5.6.2",

View File

@ -51,6 +51,33 @@ importers:
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@vicons/antd':
specifier: ^0.13.0
version: 0.13.0
'@vicons/carbon':
specifier: ^0.13.0
version: 0.13.0
'@vicons/fa':
specifier: ^0.13.0
version: 0.13.0
'@vicons/fluent':
specifier: ^0.13.0
version: 0.13.0
'@vicons/ionicons4':
specifier: ^0.13.0
version: 0.13.0
'@vicons/ionicons5':
specifier: ^0.13.0
version: 0.13.0
'@vicons/material':
specifier: ^0.13.0
version: 0.13.0
'@vicons/tabler':
specifier: ^0.13.0
version: 0.13.0
'@vicons/utils':
specifier: ^0.1.4
version: 0.1.4(vue@3.5.41(typescript@5.6.2))
'@vitejs/plugin-vue':
specifier: ^5.2.1
version: 5.2.1(vite@6.4.3(@types/node@26.2.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.6.2))
@ -627,12 +654,44 @@ packages:
'@types/lodash@4.17.25':
resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==}
'@types/node@14.14.31':
resolution: {integrity: sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g==}
'@types/node@26.2.0':
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@vicons/antd@0.13.0':
resolution: {integrity: sha512-yrUGoUSz2BbGupk9ghQOahc04n5H3MwUDM9pVPsLh9U1uqB47oRWZvYRiZaT1JKPqgTgSE6BXcVw4i9MOF4M+g==}
'@vicons/carbon@0.13.0':
resolution: {integrity: sha512-Z/jExyyS4gsXJc66oTqV/j98nsaiX2JlQ0IUwu9Ms3rztf8VOHEQRuX8Jey1/zbxJpFY/tU+bWvKPRFYGIvCWQ==}
'@vicons/fa@0.13.0':
resolution: {integrity: sha512-BFcDewcT78fSn4Y/fOgqlswbLUEW3+qJK2iJiNtgmkMzadBVpDXhNyVKsYM3V2uKPvDUrZT0JCWDWVRCiBXJZA==}
'@vicons/fluent@0.13.0':
resolution: {integrity: sha512-bYGZsOE3qzvm3Cm43e7tybgGlr5ZUpYqtRZq0g0Tfupe8jIzLolpvQLNUt1zS8Mgt6goTbUk5YH7Fkv16jkykg==}
'@vicons/ionicons4@0.13.0':
resolution: {integrity: sha512-5WHIl/4R5a4i9GONa+hIQWxg/WczrbsCdqxawHZvdd3drsEr+Q3yzlfS+NNRO4WS3uDW2uWLCwoW+yp5TgcKeQ==}
'@vicons/ionicons5@0.13.0':
resolution: {integrity: sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==}
'@vicons/material@0.13.0':
resolution: {integrity: sha512-lKVxFNprM+CaBkUH3gt6VjIeiMsKQl2zARQMwTCZruQl2vRHzyeZiKeCflWS99CEfv2JzX/6y697smxlzyxcVw==}
'@vicons/tabler@0.13.0':
resolution: {integrity: sha512-AykuhiqjszkIoAL/7knIFm6RDOBS1ZmQdJfQ+RNLEah0fVsxykUFCfMBSNZh8lOzC85EtdD1k5g/sv5GYk0Ohg==}
'@vicons/utils@0.1.4':
resolution: {integrity: sha512-OHI19qVNN6i+uPQ+Y3f2s0dUxwsYnOCcKBW7XOU4yXXO1aU3ZoKpblCc3+4N0qmgoJs5rWKRAaMisipqEXJwAg==}
peerDependencies:
vue: ^3.0.6
'@vitejs/plugin-vue@5.2.1':
resolution: {integrity: sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==}
engines: {node: ^18.0.0 || >=20.0.0}
@ -705,6 +764,9 @@ packages:
'@vue/shared@3.5.41':
resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==}
'@xicons/utils@0.1.4':
resolution: {integrity: sha512-uXxKDLz9abr80yJC05XSTq6wlyFcdW+N/1IYJkeHjzzXVc4VQ0sEYMoMMTjAH7HQBOyOkzOB4pf5NGF72lwa8Q==}
acorn@8.18.0:
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
engines: {node: '>=0.4.0'}
@ -787,6 +849,9 @@ packages:
confbox@0.2.4:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
css-render@0.13.9:
resolution: {integrity: sha512-n3C4ZH59rveBrUlAD7n0Ze9/gUMKa4dlH1C9CWKpGcIHR/xRcIVXzBGy1iw8WWq2ySmn2/ZqOpySQNAK5Pb6sw==}
css-render@0.15.14:
resolution: {integrity: sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==}
@ -1750,6 +1815,8 @@ snapshots:
'@types/lodash@4.17.25': {}
'@types/node@14.14.31': {}
'@types/node@26.2.0':
dependencies:
undici-types: 8.3.0
@ -1758,6 +1825,27 @@ snapshots:
dependencies:
'@types/node': 26.2.0
'@vicons/antd@0.13.0': {}
'@vicons/carbon@0.13.0': {}
'@vicons/fa@0.13.0': {}
'@vicons/fluent@0.13.0': {}
'@vicons/ionicons4@0.13.0': {}
'@vicons/ionicons5@0.13.0': {}
'@vicons/material@0.13.0': {}
'@vicons/tabler@0.13.0': {}
'@vicons/utils@0.1.4(vue@3.5.41(typescript@5.6.2))':
dependencies:
'@xicons/utils': 0.1.4
vue: 3.5.41(typescript@5.6.2)
'@vitejs/plugin-vue@5.2.1(vite@6.4.3(@types/node@26.2.0)(sass@1.102.0)(yaml@2.9.0))(vue@3.5.41(typescript@5.6.2))':
dependencies:
vite: 6.4.3(@types/node@26.2.0)(sass@1.102.0)(yaml@2.9.0)
@ -1870,6 +1958,10 @@ snapshots:
'@vue/shared@3.5.41': {}
'@xicons/utils@0.1.4':
dependencies:
css-render: 0.13.9
acorn@8.18.0: {}
agent-base@6.0.0:
@ -1955,6 +2047,12 @@ snapshots:
confbox@0.2.4: {}
css-render@0.13.9:
dependencies:
'@emotion/hash': 0.8.0
'@types/node': 14.14.31
csstype: 3.0.11
css-render@0.15.14:
dependencies:
'@emotion/hash': 0.8.0

View File

@ -5,6 +5,11 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"opener:default",
"store:default"
]

View File

@ -9,11 +9,20 @@ use services::db::init_pool;
use services::http::HttpClient;
use tauri::Manager;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.setup(|app| {
#[cfg(target_os = "macos")]
if let Some(window) = app.get_webview_window("main") {
window.set_decorations(true)?;
window.set_title_bar_style(TitleBarStyle::Transparent)?;
}
let pool = tauri::async_runtime::block_on(async {
init_pool().await.expect("sqlite数据库连接池初始化失败!")
});

View File

@ -13,8 +13,12 @@
"windows": [
{
"title": "YwYMusic",
"width": 1160,
"height": 660
"width": 1100,
"height": 700,
"decorations": false,
"transparent": true,
"resizable": false,
"dragDropEnabled": false
}
],
"security": {

View File

@ -1,10 +1,31 @@
<template>
<div class="app-root">
<div class="titlebar" :class="{ 'titlebar-macos': isMacos }">
<div class="titlebar-drag" data-tauri-drag-region @mousedown="handleTitlebarMouseDown">
<span class="titlebar-dot"></span>
<span class="titlebar-title">YwYMusic</span>
</div>
<div class="titlebar-controls">
<button type="button" aria-label="最小化" @click="minimizeWindow">
<span class="window-icon window-icon-minimize"></span>
</button>
<button type="button" aria-label="最大化或还原" @click="toggleMaximizeWindow">
<span class="window-icon window-icon-maximize"></span>
</button>
<button type="button" class="titlebar-close" aria-label="关闭" @click="closeWindow">
<span class="window-icon window-icon-close"></span>
</button>
</div>
</div>
<n-config-provider :theme-overrides="themeOverrides">
<n-message-provider>
<n-notification-provider>
<n-dialog-provider>
<RouterView />
<div class="app-content">
<RouterView />
</div>
</n-dialog-provider>
</n-notification-provider>
</n-message-provider>
@ -20,8 +41,12 @@ import {
NNotificationProvider,
type GlobalThemeOverrides,
} from "naive-ui";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { RouterView } from "vue-router";
const appWindow = getCurrentWindow();
const isMacos = navigator.userAgent.includes("Mac OS X");
const themeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: "#b4532a",
@ -50,10 +75,168 @@ const themeOverrides: GlobalThemeOverrides = {
"'Inter','HarmonyOS Sans SC','PingFang SC','Microsoft YaHei',system-ui,-apple-system,sans-serif",
},
};
function minimizeWindow() {
void appWindow.minimize();
}
function toggleMaximizeWindow() {
void appWindow.toggleMaximize();
}
function closeWindow() {
void appWindow.close();
}
function handleTitlebarMouseDown(event: MouseEvent) {
if (event.detail === 2) {
void appWindow.toggleMaximize();
}
}
</script>
<style scoped>
.app-root {
height: 100%;
padding-top: var(--titlebar-height);
background: var(--app-bg);
}
.titlebar {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 9999;
display: grid;
grid-template-columns: 1fr auto;
height: var(--titlebar-height);
overflow: hidden;
user-select: none;
background: rgba(234, 220, 200, 0.86);
border-bottom: 1px solid rgba(180, 83, 42, 0.12);
backdrop-filter: blur(16px);
}
.titlebar-drag {
display: flex;
align-items: center;
height: 100%;
gap: 8px;
padding-left: 14px;
}
.titlebar-macos {
background: rgba(244, 237, 223, 0.72);
}
.titlebar-macos .titlebar-drag {
padding-left: 78px;
}
.titlebar-macos .titlebar-controls,
.titlebar-macos .titlebar-dot {
display: none;
}
.titlebar-macos .titlebar-title {
color: var(--app-text-muted);
}
.titlebar-dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: linear-gradient(135deg, #d98b5f 0%, var(--app-primary) 100%);
box-shadow: 0 0 0 3px rgba(180, 83, 42, 0.12);
}
.titlebar-title {
color: var(--app-text-secondary);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.2px;
}
.titlebar-controls {
display: flex;
height: 100%;
}
.titlebar-controls button {
display: grid;
place-items: center;
width: 46px;
height: 100%;
padding: 0;
color: var(--app-text-secondary);
background: transparent;
border: 0;
cursor: pointer;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
.titlebar-controls button:hover {
color: var(--app-text);
background: rgba(180, 83, 42, 0.1);
}
.titlebar-controls .titlebar-close:hover {
color: #fffaf0;
background: #d83b32;
}
.window-icon {
position: relative;
display: block;
width: 12px;
height: 12px;
}
.window-icon-minimize::before,
.window-icon-maximize::before,
.window-icon-close::before,
.window-icon-close::after {
position: absolute;
content: "";
}
.window-icon-minimize::before {
right: 1px;
bottom: 3px;
left: 1px;
height: 1.5px;
background: currentColor;
}
.window-icon-maximize::before {
inset: 1px;
border: 1.5px solid currentColor;
border-radius: 1px;
}
.window-icon-close::before,
.window-icon-close::after {
top: 5px;
left: 0;
width: 12px;
height: 1.5px;
background: currentColor;
border-radius: 999px;
}
.window-icon-close::before {
transform: rotate(45deg);
}
.window-icon-close::after {
transform: rotate(-45deg);
}
.app-content {
height: calc(100vh - var(--titlebar-height));
min-height: 0;
}
</style>

View File

@ -133,6 +133,7 @@ const lyrics = ref<LyricLine[]>([]);
function parseLyrics(raw: string): LyricLine[] {
const result: LyricLine[] = [];
const seen = new Set<string>();
const timePattern = /\[(\d{1,3}):(\d{2})(?:[.:](\d{1,3}))?\]/g;
for (const line of raw.split(/\r?\n/)) {
@ -151,10 +152,12 @@ function parseLyrics(raw: string): LyricLine[] {
: fraction.length === 2
? Number(fraction) * 10
: Number(fraction.slice(0, 3));
result.push({
time: minutes * 60 + seconds + milliseconds / 1000,
text,
});
const time = minutes * 60 + seconds + milliseconds / 1000;
const key = `${time.toFixed(3)}\n${text}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({ time, text });
}
}

View File

@ -1,98 +1,129 @@
<template>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<!-- <span class="brand-icon"><AppIcon name="disc" :size="21" /></span> -->
<img class="brand-logo" src="/src/assets/logo.png" alt="YwYMusic logo" />
<!-- <span class="brand-name">YwYMusic</span> -->
</div>
<n-anchor
class="nav"
:show-rail="false"
:show-background="false"
@click="handleNavClick"
<n-layout class="workspace" has-sider>
<n-layout-sider
class="sidebar-sider"
:class="{ 'sidebar-sider--collapsed': collapsed }"
bordered
collapse-mode="width"
:collapsed-width="72"
:width="240"
:collapsed="collapsed"
:native-scrollbar="false"
show-trigger
@collapse="collapsed = true"
@expand="collapsed = false"
>
<n-anchor-link
v-for="item in navItems"
:key="item.path"
:href="item.path"
:title="item.label"
class="nav-item"
:class="{ active: isActive(item.path) }"
>
<template #title>
<AppIcon :name="item.icon" :size="18" />
<span>{{ item.label }}</span>
</template>
</n-anchor-link>
</n-anchor>
<div class="sidebar-content">
<n-menu
class="nav-menu"
:collapsed="collapsed"
:collapsed-width="72"
:collapsed-icon-size="20"
:value="activeMenuKey"
:options="menuOptions"
@update:value="handleMenuSelect"
/>
<n-anchor
class="nav-settings"
:show-rail="false"
:show-background="false"
@click="handleNavClick"
>
<n-anchor-link
href="/settings"
title="设置"
class="nav-item"
:class="{ active: isActive('/settings') }"
>
<template #title>
<AppIcon name="settings" :size="18" />
<span>设置</span>
</template>
</n-anchor-link>
</n-anchor>
<div class="nav-settings">
<n-menu
:collapsed="collapsed"
:collapsed-width="72"
:collapsed-icon-size="20"
:value="activeMenuKey"
:options="settingsOptions"
@update:value="handleMenuSelect"
/>
</div>
</div>
</n-layout-sider>
<!-- <div class="sidebar-footer">v0.1.0 · 轻量在线播放</div> -->
</aside>
<n-layout-content ref="contentRef" class="content-layout" :native-scrollbar="false">
<main class="content">
<RouterView />
</main>
</n-layout-content>
</n-layout>
<div class="main-area">
<main ref="contentRef" class="content">
<RouterView />
</main>
<n-layout-footer class="playerbar-footer">
<PlayerBar />
</div>
</n-layout-footer>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from "vue"
import { computed, h, onMounted, onUnmounted, ref, watch } from "vue"
import type { Component } from "vue"
import { RouterView, useRoute, useRouter } from "vue-router"
import AppIcon from "@/components/AppIcon.vue"
import type { MenuOption } from "naive-ui"
import type { LayoutInst } from "naive-ui/es/layout"
import { NIcon } from "naive-ui"
import { MusicalNotesOutline, SearchOutline, SettingsOutline, TrophyOutline } from "@vicons/ionicons5"
import { Playlist } from "@vicons/tabler"
import PlayerBar from "@/components/PlayerBar.vue"
const route = useRoute()
const router = useRouter()
const contentRef = ref<HTMLElement | null>(null)
const contentRef = ref<LayoutInst | null>(null)
const collapsed = ref(false)
const navItems = [
{ path: "/search", label: "搜索", icon: "search" },
{ path: "/", label: "歌单", icon: "home" },
{ path: "/ranking", label: "排行榜", icon: "trophy" },
{ path: "/library", label: "我的音乐", icon: "music" },
// { path: "/demo", label: "", icon: "play" },
] as const
const menuOptions: MenuOption[] = [
createMenuOption("/search", "搜索", "search"),
createMenuOption("/", "歌单", "home"),
createMenuOption("/ranking", "排行榜", "trophy"),
createMenuOption("/library", "我的音乐", "music"),
]
function isActive(path: string) {
if (path === "/") return route.path === "/"
return route.path.startsWith(path)
const settingsOptions: MenuOption[] = [createMenuOption("/settings", "设置", "settings")]
const activeMenuKey = computed(() => {
if (route.path === "/") return "/"
if (route.path.startsWith("/settings")) return "/settings"
const activeOption = menuOptions.find((option) => {
const key = String(option.key)
return key !== "/" && route.path.startsWith(key)
})
return String(activeOption?.key ?? route.path)
})
const viewportQuery = window.matchMedia("(max-width: 1100px)")
const menuIcons: Record<string, Component> = {
search: SearchOutline,
home: Playlist,
trophy: TrophyOutline,
music: MusicalNotesOutline,
settings: SettingsOutline,
}
function handleNavClick(event: MouseEvent) {
const target = event.target
if (!(target instanceof Element)) return
const link = target.closest<HTMLAnchorElement>("a[href]")
if (!link) return
event.preventDefault()
void router.push(link.getAttribute("href") ?? "/")
function createMenuOption(key: string, label: string, icon: string): MenuOption {
return {
key,
label,
icon: () => h(NIcon, { size: 20 }, { default: () => h(menuIcons[icon]) }),
}
}
function handleMenuSelect(key: string | number) {
const path = String(key)
if (path !== route.path) void router.push(path)
}
function handleViewportChange(event: MediaQueryListEvent) {
collapsed.value = event.matches
}
onMounted(() => {
collapsed.value = viewportQuery.matches
viewportQuery.addEventListener("change", handleViewportChange)
})
onUnmounted(() => {
viewportQuery.removeEventListener("change", handleViewportChange)
})
watch(
() => route.path,
() => {
@ -103,232 +134,143 @@ watch(
<style scoped>
.app-shell {
display: flex;
height: 100vh;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
height: 100%;
min-width: 0;
overflow: hidden;
background: var(--app-bg);
}
.workspace {
height: 100%;
min-height: 0;
overflow: hidden;
}
.sidebar {
.sidebar-sider {
min-height: 0;
min-width: 0;
overflow: visible;
background: linear-gradient(180deg, #eadcc8 0%, #f4eddf 100%);
}
.sidebar-content {
display: flex;
flex-direction: column;
width: clamp(148px, 12vw, 172px);
height: 100vh;
flex-shrink: 0;
height: 100%;
min-width: 0;
padding: 20px 14px 14px;
background: linear-gradient(180deg, #eadcc8 0%, #f4eddf 100%);
border-right: 1px solid var(--app-border);
overflow-x: hidden;
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 0 10px 18px;
}
.brand-icon {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(135deg, #d98b5f 0%, #b4532a 100%);
color: #fffaf0;
box-shadow: 0 6px 14px rgba(180, 83, 42, 0.25);
}
.brand-logo {
display: block;
width: 64px;
height: 64px;
object-fit: contain;
margin: 0 auto;
}
.brand-name {
font-size: 17px;
font-weight: 700;
letter-spacing: 0.2px;
}
.nav {
display: flex;
flex-direction: column;
gap: 4px;
.nav-menu {
flex: 1;
min-height: 0;
min-width: 0;
}
.nav-settings {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--app-border);
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
.nav-settings--collapsed {
padding-top: 10px;
}
.nav-menu :deep(.n-menu-item-content),
.nav-settings :deep(.n-menu-item-content) {
border-radius: 8px;
font-size: 14px;
color: var(--app-text-secondary);
text-decoration: none;
cursor: pointer;
transition:
color 0.15s ease,
background-color 0.15s ease;
box-shadow: none;
}
.nav-item :deep(.n-anchor-link__title) {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
gap: 10px;
text-align: left;
}
.nav-item:hover {
.nav-menu :deep(.n-menu-item-content:hover),
.nav-settings :deep(.n-menu-item-content:hover) {
color: var(--app-text);
background: rgba(180, 83, 42, 0.08);
box-shadow: none;
}
.nav-item.active {
color: var(--app-primary);
.nav-menu :deep(.n-menu-item-content--selected),
.nav-settings :deep(.n-menu-item-content--selected) {
color: var(--app-primary);
font-weight: 600;
background: linear-gradient(90deg, rgba(180, 83, 42, 0.16), rgba(180, 83, 42, 0.05));
box-shadow: none;
}
.nav-section {
flex: 1;
margin-top: 24px;
overflow-y: auto;
.nav-menu :deep(.n-menu-item-content::before),
.nav-settings :deep(.n-menu-item-content::before) {
box-shadow: none;
}
.section-title {
margin: 0;
padding: 0 12px 8px;
font-size: 12px;
letter-spacing: 1px;
color: var(--app-text-muted);
.nav-menu :deep(.n-menu-item-content:hover::before),
.nav-settings :deep(.n-menu-item-content:hover::before),
.nav-menu :deep(.n-menu-item-content--selected::before),
.nav-settings :deep(.n-menu-item-content--selected::before) {
box-shadow: none;
}
.nav-sub {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 8px;
font-size: 13px;
color: var(--app-text-secondary);
text-decoration: none;
cursor: pointer;
transition:
color 0.15s ease,
background-color 0.15s ease;
/* The menu keeps its own item padding when collapsed. Reset the outer padding
and give every item a stable hit area so icons stay centered in 72px. */
.sidebar-sider--collapsed .sidebar-content {
width: 72px;
padding-left: 0;
padding-right: 0;
}
.nav-sub:hover {
color: var(--app-text);
background: rgba(180, 83, 42, 0.08);
.sidebar-sider--collapsed .nav-menu,
.sidebar-sider--collapsed .nav-settings {
width: 72px;
min-width: 72px;
}
.nav-sub.active {
color: var(--app-green);
background: rgba(112, 132, 91, 0.12);
.sidebar-sider--collapsed .nav-menu :deep(.n-menu-item-content),
.sidebar-sider--collapsed .nav-settings :deep(.n-menu-item-content) {
width: 48px;
min-width: 48px;
height: 48px;
margin-inline: auto;
padding-inline: 0;
justify-content: center;
}
.sub-dot {
width: 22px;
height: 22px;
border-radius: 6px;
flex-shrink: 0;
.sidebar-sider--collapsed .nav-menu :deep(.n-menu-item-content__icon),
.sidebar-sider--collapsed .nav-settings :deep(.n-menu-item-content__icon) {
margin-right: 0;
}
.sub-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.sidebar-sider :deep(.n-layout-sider-scroll-container) {
overflow-x: hidden;
}
.sidebar-footer {
margin-top: 14px;
padding: 12px 10px 0;
border-top: 1px solid var(--app-border);
font-size: 11px;
color: var(--app-text-muted);
.sidebar-sider :deep(.n-scrollbar-container),
.sidebar-sider :deep(.n-scrollbar-content) {
overflow-x: hidden;
}
.main-area {
display: flex;
flex-direction: column;
flex: 1;
height: 100vh;
.sidebar-sider :deep(.n-layout-toggle-button) {
z-index: 3;
box-shadow: none;
}
.sidebar-sider :deep(.n-layout-sider__border) {
z-index: 2;
}
.content-layout {
min-width: 0;
min-height: 0;
}
.content {
flex: 1;
overflow-y: auto;
padding: clamp(18px, 3vw, 28px) clamp(18px, 3vw, 32px) 44px;
min-height: 0;
min-width: 0;
padding: clamp(18px, 3vw, 28px) clamp(18px, 3vw, 32px) 44px;
}
/* 窗口较窄时,侧边栏折叠为纯图标导航 */
@media (max-width: 1100px) {
.sidebar {
width: 72px;
padding: 20px 10px 14px;
align-items: center;
}
.brand {
justify-content: center;
padding: 0 0 18px;
}
.brand-name,
.sidebar-footer,
.section-title {
display: none;
}
.nav {
width: 100%;
}
.nav-settings {
width: 100%;
margin-top: auto;
}
.nav-item {
justify-content: center;
padding: 10px 0;
}
.nav-item :deep(.n-anchor-link__title) {
justify-content: center;
width: auto;
}
.nav-item span,
.nav-sub .sub-name {
display: none;
}
.nav-section {
width: 100%;
margin-top: 20px;
}
.nav-sub {
justify-content: center;
padding: 8px 0;
}
.playerbar-footer {
background: transparent;
}
</style>

View File

@ -1,12 +1,5 @@
import { createRouter, createWebHashHistory } from "vue-router";
import MainLayout from "@/layouts/MainLayout.vue";
import DiscoverView from "@/views/DiscoverView.vue";
import LibraryView from "@/views/LibraryView.vue";
import PlaylistDetailView from "@/views/PlaylistDetailView.vue";
import RankingView from "@/views/RankingView.vue";
import SearchView from "@/views/SearchView.vue";
import SettingsView from "@/views/SettingsView.vue";
import PlayMusic from "@/views/PlayMusic.vue";
import { createRouter, createWebHashHistory } from "vue-router"
import MainLayout from "@/layouts/MainLayout.vue"
const router = createRouter({
history: createWebHashHistory(),
@ -15,21 +8,32 @@ const router = createRouter({
path: "/",
component: MainLayout,
children: [
{ path: "", name: "discover", component: DiscoverView },
{ path: "ranking", name: "ranking", component: RankingView },
{ path: "library", name: "library", component: LibraryView },
{ path: "", name: "discover", component: () => import("@/views/DiscoverView.vue") },
{ path: "ranking", name: "ranking", component: () => import("@/views/RankingView.vue") },
{ path: "library", name: "library", component: () => import("@/views/LibraryView.vue") },
{
path: "playlist/:id",
name: "playlist",
component: PlaylistDetailView,
component: () => import("@/views/PlaylistDetailView.vue"),
props: true,
},
{ path: "search", name: "search", component: SearchView },
{ path: "settings", name: "settings", component: SettingsView },
{ path: "demo", name: "demo", component: PlayMusic },
{ path: "search", name: "search", component: () => import("@/views/SearchView.vue") },
{
path: "settings",
name: "settings",
component: () => import("@/views/SettingsView.vue"),
redirect: "/settings/source",
children: [
{
path: "source",
name: "settings-source",
component: () => import("@/views/setting/SourceSetting.vue"),
},
],
},
],
},
],
});
})
export default router;
export default router

View File

@ -1,4 +1,5 @@
:root {
--titlebar-height: 34px;
--app-bg: #f4eddf;
--app-bg-elevated: #eee2d1;
--app-card: #fffaf0;
@ -10,6 +11,13 @@
--app-green: #70845b;
}
/* Keep top-aligned messages below the custom titlebar, including discrete APIs. */
.n-message-container.n-message-container--top,
.n-message-container.n-message-container--top-left,
.n-message-container.n-message-container--top-right {
top: calc(12px + var(--titlebar-height)) !important;
}
* {
box-sizing: border-box;
}

View File

@ -1,79 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
/* oxlint-disable */
/* oxfmt-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const effectScope: typeof import('vue').effectScope
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useDialog: typeof import('naive-ui').useDialog
const useId: typeof import('vue').useId
const useLoadingBar: typeof import('naive-ui').useLoadingBar
const useMessage: typeof import('naive-ui').useMessage
const useModel: typeof import('vue').useModel
const useNotification: typeof import('naive-ui').useNotification
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

View File

@ -14,9 +14,13 @@ declare module 'vue' {
AppIcon: typeof import('./../components/AppIcon.vue')['default']
FullscreenPlayer: typeof import('./../components/FullscreenPlayer.vue')['default']
MusicCard: typeof import('./../components/MusicCard.vue')['default']
NAnchor: typeof import('naive-ui')['NAnchor']
NAnchorLink: typeof import('naive-ui')['NAnchorLink']
NBackTop: typeof import('naive-ui')['NBackTop']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NLayout: typeof import('naive-ui')['NLayout']
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
NLayoutFooter: typeof import('naive-ui')['NLayoutFooter']
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NMenu: typeof import('naive-ui')['NMenu']
NRadio: typeof import('naive-ui')['NRadio']
PlayerBar: typeof import('./../components/PlayerBar.vue')['default']
QrLogin: typeof import('./../components/QrLogin.vue')['default']

View File

@ -5,18 +5,8 @@
<h3>分类</h3>
<span class="section-sub">歌单分类</span>
</div>
<n-tabs
v-model:value="activeCategory"
type="line"
animated
@update:value="playList"
>
<n-tab-pane
v-for="category in categories"
:key="category.id"
:name="category.id"
:tab="category.name"
/>
<n-tabs v-model:value="activeCategory" type="line" animated @update:value="playList">
<n-tab-pane v-for="category in categories" :key="category.id" :name="category.id" :tab="category.name" />
</n-tabs>
<n-spin :show="loading" class="card-spin">
<div class="card-grid">
@ -42,11 +32,11 @@
<MusicCard v-for="playlist in recommendedPlaylists" :key="playlist.id" :playlist="playlist" />
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue"
import PlayListApi, { type PlayListCategory, type PlayListSummary } from "@/api/playlist"
import MusicCard from "@/components/MusicCard.vue"
import { useSettingsStore } from "@/stores/settings"

View File

@ -1,18 +0,0 @@
<template>
<div>
<img :src="img" />
<audio :src="url" controls autoplay />
<p>{{ lyric }}</p>
</div>
</template>
<script setup lang="ts">
const baseUrl = ref("http://localhost:81");
const url = ref(`${baseUrl.value}/api?server=kugou&type=url&id=7de2123314993994df18ee5868ebe4db&auth=6f3bbab9f99d27e6c7b395bf5901fc3c92cb740b`);
const img = ref(`${baseUrl.value}/api?server=kugou&type=pic&id=7de2123314993994df18ee5868ebe4db&auth=3e901a387dc52eab07527a078419a7830ffacf11`)
const lyric = ref(`${baseUrl.value}/api?server=kugou&type=lrc&id=7de2123314993994df18ee5868ebe4db&auth=cf4f297474bb697ccc2d213f8cb25712be6cbb3a`);
</script>
<style scoped>
</style>

View File

@ -20,7 +20,7 @@
<div class="source-bar">
<n-tabs
v-model:value="selectedSource"
type="line"
type="segment"
animated
@update:value="onSourceChange"
>
@ -243,7 +243,7 @@ async function submitSearch() {
async function searchWith(kw: string) {
keyword.value = kw
searchHistoryStore.add(kw)
message.success(`搜索「${kw}`)
// message.success(`${kw}`)
}
async function removeItem(item: string, event: MouseEvent) {

View File

@ -1,167 +1,123 @@
<template>
<div class="page">
<div class="page-head">
<h2>设置</h2>
<p>配置音乐来源之后的请求会使用你选择的平台更改自动保存</p>
</div>
<div class="settings-layout">
<aside class="settings-sidebar">
<!-- <div class="settings-heading">
<span class="settings-eyebrow">PREFERENCES</span>
<h2>设置</h2>
<p>管理应用偏好与播放体验</p>
</div> -->
<section class="setting-card">
<div class="setting-head">
<h3>音乐源</h3>
<p>勾选后搜索等请求会同时在所选平台查找至少保留一个</p>
</div>
<n-checkbox-group :value="settingsStore.enabledSources" @update:value="settingsStore.setEnabledSources">
<div class="source-grid">
<div
v-for="source in ALL_SOURCES"
:key="source"
class="source-item"
:class="{ checked: settingsStore.enabledSources.includes(source) }"
>
<n-checkbox :value="source" />
<span class="source-label">{{ SOURCE_LABELS[source] }}</span>
<span class="source-key">{{ source }}</span>
</div>
</div>
</n-checkbox-group>
</section>
<n-menu
:value="activeSetting"
:options="settingOptions"
class="settings-menu"
@update:value="handleSettingChange"
/>
</aside>
<section class="setting-card">
<div class="setting-head">
<h3>默认音乐源</h3>
<p>可从全部音乐源中任选一个作为默认不要求已勾选用于歌单详情分类浏览以及歌曲未携带平台信息时解析播放直链</p>
</div>
<n-radio-group
:value="settingsStore.defaultSource"
@update:value="settingsStore.setDefaultSource"
class="default-group"
>
<n-radio
v-for="source in ALL_SOURCES"
:key="source"
:value="source"
:label="SOURCE_LABELS[source]"
class="default-radio"
/>
</n-radio-group>
</section>
<QrLogin />
<main class="settings-content">
<RouterView />
</main>
</div>
</template>
<script setup lang="ts">
import { NCheckboxGroup, NRadioGroup } from "naive-ui"
import QrLogin from "@/components/QrLogin.vue"
import { ALL_SOURCES, SOURCE_LABELS, useSettingsStore } from "@/stores/settings"
import { RouterView, useRoute, useRouter } from "vue-router"
import type { MenuOption } from "naive-ui"
const settingsStore = useSettingsStore()
const route = useRoute()
const router = useRouter()
const settingOptions: MenuOption[] = [
{
label: "音乐源",
key: "settings-source",
description: "搜索与解析来源",
},
{
label: "外观",
key: "settings-appearance",
description: "主题与布局设置",
}
]
const activeSetting = computed(() => String(route.name ?? "settings-source"))
function handleSettingChange(key: string) {
void router.push({ name: key })
}
</script>
<style scoped>
.page {
max-width: 1100px;
.settings-layout {
display: grid;
grid-template-columns: 190px minmax(0, 1fr);
gap: clamp(24px, 4vw, 52px);
max-width: 1180px;
min-height: 100%;
margin: 0 auto;
}
.page-head {
margin-bottom: 24px;
.settings-sidebar {
position: sticky;
top: 0;
align-self: start;
}
.page-head h2 {
margin: 0 0 4px;
font-size: clamp(22px, 2.5vw, 26px);
.settings-heading {
margin-bottom: 22px;
padding: 4px 12px 0;
}
.settings-eyebrow {
color: var(--app-primary);
font-size: 10px;
font-weight: 700;
letter-spacing: 1.6px;
}
.page-head p {
.settings-heading h2 {
margin: 5px 0 4px;
font-size: 26px;
line-height: 1.2;
}
.settings-heading p {
margin: 0;
font-size: 13px;
color: var(--app-text-muted);
}
.setting-card {
margin-bottom: 20px;
padding: clamp(16px, 2vw, 22px);
border: 1px solid var(--app-border);
border-radius: 14px;
background: rgba(255, 250, 240, 0.72);
}
.setting-head {
margin-bottom: 16px;
}
.setting-head h3 {
margin: 0 0 4px;
font-size: 16px;
font-weight: 600;
}
.setting-head p {
margin: 0;
font-size: 12px;
color: var(--app-text-muted);
line-height: 1.6;
}
.source-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 10px;
.settings-menu {
--n-item-text-color: var(--app-text-secondary);
--n-item-text-color-hover: var(--app-text);
--n-item-text-color-active: var(--app-primary);
--n-item-color-active: rgba(180, 83, 42, 0.12);
--n-item-color-active-hover: rgba(180, 83, 42, 0.16);
}
.source-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--app-border);
border-radius: 10px;
cursor: pointer;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
.settings-content {
min-width: 0;
}
.source-item:hover {
border-color: rgba(180, 83, 42, 0.5);
background: rgba(180, 83, 42, 0.08);
}
@media (max-width: 760px) {
.settings-layout {
grid-template-columns: 1fr;
gap: 18px;
}
.source-item.checked {
border-color: rgba(112, 132, 91, 0.58);
background: rgba(112, 132, 91, 0.1);
}
.settings-sidebar {
position: static;
}
.source-label {
font-size: 13px;
color: var(--app-text);
}
.settings-heading {
margin-bottom: 12px;
}
.source-key {
margin-left: auto;
font-size: 11px;
color: var(--app-text-muted);
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
}
.default-group {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
}
.default-radio {
padding: 8px 12px;
border: 1px solid var(--app-border);
border-radius: 10px;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.default-radio:hover {
border-color: rgba(180, 83, 42, 0.5);
background: rgba(180, 83, 42, 0.08);
.settings-menu {
display: flex;
overflow-x: auto;
}
}
</style>

View File

@ -0,0 +1,167 @@
<template>
<div class="page">
<div class="page-head">
<h2>设置</h2>
<p>配置音乐来源之后的请求会使用你选择的平台更改自动保存</p>
</div>
<section class="setting-card">
<div class="setting-head">
<h3>音乐源</h3>
<p>勾选后搜索等请求会同时在所选平台查找至少保留一个</p>
</div>
<n-checkbox-group :value="settingsStore.enabledSources" @update:value="settingsStore.setEnabledSources">
<div class="source-grid">
<div
v-for="source in ALL_SOURCES"
:key="source"
class="source-item"
:class="{ checked: settingsStore.enabledSources.includes(source) }"
>
<n-checkbox :value="source" />
<span class="source-label">{{ SOURCE_LABELS[source] }}</span>
<span class="source-key">{{ source }}</span>
</div>
</div>
</n-checkbox-group>
</section>
<section class="setting-card">
<div class="setting-head">
<h3>默认音乐源</h3>
<p>可从全部音乐源中任选一个作为默认不要求已勾选用于歌单详情分类浏览以及歌曲未携带平台信息时解析播放直链</p>
</div>
<n-radio-group
:value="settingsStore.defaultSource"
@update:value="settingsStore.setDefaultSource"
class="default-group"
>
<n-radio
v-for="source in ALL_SOURCES"
:key="source"
:value="source"
:label="SOURCE_LABELS[source]"
class="default-radio"
/>
</n-radio-group>
</section>
<QrLogin />
</div>
</template>
<script setup lang="ts">
import { NCheckboxGroup, NRadioGroup } from "naive-ui"
import QrLogin from "@/components/QrLogin.vue"
import { ALL_SOURCES, SOURCE_LABELS, useSettingsStore } from "@/stores/settings"
const settingsStore = useSettingsStore()
</script>
<style scoped>
.page {
max-width: 1100px;
margin: 0 auto;
}
.page-head {
margin-bottom: 24px;
}
.page-head h2 {
margin: 0 0 4px;
font-size: clamp(22px, 2.5vw, 26px);
font-weight: 700;
}
.page-head p {
margin: 0;
font-size: 13px;
color: var(--app-text-muted);
}
.setting-card {
margin-bottom: 20px;
padding: clamp(16px, 2vw, 22px);
border: 1px solid var(--app-border);
border-radius: 14px;
background: rgba(255, 250, 240, 0.72);
}
.setting-head {
margin-bottom: 16px;
}
.setting-head h3 {
margin: 0 0 4px;
font-size: 16px;
font-weight: 600;
}
.setting-head p {
margin: 0;
font-size: 12px;
color: var(--app-text-muted);
}
.source-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 10px;
}
.source-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--app-border);
border-radius: 10px;
cursor: pointer;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.source-item:hover {
border-color: rgba(180, 83, 42, 0.5);
background: rgba(180, 83, 42, 0.08);
}
.source-item.checked {
border-color: rgba(112, 132, 91, 0.58);
background: rgba(112, 132, 91, 0.1);
}
.source-label {
font-size: 13px;
color: var(--app-text);
}
.source-key {
margin-left: auto;
font-size: 11px;
color: var(--app-text-muted);
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
}
.default-group {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
}
.default-radio {
padding: 8px 12px;
border: 1px solid var(--app-border);
border-radius: 10px;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.default-radio:hover {
border-color: rgba(180, 83, 42, 0.5);
background: rgba(180, 83, 42, 0.08);
}
</style>