docs: 添加 AGENTS.md 文件提供项目开发指导
新增 AGENTS.md 文件,为 Codex AI 提供项目开发指南,包含: - 项目概述(Tauri 2 + Vue 3 + TypeScript 桌面音乐播放器) - 开发命令(安装依赖、运行开发服务器、构建等) - 架构说明(前端 Vue 3、后端 Tauri/Rust、自动导入配置) - API 请求模式和状态管理说明 - 关键文件列表和 TypeScript 配置 - 前端变更测试指导
This commit is contained in:
parent
d94d859128
commit
07d36496bc
|
|
@ -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
|
|
@ -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` 等 | 5–10 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(网易云是数字 id,QQ 是 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>`(zzcSign,search_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`
|
||||
|
||||
主路径 + 兜底(见 decisions:jadeite 可能 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`(UA:Dalvik/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` |
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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数据库连接池初始化失败!")
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,8 +13,12 @@
|
|||
"windows": [
|
||||
{
|
||||
"title": "YwYMusic",
|
||||
"width": 1160,
|
||||
"height": 660
|
||||
"width": 1100,
|
||||
"height": 700,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"resizable": false,
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
|
|
|||
183
src/App.vue
183
src/App.vue
|
|
@ -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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> -->
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<n-anchor
|
||||
class="nav"
|
||||
:show-rail="false"
|
||||
:show-background="false"
|
||||
@click="handleNavClick"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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="sidebar-footer">v0.1.0 · 轻量在线播放</div> -->
|
||||
</aside>
|
||||
|
||||
<div class="main-area">
|
||||
<main ref="contentRef" class="content">
|
||||
<n-layout-content ref="contentRef" class="content-layout" :native-scrollbar="false">
|
||||
<main class="content">
|
||||
<RouterView />
|
||||
</main>
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: clamp(148px, 12vw, 172px);
|
||||
height: 100vh;
|
||||
flex-shrink: 0;
|
||||
padding: 20px 14px 14px;
|
||||
.sidebar-sider {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
background: linear-gradient(180deg, #eadcc8 0%, #f4eddf 100%);
|
||||
border-right: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
.sidebar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px 18px;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 14px 14px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,167 +1,123 @@
|
|||
<template>
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div class="settings-layout">
|
||||
<aside class="settings-sidebar">
|
||||
<!-- <div class="settings-heading">
|
||||
<span class="settings-eyebrow">PREFERENCES</span>
|
||||
<h2>设置</h2>
|
||||
<p>配置音乐来源,之后的请求会使用你选择的平台(更改自动保存)</p>
|
||||
</div>
|
||||
<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-menu
|
||||
:value="activeSetting"
|
||||
:options="settingOptions"
|
||||
class="settings-menu"
|
||||
@update:value="handleSettingChange"
|
||||
/>
|
||||
</n-radio-group>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<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 {
|
||||
.settings-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.settings-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.settings-heading {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.settings-menu {
|
||||
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);
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue