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",
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/node": "^26.2.0",
|
"@types/node": "^26.2.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@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",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"sass": "^1.102.0",
|
"sass": "^1.102.0",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,33 @@ importers:
|
||||||
'@types/qrcode':
|
'@types/qrcode':
|
||||||
specifier: ^1.5.6
|
specifier: ^1.5.6
|
||||||
version: 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':
|
'@vitejs/plugin-vue':
|
||||||
specifier: ^5.2.1
|
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))
|
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':
|
'@types/lodash@4.17.25':
|
||||||
resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==}
|
resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==}
|
||||||
|
|
||||||
|
'@types/node@14.14.31':
|
||||||
|
resolution: {integrity: sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g==}
|
||||||
|
|
||||||
'@types/node@26.2.0':
|
'@types/node@26.2.0':
|
||||||
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
|
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
|
||||||
|
|
||||||
'@types/qrcode@1.5.6':
|
'@types/qrcode@1.5.6':
|
||||||
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
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':
|
'@vitejs/plugin-vue@5.2.1':
|
||||||
resolution: {integrity: sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==}
|
resolution: {integrity: sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
|
@ -705,6 +764,9 @@ packages:
|
||||||
'@vue/shared@3.5.41':
|
'@vue/shared@3.5.41':
|
||||||
resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==}
|
resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==}
|
||||||
|
|
||||||
|
'@xicons/utils@0.1.4':
|
||||||
|
resolution: {integrity: sha512-uXxKDLz9abr80yJC05XSTq6wlyFcdW+N/1IYJkeHjzzXVc4VQ0sEYMoMMTjAH7HQBOyOkzOB4pf5NGF72lwa8Q==}
|
||||||
|
|
||||||
acorn@8.18.0:
|
acorn@8.18.0:
|
||||||
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
|
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
@ -787,6 +849,9 @@ packages:
|
||||||
confbox@0.2.4:
|
confbox@0.2.4:
|
||||||
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
|
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:
|
css-render@0.15.14:
|
||||||
resolution: {integrity: sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==}
|
resolution: {integrity: sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==}
|
||||||
|
|
||||||
|
|
@ -1750,6 +1815,8 @@ snapshots:
|
||||||
|
|
||||||
'@types/lodash@4.17.25': {}
|
'@types/lodash@4.17.25': {}
|
||||||
|
|
||||||
|
'@types/node@14.14.31': {}
|
||||||
|
|
||||||
'@types/node@26.2.0':
|
'@types/node@26.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 8.3.0
|
undici-types: 8.3.0
|
||||||
|
|
@ -1758,6 +1825,27 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 26.2.0
|
'@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))':
|
'@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:
|
dependencies:
|
||||||
vite: 6.4.3(@types/node@26.2.0)(sass@1.102.0)(yaml@2.9.0)
|
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': {}
|
'@vue/shared@3.5.41': {}
|
||||||
|
|
||||||
|
'@xicons/utils@0.1.4':
|
||||||
|
dependencies:
|
||||||
|
css-render: 0.13.9
|
||||||
|
|
||||||
acorn@8.18.0: {}
|
acorn@8.18.0: {}
|
||||||
|
|
||||||
agent-base@6.0.0:
|
agent-base@6.0.0:
|
||||||
|
|
@ -1955,6 +2047,12 @@ snapshots:
|
||||||
|
|
||||||
confbox@0.2.4: {}
|
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:
|
css-render@0.15.14:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emotion/hash': 0.8.0
|
'@emotion/hash': 0.8.0
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"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",
|
"opener:default",
|
||||||
"store:default"
|
"store:default"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,20 @@ use services::db::init_pool;
|
||||||
use services::http::HttpClient;
|
use services::http::HttpClient;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use tauri::TitleBarStyle;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.setup(|app| {
|
.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 {
|
let pool = tauri::async_runtime::block_on(async {
|
||||||
init_pool().await.expect("sqlite数据库连接池初始化失败!")
|
init_pool().await.expect("sqlite数据库连接池初始化失败!")
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,12 @@
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "YwYMusic",
|
"title": "YwYMusic",
|
||||||
"width": 1160,
|
"width": 1100,
|
||||||
"height": 660
|
"height": 700,
|
||||||
|
"decorations": false,
|
||||||
|
"transparent": true,
|
||||||
|
"resizable": false,
|
||||||
|
"dragDropEnabled": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|
|
||||||
185
src/App.vue
185
src/App.vue
|
|
@ -1,10 +1,31 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="app-root">
|
<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-config-provider :theme-overrides="themeOverrides">
|
||||||
<n-message-provider>
|
<n-message-provider>
|
||||||
<n-notification-provider>
|
<n-notification-provider>
|
||||||
<n-dialog-provider>
|
<n-dialog-provider>
|
||||||
<RouterView />
|
<div class="app-content">
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
</n-dialog-provider>
|
</n-dialog-provider>
|
||||||
</n-notification-provider>
|
</n-notification-provider>
|
||||||
</n-message-provider>
|
</n-message-provider>
|
||||||
|
|
@ -20,8 +41,12 @@ import {
|
||||||
NNotificationProvider,
|
NNotificationProvider,
|
||||||
type GlobalThemeOverrides,
|
type GlobalThemeOverrides,
|
||||||
} from "naive-ui";
|
} from "naive-ui";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { RouterView } from "vue-router";
|
import { RouterView } from "vue-router";
|
||||||
|
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
const isMacos = navigator.userAgent.includes("Mac OS X");
|
||||||
|
|
||||||
const themeOverrides: GlobalThemeOverrides = {
|
const themeOverrides: GlobalThemeOverrides = {
|
||||||
common: {
|
common: {
|
||||||
primaryColor: "#b4532a",
|
primaryColor: "#b4532a",
|
||||||
|
|
@ -50,10 +75,168 @@ const themeOverrides: GlobalThemeOverrides = {
|
||||||
"'Inter','HarmonyOS Sans SC','PingFang SC','Microsoft YaHei',system-ui,-apple-system,sans-serif",
|
"'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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.app-root {
|
.app-root {
|
||||||
height: 100%;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,7 @@ const lyrics = ref<LyricLine[]>([]);
|
||||||
|
|
||||||
function parseLyrics(raw: string): LyricLine[] {
|
function parseLyrics(raw: string): LyricLine[] {
|
||||||
const result: LyricLine[] = [];
|
const result: LyricLine[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
const timePattern = /\[(\d{1,3}):(\d{2})(?:[.:](\d{1,3}))?\]/g;
|
const timePattern = /\[(\d{1,3}):(\d{2})(?:[.:](\d{1,3}))?\]/g;
|
||||||
|
|
||||||
for (const line of raw.split(/\r?\n/)) {
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
|
|
@ -151,10 +152,12 @@ function parseLyrics(raw: string): LyricLine[] {
|
||||||
: fraction.length === 2
|
: fraction.length === 2
|
||||||
? Number(fraction) * 10
|
? Number(fraction) * 10
|
||||||
: Number(fraction.slice(0, 3));
|
: Number(fraction.slice(0, 3));
|
||||||
result.push({
|
const time = minutes * 60 + seconds + milliseconds / 1000;
|
||||||
time: minutes * 60 + seconds + milliseconds / 1000,
|
const key = `${time.toFixed(3)}\n${text}`;
|
||||||
text,
|
if (seen.has(key)) continue;
|
||||||
});
|
|
||||||
|
seen.add(key);
|
||||||
|
result.push({ time, text });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,98 +1,129 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<aside class="sidebar">
|
<n-layout class="workspace" has-sider>
|
||||||
<div class="brand">
|
<n-layout-sider
|
||||||
<!-- <span class="brand-icon"><AppIcon name="disc" :size="21" /></span> -->
|
class="sidebar-sider"
|
||||||
<img class="brand-logo" src="/src/assets/logo.png" alt="YwYMusic logo" />
|
:class="{ 'sidebar-sider--collapsed': collapsed }"
|
||||||
<!-- <span class="brand-name">YwYMusic</span> -->
|
bordered
|
||||||
</div>
|
collapse-mode="width"
|
||||||
|
:collapsed-width="72"
|
||||||
<n-anchor
|
:width="240"
|
||||||
class="nav"
|
:collapsed="collapsed"
|
||||||
:show-rail="false"
|
:native-scrollbar="false"
|
||||||
:show-background="false"
|
show-trigger
|
||||||
@click="handleNavClick"
|
@collapse="collapsed = true"
|
||||||
|
@expand="collapsed = false"
|
||||||
>
|
>
|
||||||
<n-anchor-link
|
<div class="sidebar-content">
|
||||||
v-for="item in navItems"
|
<n-menu
|
||||||
:key="item.path"
|
class="nav-menu"
|
||||||
:href="item.path"
|
:collapsed="collapsed"
|
||||||
:title="item.label"
|
:collapsed-width="72"
|
||||||
class="nav-item"
|
:collapsed-icon-size="20"
|
||||||
:class="{ active: isActive(item.path) }"
|
:value="activeMenuKey"
|
||||||
>
|
:options="menuOptions"
|
||||||
<template #title>
|
@update:value="handleMenuSelect"
|
||||||
<AppIcon :name="item.icon" :size="18" />
|
/>
|
||||||
<span>{{ item.label }}</span>
|
|
||||||
</template>
|
|
||||||
</n-anchor-link>
|
|
||||||
</n-anchor>
|
|
||||||
|
|
||||||
<n-anchor
|
<div class="nav-settings">
|
||||||
class="nav-settings"
|
<n-menu
|
||||||
:show-rail="false"
|
:collapsed="collapsed"
|
||||||
:show-background="false"
|
:collapsed-width="72"
|
||||||
@click="handleNavClick"
|
:collapsed-icon-size="20"
|
||||||
>
|
:value="activeMenuKey"
|
||||||
<n-anchor-link
|
:options="settingsOptions"
|
||||||
href="/settings"
|
@update:value="handleMenuSelect"
|
||||||
title="设置"
|
/>
|
||||||
class="nav-item"
|
</div>
|
||||||
:class="{ active: isActive('/settings') }"
|
</div>
|
||||||
>
|
</n-layout-sider>
|
||||||
<template #title>
|
|
||||||
<AppIcon name="settings" :size="18" />
|
|
||||||
<span>设置</span>
|
|
||||||
</template>
|
|
||||||
</n-anchor-link>
|
|
||||||
</n-anchor>
|
|
||||||
|
|
||||||
<!-- <div class="sidebar-footer">v0.1.0 · 轻量在线播放</div> -->
|
<n-layout-content ref="contentRef" class="content-layout" :native-scrollbar="false">
|
||||||
</aside>
|
<main class="content">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</n-layout-content>
|
||||||
|
</n-layout>
|
||||||
|
|
||||||
<div class="main-area">
|
<n-layout-footer class="playerbar-footer">
|
||||||
<main ref="contentRef" class="content">
|
|
||||||
<RouterView />
|
|
||||||
</main>
|
|
||||||
<PlayerBar />
|
<PlayerBar />
|
||||||
</div>
|
</n-layout-footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { 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"
|
import PlayerBar from "@/components/PlayerBar.vue"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const contentRef = ref<HTMLElement | null>(null)
|
const contentRef = ref<LayoutInst | null>(null)
|
||||||
|
const collapsed = ref(false)
|
||||||
|
|
||||||
const navItems = [
|
const menuOptions: MenuOption[] = [
|
||||||
{ path: "/search", label: "搜索", icon: "search" },
|
createMenuOption("/search", "搜索", "search"),
|
||||||
{ path: "/", label: "歌单", icon: "home" },
|
createMenuOption("/", "歌单", "home"),
|
||||||
{ path: "/ranking", label: "排行榜", icon: "trophy" },
|
createMenuOption("/ranking", "排行榜", "trophy"),
|
||||||
{ path: "/library", label: "我的音乐", icon: "music" },
|
createMenuOption("/library", "我的音乐", "music"),
|
||||||
// { path: "/demo", label: "演示", icon: "play" },
|
]
|
||||||
] as const
|
|
||||||
|
|
||||||
function isActive(path: string) {
|
const settingsOptions: MenuOption[] = [createMenuOption("/settings", "设置", "settings")]
|
||||||
if (path === "/") return route.path === "/"
|
|
||||||
return route.path.startsWith(path)
|
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) {
|
function createMenuOption(key: string, label: string, icon: string): MenuOption {
|
||||||
const target = event.target
|
return {
|
||||||
if (!(target instanceof Element)) return
|
key,
|
||||||
|
label,
|
||||||
const link = target.closest<HTMLAnchorElement>("a[href]")
|
icon: () => h(NIcon, { size: 20 }, { default: () => h(menuIcons[icon]) }),
|
||||||
if (!link) return
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
void router.push(link.getAttribute("href") ?? "/")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
watch(
|
||||||
() => route.path,
|
() => route.path,
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -103,232 +134,143 @@ watch(
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.app-shell {
|
.app-shell {
|
||||||
display: flex;
|
display: grid;
|
||||||
height: 100vh;
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: clamp(148px, 12vw, 172px);
|
height: 100%;
|
||||||
height: 100vh;
|
min-width: 0;
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 20px 14px 14px;
|
padding: 20px 14px 14px;
|
||||||
background: linear-gradient(180deg, #eadcc8 0%, #f4eddf 100%);
|
overflow-x: hidden;
|
||||||
border-right: 1px solid var(--app-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.nav-menu {
|
||||||
display: flex;
|
flex: 1;
|
||||||
flex-direction: column;
|
min-height: 0;
|
||||||
align-items: center;
|
min-width: 0;
|
||||||
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-settings {
|
.nav-settings {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid var(--app-border);
|
border-top: 1px solid var(--app-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-settings--collapsed {
|
||||||
display: flex;
|
padding-top: 10px;
|
||||||
align-items: center;
|
}
|
||||||
gap: 10px;
|
|
||||||
padding: 9px 12px;
|
.nav-menu :deep(.n-menu-item-content),
|
||||||
|
.nav-settings :deep(.n-menu-item-content) {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 14px;
|
|
||||||
color: var(--app-text-secondary);
|
color: var(--app-text-secondary);
|
||||||
text-decoration: none;
|
box-shadow: none;
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
color 0.15s ease,
|
|
||||||
background-color 0.15s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item :deep(.n-anchor-link__title) {
|
.nav-menu :deep(.n-menu-item-content:hover),
|
||||||
display: flex;
|
.nav-settings :deep(.n-menu-item-content:hover) {
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
width: 100%;
|
|
||||||
gap: 10px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:hover {
|
|
||||||
color: var(--app-text);
|
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),
|
||||||
color: var(--app-primary);
|
.nav-settings :deep(.n-menu-item-content--selected) {
|
||||||
|
color: var(--app-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: linear-gradient(90deg, rgba(180, 83, 42, 0.16), rgba(180, 83, 42, 0.05));
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-section {
|
.nav-menu :deep(.n-menu-item-content::before),
|
||||||
flex: 1;
|
.nav-settings :deep(.n-menu-item-content::before) {
|
||||||
margin-top: 24px;
|
box-shadow: none;
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.nav-menu :deep(.n-menu-item-content:hover::before),
|
||||||
margin: 0;
|
.nav-settings :deep(.n-menu-item-content:hover::before),
|
||||||
padding: 0 12px 8px;
|
.nav-menu :deep(.n-menu-item-content--selected::before),
|
||||||
font-size: 12px;
|
.nav-settings :deep(.n-menu-item-content--selected::before) {
|
||||||
letter-spacing: 1px;
|
box-shadow: none;
|
||||||
color: var(--app-text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-sub {
|
/* The menu keeps its own item padding when collapsed. Reset the outer padding
|
||||||
display: flex;
|
and give every item a stable hit area so icons stay centered in 72px. */
|
||||||
align-items: center;
|
.sidebar-sider--collapsed .sidebar-content {
|
||||||
gap: 8px;
|
width: 72px;
|
||||||
padding: 7px 12px;
|
padding-left: 0;
|
||||||
border-radius: 8px;
|
padding-right: 0;
|
||||||
font-size: 13px;
|
|
||||||
color: var(--app-text-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
color 0.15s ease,
|
|
||||||
background-color 0.15s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-sub:hover {
|
.sidebar-sider--collapsed .nav-menu,
|
||||||
color: var(--app-text);
|
.sidebar-sider--collapsed .nav-settings {
|
||||||
background: rgba(180, 83, 42, 0.08);
|
width: 72px;
|
||||||
|
min-width: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-sub.active {
|
.sidebar-sider--collapsed .nav-menu :deep(.n-menu-item-content),
|
||||||
color: var(--app-green);
|
.sidebar-sider--collapsed .nav-settings :deep(.n-menu-item-content) {
|
||||||
background: rgba(112, 132, 91, 0.12);
|
width: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-inline: 0;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-dot {
|
.sidebar-sider--collapsed .nav-menu :deep(.n-menu-item-content__icon),
|
||||||
width: 22px;
|
.sidebar-sider--collapsed .nav-settings :deep(.n-menu-item-content__icon) {
|
||||||
height: 22px;
|
margin-right: 0;
|
||||||
border-radius: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-name {
|
.sidebar-sider :deep(.n-layout-sider-scroll-container) {
|
||||||
white-space: nowrap;
|
overflow-x: hidden;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-sider :deep(.n-scrollbar-container),
|
||||||
margin-top: 14px;
|
.sidebar-sider :deep(.n-scrollbar-content) {
|
||||||
padding: 12px 10px 0;
|
overflow-x: hidden;
|
||||||
border-top: 1px solid var(--app-border);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--app-text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-area {
|
.sidebar-sider :deep(.n-layout-toggle-button) {
|
||||||
display: flex;
|
z-index: 3;
|
||||||
flex-direction: column;
|
box-shadow: none;
|
||||||
flex: 1;
|
}
|
||||||
height: 100vh;
|
|
||||||
|
.sidebar-sider :deep(.n-layout-sider__border) {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-layout {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
|
||||||
padding: clamp(18px, 3vw, 28px) clamp(18px, 3vw, 32px) 44px;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding: clamp(18px, 3vw, 28px) clamp(18px, 3vw, 32px) 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 窗口较窄时,侧边栏折叠为纯图标导航 */
|
.playerbar-footer {
|
||||||
@media (max-width: 1100px) {
|
background: transparent;
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
import { createRouter, createWebHashHistory } from "vue-router";
|
import { createRouter, createWebHashHistory } from "vue-router"
|
||||||
import MainLayout from "@/layouts/MainLayout.vue";
|
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";
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
|
|
@ -15,21 +8,32 @@ const router = createRouter({
|
||||||
path: "/",
|
path: "/",
|
||||||
component: MainLayout,
|
component: MainLayout,
|
||||||
children: [
|
children: [
|
||||||
{ path: "", name: "discover", component: DiscoverView },
|
{ path: "", name: "discover", component: () => import("@/views/DiscoverView.vue") },
|
||||||
{ path: "ranking", name: "ranking", component: RankingView },
|
{ path: "ranking", name: "ranking", component: () => import("@/views/RankingView.vue") },
|
||||||
{ path: "library", name: "library", component: LibraryView },
|
{ path: "library", name: "library", component: () => import("@/views/LibraryView.vue") },
|
||||||
{
|
{
|
||||||
path: "playlist/:id",
|
path: "playlist/:id",
|
||||||
name: "playlist",
|
name: "playlist",
|
||||||
component: PlaylistDetailView,
|
component: () => import("@/views/PlaylistDetailView.vue"),
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
{ path: "search", name: "search", component: SearchView },
|
{ path: "search", name: "search", component: () => import("@/views/SearchView.vue") },
|
||||||
{ path: "settings", name: "settings", component: SettingsView },
|
{
|
||||||
{ path: "demo", name: "demo", component: PlayMusic },
|
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 {
|
:root {
|
||||||
|
--titlebar-height: 34px;
|
||||||
--app-bg: #f4eddf;
|
--app-bg: #f4eddf;
|
||||||
--app-bg-elevated: #eee2d1;
|
--app-bg-elevated: #eee2d1;
|
||||||
--app-card: #fffaf0;
|
--app-card: #fffaf0;
|
||||||
|
|
@ -10,6 +11,13 @@
|
||||||
--app-green: #70845b;
|
--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;
|
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']
|
AppIcon: typeof import('./../components/AppIcon.vue')['default']
|
||||||
FullscreenPlayer: typeof import('./../components/FullscreenPlayer.vue')['default']
|
FullscreenPlayer: typeof import('./../components/FullscreenPlayer.vue')['default']
|
||||||
MusicCard: typeof import('./../components/MusicCard.vue')['default']
|
MusicCard: typeof import('./../components/MusicCard.vue')['default']
|
||||||
NAnchor: typeof import('naive-ui')['NAnchor']
|
NBackTop: typeof import('naive-ui')['NBackTop']
|
||||||
NAnchorLink: typeof import('naive-ui')['NAnchorLink']
|
|
||||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
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']
|
NRadio: typeof import('naive-ui')['NRadio']
|
||||||
PlayerBar: typeof import('./../components/PlayerBar.vue')['default']
|
PlayerBar: typeof import('./../components/PlayerBar.vue')['default']
|
||||||
QrLogin: typeof import('./../components/QrLogin.vue')['default']
|
QrLogin: typeof import('./../components/QrLogin.vue')['default']
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,8 @@
|
||||||
<h3>分类</h3>
|
<h3>分类</h3>
|
||||||
<span class="section-sub">歌单分类</span>
|
<span class="section-sub">歌单分类</span>
|
||||||
</div>
|
</div>
|
||||||
<n-tabs
|
<n-tabs v-model:value="activeCategory" type="line" animated @update:value="playList">
|
||||||
v-model:value="activeCategory"
|
<n-tab-pane v-for="category in categories" :key="category.id" :name="category.id" :tab="category.name" />
|
||||||
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-tabs>
|
||||||
<n-spin :show="loading" class="card-spin">
|
<n-spin :show="loading" class="card-spin">
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
|
|
@ -42,11 +32,11 @@
|
||||||
<MusicCard v-for="playlist in recommendedPlaylists" :key="playlist.id" :playlist="playlist" />
|
<MusicCard v-for="playlist in recommendedPlaylists" :key="playlist.id" :playlist="playlist" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue"
|
||||||
import PlayListApi, { type PlayListCategory, type PlayListSummary } from "@/api/playlist"
|
import PlayListApi, { type PlayListCategory, type PlayListSummary } from "@/api/playlist"
|
||||||
import MusicCard from "@/components/MusicCard.vue"
|
import MusicCard from "@/components/MusicCard.vue"
|
||||||
import { useSettingsStore } from "@/stores/settings"
|
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">
|
<div class="source-bar">
|
||||||
<n-tabs
|
<n-tabs
|
||||||
v-model:value="selectedSource"
|
v-model:value="selectedSource"
|
||||||
type="line"
|
type="segment"
|
||||||
animated
|
animated
|
||||||
@update:value="onSourceChange"
|
@update:value="onSourceChange"
|
||||||
>
|
>
|
||||||
|
|
@ -243,7 +243,7 @@ async function submitSearch() {
|
||||||
async function searchWith(kw: string) {
|
async function searchWith(kw: string) {
|
||||||
keyword.value = kw
|
keyword.value = kw
|
||||||
searchHistoryStore.add(kw)
|
searchHistoryStore.add(kw)
|
||||||
message.success(`搜索「${kw}」`)
|
// message.success(`搜索「${kw}」`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeItem(item: string, event: MouseEvent) {
|
async function removeItem(item: string, event: MouseEvent) {
|
||||||
|
|
|
||||||
|
|
@ -1,167 +1,123 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="settings-layout">
|
||||||
<div class="page-head">
|
<aside class="settings-sidebar">
|
||||||
<h2>设置</h2>
|
<!-- <div class="settings-heading">
|
||||||
<p>配置音乐来源,之后的请求会使用你选择的平台(更改自动保存)</p>
|
<span class="settings-eyebrow">PREFERENCES</span>
|
||||||
</div>
|
<h2>设置</h2>
|
||||||
|
<p>管理应用偏好与播放体验</p>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
<section class="setting-card">
|
<n-menu
|
||||||
<div class="setting-head">
|
:value="activeSetting"
|
||||||
<h3>音乐源</h3>
|
:options="settingOptions"
|
||||||
<p>勾选后,搜索等请求会同时在所选平台查找;至少保留一个。</p>
|
class="settings-menu"
|
||||||
</div>
|
@update:value="handleSettingChange"
|
||||||
<n-checkbox-group :value="settingsStore.enabledSources" @update:value="settingsStore.setEnabledSources">
|
/>
|
||||||
<div class="source-grid">
|
</aside>
|
||||||
<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">
|
<main class="settings-content">
|
||||||
<div class="setting-head">
|
<RouterView />
|
||||||
<h3>默认音乐源</h3>
|
</main>
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NCheckboxGroup, NRadioGroup } from "naive-ui"
|
import { RouterView, useRoute, useRouter } from "vue-router"
|
||||||
import QrLogin from "@/components/QrLogin.vue"
|
import type { MenuOption } from "naive-ui"
|
||||||
import { ALL_SOURCES, SOURCE_LABELS, useSettingsStore } from "@/stores/settings"
|
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.settings-layout {
|
||||||
max-width: 1100px;
|
display: grid;
|
||||||
|
grid-template-columns: 190px minmax(0, 1fr);
|
||||||
|
gap: clamp(24px, 4vw, 52px);
|
||||||
|
max-width: 1180px;
|
||||||
|
min-height: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-head {
|
.settings-sidebar {
|
||||||
margin-bottom: 24px;
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
align-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-head h2 {
|
.settings-heading {
|
||||||
margin: 0 0 4px;
|
margin-bottom: 22px;
|
||||||
font-size: clamp(22px, 2.5vw, 26px);
|
padding: 4px 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-eyebrow {
|
||||||
|
color: var(--app-primary);
|
||||||
|
font-size: 10px;
|
||||||
font-weight: 700;
|
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;
|
margin: 0;
|
||||||
font-size: 13px;
|
|
||||||
color: var(--app-text-muted);
|
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;
|
font-size: 12px;
|
||||||
color: var(--app-text-muted);
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-grid {
|
.settings-menu {
|
||||||
display: grid;
|
--n-item-text-color: var(--app-text-secondary);
|
||||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
--n-item-text-color-hover: var(--app-text);
|
||||||
gap: 10px;
|
--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 {
|
||||||
display: flex;
|
min-width: 0;
|
||||||
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 {
|
@media (max-width: 760px) {
|
||||||
border-color: rgba(180, 83, 42, 0.5);
|
.settings-layout {
|
||||||
background: rgba(180, 83, 42, 0.08);
|
grid-template-columns: 1fr;
|
||||||
}
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.source-item.checked {
|
.settings-sidebar {
|
||||||
border-color: rgba(112, 132, 91, 0.58);
|
position: static;
|
||||||
background: rgba(112, 132, 91, 0.1);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.source-label {
|
.settings-heading {
|
||||||
font-size: 13px;
|
margin-bottom: 12px;
|
||||||
color: var(--app-text);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.source-key {
|
.settings-menu {
|
||||||
margin-left: auto;
|
display: flex;
|
||||||
font-size: 11px;
|
overflow-x: auto;
|
||||||
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>
|
</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