feat: 添加音乐搜索功能和设置页面

- 新增MusicSearchSong、MusicSearchPlaylist、MusicSearchAlbum和
  MusicSearchResult接口定义
- 实现音乐搜索API,支持多源搜索和结果类型过滤
- 添加设置页面,支持配置启用的音乐源和默认音乐源
- 在搜索页面添加防抖搜索和加载状态
- 更新歌单详情页使用动态音乐源获取播放链接
- 添加settings图标到AppIcon组件
- 在主布局中添加设置导航项
- 创建settings store用于管理音乐源设置
- 添加对Song接口的source字段支持
- 更新路由配置添加设置页面路由
This commit is contained in:
Yuhang Wu 2026-08-12 11:54:56 +08:00
parent 32ca1b4f74
commit 2db45f95c0
12 changed files with 460 additions and 70 deletions

View File

@ -6,6 +6,57 @@ export interface MusicUrl {
url: string url: string
} }
/** 搜索结果-单曲 */
export interface MusicSearchSong {
id: string
name: string
artist: string
album: string
album_id: string
duration: number
size: number
bitrate: number
source: string
url: string
ext: string
cover: string
link: string
}
/** 搜索结果-歌单 */
export interface MusicSearchPlaylist {
id: string
name: string
cover: string
track_count: number
play_count: number
creator: string
description: string
source: string
link: string
}
/** 搜索结果-专辑 */
export interface MusicSearchAlbum {
id: string
name: string
cover: string
track_count: number
play_count: number
creator: string
description: string
source: string
link: string
}
/** 综合搜索结果 */
export interface MusicSearchResult {
songs: MusicSearchSong[] | null
playlists: MusicSearchPlaylist[] | null
albums: MusicSearchAlbum[] | null
type: "song" | "playlist" | "album"
}
const MusicApi = { const MusicApi = {
/** /**
* *
@ -59,7 +110,8 @@ const MusicApi = {
* @returns * @returns
*/ */
search: (keyword: string, type: string, sources?: Source[]) => { search: (keyword: string, type: string, sources?: Source[]) => {
return get<any>(`/api/v1/music/search?q=${keyword}&type=${type}&sources=${sources}`) const sourceParams = (sources ?? []).map((s) => `&sources=${encodeURIComponent(s)}`).join("")
return get<MusicSearchResult>(`/api/v1/music/search?q=${encodeURIComponent(keyword)}&type=${type}${sourceParams}`)
}, },
/** /**
* *

View File

@ -100,7 +100,8 @@ const PlayListApi = {
* @returns * @returns
*/ */
categoryList: (sources?: Source[]) => { categoryList: (sources?: Source[]) => {
return get<PlayListCategoryRes[]>(`/api/v1/playlist/categories?sources=${sources}`) const sourceParams = (sources ?? []).map((s) => `sources=${encodeURIComponent(s)}`).join("&")
return get<PlayListCategoryRes[]>(`/api/v1/playlist/categories${sourceParams ? `?${sourceParams}` : ""}`)
}, },
/** /**
* *
@ -131,7 +132,8 @@ const PlayListApi = {
* @returns * @returns
*/ */
recommend: (sources?: Source[]) => { recommend: (sources?: Source[]) => {
return get<PlayListSummary[]>(`/api/v1/playlist/recommend?sources=${sources}`) const sourceParams = (sources ?? []).map((s) => `sources=${encodeURIComponent(s)}`).join("&")
return get<PlayListSummary[]>(`/api/v1/playlist/recommend${sourceParams ? `?${sourceParams}` : ""}`)
}, },
} }

View File

@ -85,6 +85,10 @@ const paths: Record<string, string[]> = {
"M18 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2Z", "M18 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2Z",
"M3 14v-3a9 9 0 0 1 18 0v3", "M3 14v-3a9 9 0 0 1 18 0v3",
], ],
settings: [
"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z",
"M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z",
],
}; };
const isFilled = computed(() => filledNames.has(props.name)); const isFilled = computed(() => filledNames.has(props.name));

View File

@ -62,6 +62,7 @@ const navItems = [
{ path: "/ranking", label: "排行榜", icon: "trophy" }, { path: "/ranking", label: "排行榜", icon: "trophy" },
{ path: "/library", label: "我的音乐", icon: "music" }, { path: "/library", label: "我的音乐", icon: "music" },
{ path: "/search", label: "搜索", icon: "search" }, { path: "/search", label: "搜索", icon: "search" },
{ path: "/settings", label: "设置", icon: "settings" },
] as const; ] as const;
function isActive(path: string) { function isActive(path: string) {

View File

@ -5,6 +5,8 @@ export interface Song {
album: string; album: string;
duration: number; duration: number;
cover: string; cover: string;
/** 来源平台API 搜索结果携带,用于解析播放直链) */
source?: string;
} }
export interface Playlist { export interface Playlist {

View File

@ -5,6 +5,7 @@ import LibraryView from "@/views/LibraryView.vue";
import PlaylistDetailView from "@/views/PlaylistDetailView.vue"; import PlaylistDetailView from "@/views/PlaylistDetailView.vue";
import RankingView from "@/views/RankingView.vue"; import RankingView from "@/views/RankingView.vue";
import SearchView from "@/views/SearchView.vue"; import SearchView from "@/views/SearchView.vue";
import SettingsView from "@/views/SettingsView.vue";
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
@ -23,6 +24,7 @@ const router = createRouter({
props: true, props: true,
}, },
{ path: "search", name: "search", component: SearchView }, { path: "search", name: "search", component: SearchView },
{ path: "settings", name: "settings", component: SettingsView },
], ],
}, },
], ],

91
src/stores/settings.ts Normal file
View File

@ -0,0 +1,91 @@
import { reactive, watch } from "vue"
import Source from "@/types/global"
/** localStorage 存储键 */
const STORAGE_KEY = "ywymusic.settings"
/** 各平台中文名 */
export const SOURCE_LABELS: Record<Source, string> = {
[Source.WangYiYun]: "网易云音乐",
[Source.QQ]: "QQ 音乐",
[Source.QQ_WX]: "QQ 音乐(微信端)",
[Source.KuGou]: "酷狗音乐",
[Source.KuWo]: "酷我音乐",
[Source.MiGu]: "咪咕音乐",
[Source.QianQian]: "千千音乐",
[Source.QiShui]: "汽水音乐",
[Source.FiveSing]: "5sing",
[Source.Jamendo]: "Jamendo",
[Source.JOOX]: "JOOX",
[Source.Bilibili]: "哔哩哔哩",
}
/** 所有可用的音乐源(按枚举声明顺序) */
export const ALL_SOURCES: Source[] = Object.values(Source) as Source[]
interface SettingsState {
/** 已启用的音乐源(勾选),用于搜索等多源请求 */
enabledSources: Source[]
/** 默认音乐源(可从全部源中选择,独立于启用源),用于歌单详情、分类浏览、播放直链等单源请求 */
defaultSource: Source
}
const DEFAULT_SETTINGS: SettingsState = {
enabledSources: [Source.KuGou],
defaultSource: Source.KuGou,
}
function isSource(value: unknown): value is Source {
return typeof value === "string" && ALL_SOURCES.includes(value as Source)
}
function loadSettings(): SettingsState {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return { ...DEFAULT_SETTINGS }
const data = JSON.parse(raw) as Partial<SettingsState>
const enabled = (Array.isArray(data.enabledSources) ? data.enabledSources : [])
.filter(isSource)
.filter((source, index, arr) => arr.indexOf(source) === index)
if (enabled.length === 0) return { ...DEFAULT_SETTINGS }
const defaultSource =
isSource(data.defaultSource)
? data.defaultSource
: DEFAULT_SETTINGS.defaultSource
return { enabledSources: enabled, defaultSource }
} catch {
return { ...DEFAULT_SETTINGS }
}
}
export const settingsState = reactive<SettingsState>(loadSettings())
// 变更后自动持久化
watch(
settingsState,
() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settingsState))
} catch {
// 存储不可用时静默忽略
}
},
{ deep: true },
)
/**
*
*
*/
export function setEnabledSources(sources: Array<string | number | boolean>) {
const unique = [...new Set(sources.filter(isSource))]
if (unique.length === 0) return
settingsState.enabledSources = unique
}
/** 设置默认音乐源(可从全部源中选择任意一个) */
export function setDefaultSource(source: string | number | boolean) {
if (isSource(source)) {
settingsState.defaultSource = source
}
}

View File

@ -14,6 +14,8 @@ 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']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NRadio: typeof import('naive-ui')['NRadio']
NRadioButton: typeof import('naive-ui')['NRadioButton'] NRadioButton: typeof import('naive-ui')['NRadioButton']
NRadioGroup: typeof import('naive-ui')['NRadioGroup'] NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NScrollbar: typeof import('naive-ui')['NScrollbar'] NScrollbar: typeof import('naive-ui')['NScrollbar']

View File

@ -105,8 +105,8 @@ import { proxyCover } from "@/utils/cover"
import { formatPlayCount } from "@/utils/format" import { formatPlayCount } from "@/utils/format"
import { formatTime } from "@/utils/time" import { formatTime } from "@/utils/time"
import PlayListApi from "@/api/playlist" import PlayListApi from "@/api/playlist"
import Source from "@/types/global"
import { cachePlayListSummary, type PlayListCategory } from "@/api/playlist" import { cachePlayListSummary, type PlayListCategory } from "@/api/playlist"
import { settingsState } from "@/stores/settings"
const message = useMessage() const message = useMessage()
const router = useRouter() const router = useRouter()
@ -140,7 +140,7 @@ function playNewSong(songIndex: number) {
} }
const list = async () => { const list = async () => {
const res = await PlayListApi.categoryList([Source.KuGou]) const res = await PlayListApi.categoryList([settingsState.defaultSource])
categories.value = res[0].categories categories.value = res[0].categories
if (categories.value.length > 0) { if (categories.value.length > 0) {
activeCategory.value = categories.value[1].id activeCategory.value = categories.value[1].id
@ -149,7 +149,7 @@ const list = async () => {
} }
const playList = async (categoryId: string) => { const playList = async (categoryId: string) => {
const res = await PlayListApi.categoryPlayList(categoryId, Source.KuGou, 1, 30) const res = await PlayListApi.categoryPlayList(categoryId, settingsState.defaultSource, 1, 30)
recommendedPlaylists.value = res.playlists.map((p) => { recommendedPlaylists.value = res.playlists.map((p) => {
cachePlayListSummary(p) cachePlayListSummary(p)
return { return {

View File

@ -50,6 +50,7 @@ import SongTable from "@/components/SongTable.vue"
import PlayListApi, { getCachedPlayListSummary } from "@/api/playlist" import PlayListApi, { getCachedPlayListSummary } from "@/api/playlist"
import { playlists, type Song } from "@/mocks/music" import { playlists, type Song } from "@/mocks/music"
import { playSong } from "@/stores/player" import { playSong } from "@/stores/player"
import { settingsState } from "@/stores/settings"
import { displayCover } from "@/utils/cover" import { displayCover } from "@/utils/cover"
import { formatPlayCount } from "@/utils/format" import { formatPlayCount } from "@/utils/format"
import Source from "@/types/global" import Source from "@/types/global"
@ -76,10 +77,13 @@ const headerCover = computed(() => {
const cover = summary.value?.cover ?? mockPlaylist.value?.cover ?? songs.value[0]?.cover ?? "" const cover = summary.value?.cover ?? mockPlaylist.value?.cover ?? songs.value[0]?.cover ?? ""
return cover ? displayCover(cover) : "linear-gradient(135deg, #4338ca 0%, #7c3aed 100%)" return cover ? displayCover(cover) : "linear-gradient(135deg, #4338ca 0%, #7c3aed 100%)"
}) })
const playlistSource = computed<Source>(
() => (summary.value?.source as Source) ?? settingsState.defaultSource,
)
onMounted(async () => { onMounted(async () => {
try { try {
const res = await PlayListApi.detail(Source.KuGou, props.id) const res = await PlayListApi.detail(playlistSource.value, props.id)
songs.value = res.map((song) => ({ songs.value = res.map((song) => ({
id: song.id, id: song.id,
title: song.name, title: song.name,
@ -87,6 +91,7 @@ onMounted(async () => {
album: song.album, album: song.album,
duration: song.duration, duration: song.duration,
cover: displayCover(song.cover), cover: displayCover(song.cover),
source: song.source,
})) }))
} catch { } catch {
// mock // mock
@ -105,13 +110,14 @@ function playAll() {
async function playWithUrl(song: Song, queue?: Song[]) { async function playWithUrl(song: Song, queue?: Song[]) {
try { try {
const { url } = await MusicApi.url(String(song.id), Source.KuGou) const source = (song.source ?? playlistSource.value) as Source
const { url } = await MusicApi.url(String(song.id), source)
playSong(song, queue, url) playSong(song, queue, url)
} catch { } catch {
// MusicApi.stream // MusicApi.stream
playSong(song, queue) playSong(song, queue)
} }
message.success(`正在播放:${song.title}`) // message.success(`${song.title}`)
} }
function favorite() { function favorite() {

View File

@ -2,14 +2,7 @@
<div class="page"> <div class="page">
<div class="search-hero"> <div class="search-hero">
<h2>搜索</h2> <h2>搜索</h2>
<n-input <n-input v-model:value="keyword" size="large" round clearable placeholder="搜索音乐、歌手、专辑" class="search-input">
v-model:value="keyword"
size="large"
round
clearable
placeholder="搜索音乐、歌手、专辑"
class="search-input"
>
<template #prefix> <template #prefix>
<AppIcon name="search" :size="17" class="search-icon" /> <AppIcon name="search" :size="17" class="search-icon" />
</template> </template>
@ -19,15 +12,7 @@
<div v-if="!normalized" class="hot-area"> <div v-if="!normalized" class="hot-area">
<p class="hot-title">热门搜索</p> <p class="hot-title">热门搜索</p>
<div class="hot-tags"> <div class="hot-tags">
<n-tag <n-tag v-for="hot in hotKeywords" :key="hot" size="large" round :bordered="false" class="hot-tag" @click="useHot(hot)">
v-for="hot in hotKeywords"
:key="hot"
size="large"
round
:bordered="false"
class="hot-tag"
@click="useHot(hot)"
>
{{ hot }} {{ hot }}
</n-tag> </n-tag>
</div> </div>
@ -37,24 +22,26 @@
<n-tabs v-model:value="activeTab" type="line" animated> <n-tabs v-model:value="activeTab" type="line" animated>
<n-tab-pane name="song" :tab="`单曲 ${songResults.length}`"> <n-tab-pane name="song" :tab="`单曲 ${songResults.length}`">
<div class="result-body"> <div class="result-body">
<n-spin v-if="loading" class="result-loading" />
<template v-else>
<SongTable <SongTable
v-if="songResults.length > 0" v-if="songResults.length > 0"
:songs="songResults" :songs="songResults"
@play="(song) => playSong(song, songResults)" @play="playSearchSong"
/> />
<n-empty v-else class="empty" description="没有找到相关单曲" /> <n-empty v-else class="empty" description="没有找到相关单曲" />
</template>
</div> </div>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="playlist" :tab="`歌单 ${playlistResults.length}`"> <n-tab-pane name="playlist" :tab="`歌单 ${playlistResults.length}`">
<div class="result-body"> <div class="result-body">
<n-spin v-if="loading" class="result-loading" />
<template v-else>
<div v-if="playlistResults.length > 0" class="card-grid"> <div v-if="playlistResults.length > 0" class="card-grid">
<MusicCard <MusicCard v-for="playlist in playlistResults" :key="playlist.id" :playlist="playlist" />
v-for="playlist in playlistResults"
:key="playlist.id"
:playlist="playlist"
/>
</div> </div>
<n-empty v-else class="empty" description="没有找到相关歌单" /> <n-empty v-else class="empty" description="没有找到相关歌单" />
</template>
</div> </div>
</n-tab-pane> </n-tab-pane>
</n-tabs> </n-tabs>
@ -63,43 +50,116 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, onBeforeUnmount, ref, watch } from "vue"
import { NEmpty, NInput, NTabPane, NTabs, NTag, useMessage } from "naive-ui"; import { NEmpty, NInput, NSpin, NTabPane, NTabs, NTag, useMessage } from "naive-ui"
import AppIcon from "@/components/AppIcon.vue"; import AppIcon from "@/components/AppIcon.vue"
import MusicCard from "@/components/MusicCard.vue"; import MusicCard from "@/components/MusicCard.vue"
import SongTable from "@/components/SongTable.vue"; import SongTable from "@/components/SongTable.vue"
import { allSongs, hotKeywords, playlists } from "@/mocks/music"; import MusicApi, { type MusicSearchPlaylist, type MusicSearchSong } from "@/api/music"
import { playSong } from "@/stores/player"; import { hotKeywords, type Playlist, type Song } from "@/mocks/music"
import { playSong } from "@/stores/player"
import { settingsState } from "@/stores/settings"
import { displayCover } from "@/utils/cover"
import { formatPlayCount } from "@/utils/format"
import Source from "@/types/global"
const message = useMessage(); const message = useMessage()
const keyword = ref(""); const keyword = ref("")
const activeTab = ref("song"); const activeTab = ref("song")
const normalized = computed(() => keyword.value.trim().toLowerCase()); const normalized = computed(() => keyword.value.trim().toLowerCase())
const songResults = computed(() => { const loading = ref(false)
const q = normalized.value; const songResults = ref<Song[]>([])
if (!q) return []; const playlistResults = ref<Playlist[]>([])
return allSongs.filter(
(song) =>
song.title.toLowerCase().includes(q) || song.artist.toLowerCase().includes(q),
);
});
const playlistResults = computed(() => { let searchTimer: number | undefined
const q = normalized.value; let requestSeq = 0
if (!q) return [];
return playlists.filter( watch(normalized, (q) => {
(playlist) => if (searchTimer !== undefined) {
playlist.name.toLowerCase().includes(q) || window.clearTimeout(searchTimer)
playlist.tags.some((tag) => tag.toLowerCase().includes(q)), searchTimer = undefined
); }
}); if (!q) {
requestSeq += 1
songResults.value = []
playlistResults.value = []
loading.value = false
return
}
searchTimer = window.setTimeout(() => {
void doSearch(q)
}, 400)
})
async function doSearch(q: string) {
const seq = ++requestSeq
loading.value = true
const [songRes, playlistRes] = await Promise.allSettled([
MusicApi.search(q, "song", settingsState.enabledSources),
MusicApi.search(q, "playlist", settingsState.enabledSources),
])
if (seq !== requestSeq) return
if (songRes.status === "fulfilled") {
songResults.value = (songRes.value.songs ?? []).map(toSong)
} else {
songResults.value = []
}
if (playlistRes.status === "fulfilled") {
playlistResults.value = (playlistRes.value.playlists ?? []).map(toPlaylist)
} else {
playlistResults.value = []
}
loading.value = false
}
function toSong(item: MusicSearchSong): Song {
return {
id: item.id,
title: item.name,
artist: item.artist,
album: item.album,
duration: item.duration,
cover: displayCover(item.cover),
source: item.source,
}
}
function toPlaylist(item: MusicSearchPlaylist): Playlist {
return {
id: item.id,
name: item.name,
desc: item.description,
cover: displayCover(item.cover),
tags: [],
playCount: formatPlayCount(item.play_count),
songs: [],
}
}
async function playSearchSong(song: Song) {
const source = (song.source ?? settingsState.defaultSource) as Source
try {
const { url } = await MusicApi.url(String(song.id), source)
playSong(song, songResults.value, url)
} catch {
// MusicApi.stream
playSong(song, songResults.value)
}
message.success(`正在播放:${song.title}`)
}
function useHot(hot: string) { function useHot(hot: string) {
keyword.value = hot; keyword.value = hot
message.success(`搜索「${hot}`); message.success(`搜索「${hot}`)
} }
onBeforeUnmount(() => {
if (searchTimer !== undefined) {
window.clearTimeout(searchTimer)
}
})
</script> </script>
<style scoped> <style scoped>
@ -159,6 +219,12 @@ function useHot(hot: string) {
padding-top: 18px; padding-top: 18px;
} }
.result-loading {
display: flex;
justify-content: center;
padding: 48px 0;
}
.card-grid { .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));

162
src/views/SettingsView.vue Normal file
View File

@ -0,0 +1,162 @@
<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="settingsState.enabledSources" @update:value="setEnabledSources">
<div class="source-grid">
<div
v-for="source in ALL_SOURCES"
:key="source"
class="source-item"
:class="{ checked: settingsState.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="settingsState.defaultSource"
@update:value="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>
</div>
</template>
<script setup lang="ts">
import { NCheckboxGroup, NRadioGroup } from "naive-ui"
import { ALL_SOURCES, SOURCE_LABELS, setDefaultSource, setEnabledSources, settingsState } from "@/stores/settings"
</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, 255, 255, 0.02);
}
.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(109, 93, 246, 0.45);
background: rgba(255, 255, 255, 0.04);
}
.source-item.checked {
border-color: rgba(34, 197, 94, 0.5);
background: rgba(34, 197, 94, 0.06);
}
.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(109, 93, 246, 0.45);
background: rgba(255, 255, 255, 0.04);
}
</style>