Files
Nekosonic-Music/src/views/Settings.vue
Atdunbg 65ed71503e feat: 架构重构与跨平台媒体控制集成
## 后端

- 替换 rodio 为 symphonia + ringbuf,重构 audio.rs 播放引擎
- 重构 api.rs,使用 api_call! 宏统一 API 调用模式
- 新增 media_controls.rs,使用 souvlaki 实现跨平台系统媒体控制
  (Linux MPRIS / Windows SMTC / macOS Now Playing)
- 版本号升至 v0.5.0

## 前端 - 新增

- 新增 SongListItem 通用组件
- 新增 useOnlineStatus composable,检测网络状态
- 新增 usePageCache composable,页面数据缓存与失效
- 新增 getCoverUrl()、formatDate() 工具函数
- 新增 emitPlaybackState() 同步播放状态到系统媒体控制
- 新增 mpris-command 事件监听,响应系统媒体控制命令
- 新增 Toast 离线/恢复在线提示
- 各页面新增断网恢复后自动重试加载
- 新增路由守卫:已登录用户访问 /login 重定向至首页
- 新增音量持久化(settings store + localStorage)
- 新增禁用右键菜单与用户选择限制(输入框除外)

## 前端 - 变更

- Song 接口从 player.ts 迁移至 song.ts 并导出
- AlbumDetail/ArtistDetail/PlaylistDetail/RecentPlays/LocalMusic 迁移至 SongListItem
- PlayerBar 队列列表迁移至 SongListItem,封面使用 getCoverUrl()
- downloadSong 参数类型从内联对象改为 Song,使用 getCoverUrl()
- 默认主题从 green 改为 blue,ThemeName 及相关列表中 blue 移至首位
- 全局快捷键从 Alt+Control 改为 Control+Alt 顺序
- formatShortcut 新增 KeyP → P 显示
- keep-alive 从 max=3 固定 include 改为 max=5 动态列表,窗口隐藏时释放
- App.vue 封面使用 getCoverUrl() 替代手动 al/album 回退
- formatPlayCount 提取常量
- Login.vue text-warning 改为 text-yellow-400

## 前端 - 删除

- 删除 Search.vue(与 Discover.vue 重复)
- 删除 SongItemMenu.vue(被 SongListItem 替代)

## 修复

- 更新器跳过版本逻辑:仅静默检查时跳过已忽略版本,手动检查不再跳过
- 重复播放同一首歌时无法恢复播放
- settings.ts 重复的 ThemeName 定义
- PlayerBar.vue modeTexts 缺少类型注解
- Home.vue map 回调参数缺少类型
- Settings.vue v-for key 类型不匹配
2026-05-23 14:43:47 +08:00

430 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="p-8 text-content">
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回
</button>
<h1 class="text-2xl font-bold mb-8">设置</h1>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">播放</h2>
<div class="space-y-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">输出设备</p>
<p class="text-xs text-content-3 mt-0.5">选择音频播放设备</p>
</div>
<CustomSelect v-model="selectedDevice" :options="deviceOptions" />
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">音质选择</p>
<p class="text-xs text-content-3 mt-0.5">更高音质需要 VIP 权限</p>
</div>
<CustomSelect v-model="settings.audioQuality" :options="qualityLabels" />
</div>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">外观</h2>
<div>
<p class="text-sm font-medium mb-3">主题色</p>
<div class="grid grid-cols-4 gap-3">
<button
v-for="(color, key) in themeColors"
:key="key"
@click="settings.setTheme(key)"
class="flex flex-col items-center gap-2 p-3 rounded-xl transition-all border-2"
:class="settings.theme === key ? 'border-white/30 bg-white/5 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
>
<div class="w-8 h-8 rounded-full shadow-md" :style="{ backgroundColor: color }"></div>
<span class="text-xs" :class="settings.theme === key ? 'text-content font-medium' : 'text-content-3'">{{ themeLabels[key] }}</span>
</button>
</div>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">窗口</h2>
<div class="space-y-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">关闭窗口时</p>
<p class="text-xs text-content-3 mt-0.5">点击关闭按钮的默认行为</p>
</div>
<CustomSelect v-model="closeActionValue" :options="closeActionLabels" />
</div>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">下载</h2>
<div class="space-y-5">
<div>
<div class="flex items-center justify-between mb-2">
<div>
<p class="text-sm font-medium">下载路径</p>
<p class="text-xs text-content-3 mt-0.5">歌曲下载保存位置</p>
</div>
</div>
<div class="flex gap-2 items-center">
<div class="flex-1 bg-subtle border border-line rounded-lg px-3 py-2 text-sm text-content-2 truncate" :title="settings.downloadPath || defaultDownloadPath">
{{ settings.downloadPath || defaultDownloadPath }}
</div>
<button
@click="pickDownloadFolder"
class="px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 bg-accent/15 text-accent-text hover:bg-accent/25 active:scale-95"
>
选择文件夹
</button>
<button
v-if="settings.downloadPath"
@click="clearDownloadPath"
class="px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 bg-muted text-content-2 hover:bg-emphasis hover:text-content active:scale-95"
title="重置为默认路径"
>
重置
</button>
</div>
</div>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">快捷键</h2>
<div class="space-y-3">
<div
v-for="(sc, id) in settings.shortcuts"
:key="id"
class="flex items-center justify-between p-3 bg-subtle rounded-xl"
>
<div>
<p class="text-sm font-medium">{{ sc.label }}</p>
</div>
<div class="flex items-center gap-1.5">
<button
v-if="sc.key !== defaultShortcuts[id]?.key"
@click="settings.setShortcut(String(id), defaultShortcuts[id].key)"
class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger/10 transition"
title="恢复默认"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<button
@click="startRecording(String(id))"
class="px-3 py-1.5 rounded-lg text-sm transition min-w-[120px] text-center"
:class="recordingId === String(id) ? 'bg-accent text-white' : 'bg-muted hover:bg-emphasis text-content-2'"
>
{{ recordingId === String(id) ? '按下新快捷键...' : formatShortcut(sc.key) }}
</button>
</div>
</div>
<button
@click="resetShortcuts"
class="text-xs text-content-3 hover:text-danger transition"
>
恢复默认快捷键
</button>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">其他</h2>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 bg-subtle rounded-xl">
<div>
<p class="text-sm font-medium">恢复默认设置</p>
<p class="text-xs text-content-3 mt-0.5">重置所有设置为初始状态</p>
</div>
<button
@click="handleResetAll"
class="px-3 py-1.5 rounded-lg text-sm bg-muted hover:bg-emphasis text-danger transition"
>
重置
</button>
</div>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">关于</h2>
<div class="space-y-4">
<a @click.prevent="openUrl('https://github.com/atdunbg/Nekosonic-Music')"
class="flex items-center gap-4 p-4 bg-subtle rounded-xl hover:bg-muted transition cursor-pointer">
<img src="../assets/app-icon.png" class="w-12 h-12 rounded-xl flex-shrink-0" alt="Nekosonic" />
<div>
<p class="font-semibold">Nekosonic</p>
<p class="text-xs text-content-3 mt-0.5">版本 {{ appVersion }}</p>
</div>
</a>
<p class="text-xs text-content-3 leading-relaxed">
Nekosonic 是一款高颜值的跨平台第三方网易云音乐桌面客户端基于 Tauri 2 + Vue 3 构建提供轻量流畅的音乐播放体验
</p>
<div class="flex items-center gap-2">
<button
@click="handleCheckUpdate"
:disabled="updater.checking.value"
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
>
<svg v-if="!updater.checking.value" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<svg v-else class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
{{ updater.checking.value ? '检查中...' : '检查更新' }}
</button>
<button
@click="fetchChangelog"
:disabled="fetchingChangelog"
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
{{ fetchingChangelog ? '获取中...' : '更新日志' }}
</button>
</div>
<p v-if="updater.error.value" class="text-xs text-content-3">{{ updater.error.value }}</p>
</div>
</section>
<Transition name="fade">
<div v-if="showResetConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showResetConfirm = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
<h2 class="text-lg font-semibold text-content mb-1">确认重置</h2>
<p class="text-sm text-content-2 mb-5">所有设置将恢复为默认值此操作不可撤销</p>
<div class="flex gap-3">
<button @click="showResetConfirm = false"
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
取消
</button>
<button @click="confirmResetAll"
class="flex-1 py-2 rounded-lg bg-danger/20 hover:bg-danger/30 text-danger text-sm font-medium transition">
确认重置
</button>
</div>
</div>
</div>
</Transition>
<Transition name="fade">
<div v-if="showChangelogModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showChangelogModal = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[480px] max-h-[80vh] flex flex-col select-auto">
<div class="p-6 pb-4">
<div class="flex items-center justify-between mb-1">
<h2 class="text-lg font-semibold text-content">更新日志</h2>
<span v-if="changelogRelease" class="text-xs font-medium px-2 py-0.5 rounded-full bg-accent/15 text-accent-text">v{{ changelogRelease.tag_name?.replace('v', '') }}</span>
</div>
<p v-if="changelogRelease?.published_at" class="text-xs text-content-3 mt-1">{{ formatDate(changelogRelease.published_at) }}</p>
</div>
<div v-if="changelogRelease?.body" class="px-6 pb-4 flex-1 overflow-y-auto max-h-60">
<div class="text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ changelogRelease.body }}</div>
</div>
<div v-else class="px-6 pb-4">
<p class="text-sm text-content-3">暂无更新日志</p>
</div>
<div class="p-4 border-t border-line flex gap-3">
<button @click="showChangelogModal = false"
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
关闭
</button>
<button v-if="changelogRelease?.html_url" @click="openUrl(changelogRelease.html_url)"
class="flex-1 py-2 rounded-lg bg-accent/20 hover:bg-accent/30 text-accent-text text-sm font-medium transition">
GitHub 中查看
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, themeLabels, themeColors, type CloseAction } from '../stores/settings';
import { useToast } from '../composables/useToast';
import { useUpdater } from '../composables/useUpdater';
import { invoke } from '@tauri-apps/api/core';
import { getVersion } from '@tauri-apps/api/app';
import { openUrl } from '@tauri-apps/plugin-opener';
import { open } from '@tauri-apps/plugin-dialog';
import CustomSelect from '../components/CustomSelect.vue';
const settings = useSettingsStore();
const { showToast } = useToast();
const updater = useUpdater();
const devices = ref<string[]>([]);
const deviceOptions = computed(() => {
const options: Record<string, string> = { '': '跟随系统默认' };
for (const name of devices.value) {
options[name] = name;
}
return options;
});
const selectedDevice = computed({
get: () => settings.outputDevice || '',
set: (val: string) => {
const device = val === '' ? null : val;
settings.setOutputDevice(device);
invoke('set_output_device', { device }).then(() => {
showToast(device ? `已切换到: ${device}` : '已切换到系统默认', 'success');
}).catch((e) => {
console.error('切换设备失败: ', e);
showToast('切换设备失败', 'error');
});
}
});
async function loadDevices() {
try {
devices.value = await invoke<string[]>('get_output_devices');
} catch (e) {
console.error('获取设备失败: ', e);
}
}
const appVersion = ref('');
const defaultDownloadPath = ref('');
onMounted(async () => {
appVersion.value = await getVersion();
try {
defaultDownloadPath.value = await invoke<string>('get_default_download_path');
} catch { }
loadDevices();
});
const closeActionValue = computed({
get: () => settings.closeAction,
set: (val: CloseAction) => settings.setCloseAction(val),
});
async function pickDownloadFolder() {
const selected = await open({
directory: true,
multiple: false,
title: '选择下载路径',
});
if (selected) {
settings.setDownloadPath(selected);
showToast('下载路径已更新', 'success');
}
}
function clearDownloadPath() {
settings.setDownloadPath('');
showToast('已重置为默认路径', 'success');
}
async function handleCheckUpdate() {
const result = await updater.checkForUpdate(false);
if (!result) {
showToast(updater.error.value || '当前已是最新版本', 'info');
}
}
const fetchingChangelog = ref(false);
const changelogRelease = ref<any>(null);
const showChangelogModal = ref(false);
async function fetchChangelog() {
fetchingChangelog.value = true;
try {
const resp = await fetch('https://api.github.com/repos/atdunbg/Nekosonic-Music/releases?per_page=1');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const releases = await resp.json();
if (releases && releases.length > 0) {
changelogRelease.value = releases[0];
showChangelogModal.value = true;
} else {
showToast('暂无发布版本', 'info');
}
} catch (e: any) {
showToast(`获取失败: ${e}`, 'error');
} finally {
fetchingChangelog.value = false;
}
}
function formatDate(dateStr: string) {
try {
const d = new Date(dateStr);
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' });
} catch {
return dateStr;
}
}
const recordingId = ref<string | null>(null);
function formatShortcut(key: string): string {
return key
.replace('Control', 'Ctrl')
.replace('ArrowLeft', '←')
.replace('ArrowRight', '→')
.replace('ArrowUp', '↑')
.replace('ArrowDown', '↓')
.replace(/Key([A-Z])/g, '$1')
.replace(/\+/g, ' + ');
}
function startRecording(id: string) {
recordingId.value = id;
}
function resetShortcuts() {
settings.resetShortcuts();
showToast('快捷键已恢复默认', 'success');
}
const showResetConfirm = ref(false);
function handleResetAll() {
showResetConfirm.value = true;
}
function confirmResetAll() {
settings.resetAll();
showResetConfirm.value = false;
showToast('已恢复默认设置', 'success');
}
function onRecordingKeydown(e: KeyboardEvent) {
if (!recordingId.value) return;
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') {
recordingId.value = null;
return;
}
const parts: string[] = [];
if (e.ctrlKey || e.metaKey) parts.push('Control');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
const ignoredKeys = ['Control', 'Alt', 'Shift', 'Meta'];
if (!ignoredKeys.includes(e.key)) {
parts.push(e.code);
}
if (parts.length > 0 && !ignoredKeys.includes(e.key)) {
const hasModifier = parts.includes('Control') || parts.includes('Alt') || parts.includes('Shift');
if (!hasModifier) {
showToast('快捷键必须包含 Ctrl、Alt 或 Shift', 'error');
recordingId.value = null;
return;
}
const combo = parts.join('+');
settings.setShortcut(recordingId.value, combo);
recordingId.value = null;
}
}
onMounted(() => {
window.addEventListener('keydown', onRecordingKeydown, true);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', onRecordingKeydown, true);
});
</script>