fix: 优化&功能

- 播放核心异常提示
- 支持更改缓存位置
This commit is contained in:
lqtmcstudio
2026-02-07 10:56:47 +08:00
parent 47689f23a4
commit 664145c6e8
14 changed files with 1090 additions and 61 deletions

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
<title>QZMusic</title>
</head>
<body>

View File

@@ -56,6 +56,11 @@
<Icon icon="lucide:folder-open" />
打开目录
</button>
<button class="action-btn" @click="changeCacheLocation" :disabled="isChangingCache">
<Icon v-if="isChangingCache" icon="lucide:loader-2" class="spin" />
<Icon v-else icon="lucide:folder-edit" />
{{ isChangingCache ? '迁移中...' : '更改' }}
</button>
</div>
</div>
@@ -74,6 +79,49 @@
</div>
</div>
<!-- 插件管理 -->
<div v-else-if="activeCategory === 'plugins'" class="section">
<div class="section-header">
<div>
<h2 class="section-title" style="border:none;margin:0;padding:0">插件管理</h2>
<div class="setting-desc">管理已安装的音乐源插件</div>
</div>
<button class="action-btn primary" @click="installPluginFromFile">
<Icon icon="lucide:plus" />
安装插件
</button>
</div>
<div class="plugin-grid">
<div v-if="plugins.length === 0" class="empty-state">
<Icon icon="lucide:package-open" class="empty-icon"/>
<p>暂无已安装的插件</p>
<button class="text-btn" @click="installPluginFromFile">点击安装</button>
</div>
<div v-for="plugin in plugins" :key="plugin.id" class="plugin-card">
<div class="plugin-info">
<div class="plugin-header">
<span class="plugin-name">{{ plugin.name || plugin.id }}</span>
<span class="plugin-version" v-if="plugin.version">v{{ plugin.version }}</span>
</div>
<p class="plugin-desc">{{ plugin.description || '暂无描述' }}</p>
<div class="plugin-tags" v-if="plugin.quality?.length">
<span v-for="q in plugin.quality" :key="q.id" class="tag" :title="q.name">{{ q.ui }}</span>
</div>
</div>
<div class="plugin-actions">
<button class="action-btn danger small" @click="confirmUninstall(plugin)">
<Icon icon="lucide:trash-2" />
卸载
</button>
</div>
</div>
</div>
</div>
<!-- Uninstall Confirm Modal -->
<!-- 外观设置 -->
<div v-else-if="activeCategory === 'appearance'" class="section">
<h2 class="section-title">外观设置</h2>
@@ -179,6 +227,20 @@
<p class="copyright">©2026 QZ <DEVELOPERS></DEVELOPERS></p>
</div>
</div>
<!-- Uninstall Confirm Modal -->
<Transition name="fade">
<div class="modal-overlay" v-if="showUninstallModal">
<div class="modal-content">
<h3>卸载插件</h3>
<p>确定要卸载插件 "{{ pluginToUninstall?.name }}" 此操作无法撤销</p>
<div class="modal-actions">
<button class="action-btn" @click="showUninstallModal = false">取消</button>
<button class="action-btn danger" @click="executeUninstall">确认卸载</button>
</div>
</div>
</div>
</Transition>
</div>
</div>
</div>
@@ -187,8 +249,9 @@
</template>
<script setup lang="ts">
import { ref, reactive, onBeforeMount, nextTick } from 'vue';
import { ref, reactive, onBeforeMount, nextTick, watch } from 'vue';
import { Icon } from '@iconify/vue';
import { MessagePlugin } from 'tdesign-vue-next';
import { usePlayerStore } from '../stores/player';
const playerStore = usePlayerStore();
@@ -197,6 +260,7 @@ defineEmits(['close']);
const categories = [
{ id: 'storage', name: '存储', icon: 'lucide:hard-drive' },
{ id: 'plugins', name: '插件', icon: 'lucide:blocks' },
{ id: 'appearance', name: '外观', icon: 'lucide:palette' },
{ id: 'playback', name: '播放', icon: 'lucide:headphones' },
{ id: 'shortcuts', name: '快捷键', icon: 'lucide:keyboard' },
@@ -217,6 +281,7 @@ const accentColors = [
const activeCategory = ref('storage');
const isLoaded = ref(false);
const enableTransition = ref(false);
const plugins = ref<any[]>([]);
const settings = reactive({
persistCache: true,
@@ -232,6 +297,70 @@ const cacheInfo = reactive({
size: '',
});
const loadPlugins = async () => {
try {
if (window.electronAPI?.plugin?.getAll) {
plugins.value = await window.electronAPI.plugin.getAll();
}
} catch (e) {
console.error('Failed to load plugins', e);
}
};
const uninstallPlugin = async (id: string) => {
try {
if (window.electronAPI?.plugin?.uninstall) {
await window.electronAPI.plugin.uninstall(id);
await loadPlugins();
}
} catch (e) {
console.error('Failed to uninstall plugin', e);
}
}
const installPluginFromFile = async () => {
try {
if (window.electronAPI?.plugin?.install) {
const result = await window.electronAPI.plugin.install();
if (result.success) {
MessagePlugin.success(result.message || '安装成功');
await loadPlugins();
} else {
if (result.message !== 'canceled') { // Assuming 'canceled' might be a thing, or just show whatever message comes back
MessagePlugin.error(result.message || '安装失败');
}
}
}
} catch (e) {
console.error('Failed to install plugin', e);
MessagePlugin.error('安装过程中发生错误');
}
}
// Modal Logic
const showUninstallModal = ref(false);
const pluginToUninstall = ref<any>(null);
const confirmUninstall = (plugin: any) => {
pluginToUninstall.value = plugin;
showUninstallModal.value = true;
};
const executeUninstall = async () => {
if (pluginToUninstall.value) {
await uninstallPlugin(pluginToUninstall.value.id);
showUninstallModal.value = false;
pluginToUninstall.value = null;
}
};
watch(activeCategory, (newVal) => {
if (newVal === 'plugins') {
loadPlugins();
}
});
const loadCacheInfo = async () => {
if (window.electronAPI) {
const info = await window.electronAPI.getCacheInfo();
@@ -294,6 +423,31 @@ const clearCache = async () => {
}
};
const isChangingCache = ref(false);
const changeCacheLocation = async () => {
if (window.electronAPI && !isChangingCache.value) {
try {
const path = await window.electronAPI.selectDirectory();
if (path) {
isChangingCache.value = true;
const result = await window.electronAPI.changeCacheLocation(path);
if (result.success) {
MessagePlugin.success('缓存位置已修改');
await loadCacheInfo();
} else {
MessagePlugin.error(result.message || '修改失败');
}
}
} catch (e) {
MessagePlugin.error('操作失败');
console.error(e);
} finally {
isChangingCache.value = false;
}
}
}
// Load settings BEFORE mount to avoid visual flicker
onBeforeMount(async () => {
await Promise.all([loadCacheInfo(), loadAppearance()]);
@@ -479,6 +633,9 @@ onBeforeMount(async () => {
.setting-control {
margin-left: 24px;
display: flex;
align-items: center;
gap: 12px;
}
/* Toggle Switch */
@@ -702,6 +859,19 @@ input:checked + .toggle-slider:before {
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Radio Group */
.radio-group {
display: flex;
@@ -724,4 +894,167 @@ input:checked + .toggle-slider:before {
height: 16px;
cursor: pointer;
}
/* Plugin Card Styles */
.plugin-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.plugin-card {
background: var(--color-bg-secondary);
border-radius: 12px;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: flex-start;
border: 1px solid var(--color-border);
transition: all 0.2s ease;
}
.plugin-card:hover {
border-color: var(--color-accent);
box-shadow: var(--shadow-sm);
transform: translateY(-2px);
}
.plugin-info {
flex: 1;
}
.plugin-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.tag {
font-size: 11px;
background: var(--color-accent-soft);
color: var(--color-accent);
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
.action-btn.small {
padding: 6px 12px;
font-size: 13px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-border);
}
.plugin-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.empty-state {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 0;
color: var(--color-text-muted);
}
.empty-icon {
width: 48px;
height: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.text-btn {
margin-top: 12px;
color: var(--color-accent);
background: none;
border: none;
cursor: pointer;
font-size: 14px;
text-decoration: underline;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
backdrop-filter: blur(4px);
}
.modal-content {
background: var(--color-bg-primary);
padding: 24px;
border-radius: 16px;
width: 400px;
box-shadow: var(--shadow-lg);
border: 1px solid var(--color-border);
}
.modal-content h3 {
margin-bottom: 16px;
font-size: 18px;
color: var(--color-text-primary);
}
.modal-content p {
color: var(--color-text-secondary);
margin-bottom: 24px;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.plugin-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.plugin-name {
font-weight: 500;
font-size: 16px;
color: var(--color-text-primary);
}
.plugin-version {
font-size: 12px;
background: var(--color-bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
color: var(--color-text-secondary);
}
.plugin-desc {
font-size: 13px;
color: var(--color-text-secondary);
padding-bottom: 8px;
}
.action-btn.small {
padding: 6px 12px;
font-size: 13px;
}
</style>

View File

@@ -185,12 +185,12 @@ export const usePlayerStore = defineStore('player', () => {
if (!song || !song.id) return;
try {
// Check if plugin API exists
if (window.electronAPI?.plugin?.getLyric) {
const rawLyric = await window.electronAPI.plugin.getLyric(song.source || 'kw', song.id.toString());
console.log(rawLyric)
} else {
MessagePlugin.warning("当前插件不支持歌词获取").then()
}
// if (window.electronAPI?.plugin?.getLyric) {
// const rawLyric = await window.electronAPI.plugin.getLyric(song.source || 'kw', song.id.toString());
// console.log(rawLyric)
// } else {
// MessagePlugin.warning("当前插件不支持歌词获取").then()
// }
} catch (e) {
console.error('Failed to fetch lyrics:', e);
}

View File

@@ -35,7 +35,36 @@ input {
background: none;
font-family: inherit;
color: inherit;
}
input[type='radio'] {
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
border: 1px solid var(--color-border);
border-radius: 50%;
outline: none;
cursor: pointer;
position: relative;
box-shadow: none !important;
}
input[type='radio']:checked {
border-color: var(--color-accent);
background-color: var(--color-accent);
}
input[type='radio']:checked::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 6px;
height: 6px;
background-color: white;
border-radius: 50%;
}
/* Custom Scrollbar for 'Exquisite' look */

View File

@@ -17,12 +17,17 @@ export interface IElectronAPI {
call: (pluginId: string, method: string, args: any[]) => Promise<any>;
search: (pluginId: string, query: string, page: number, limit: number) => Promise<any>;
getLyric: (pluginId: string, id: string) => Promise<any>;
getAll: () => Promise<any[]>;
uninstall: (pluginId: string) => Promise<boolean>;
install: () => Promise<{ success: boolean; message: string }>;
};
// Cache Control
getCacheInfo: () => Promise<{ path: string; size: string; persistCache: boolean }>;
setCachePersist: (persist: boolean) => Promise<void>;
openCacheFolder: () => Promise<void>;
clearCache: () => Promise<void>;
changeCacheLocation: (newPath: string) => Promise<{ success: boolean; message: string; path?: string }>;
selectDirectory: () => Promise<string | null>;
// Settings
settings: {
getAll: () => Promise<{ persistCache: boolean; theme: 'dark' | 'light'; accentColor: string }>;

View File

@@ -13,13 +13,36 @@
<transition name="slide-fade">
<div class="settings-panel" v-if="showSettings">
<div class="setting-item">
<div class="limit-setting">
<span class="setting-label">每页显示: {{ limit }} </span>
<div class="slider-container">
<input type="range" min="10" max="100" step="10" v-model.number="limit" class="setting-slider">
<div class="slider-track" :style="{ width: ((limit - 10) / 90) * 100 + '%' }"></div>
</div>
</div>
<!-- Plugin Selector -->
<div class="plugin-select-container">
<button class="select-trigger" @click.stop="toggleDropdown" :title="'当前源: ' + activePluginName">
<span>{{ activePluginName }}</span>
<Icon icon="lucide:chevron-down" class="dropdown-icon" :class="{ open: isDropdownOpen }" />
</button>
<transition name="fade">
<div class="select-options" v-if="isDropdownOpen">
<div
v-for="plugin in plugins"
:key="plugin.id"
class="option"
:class="{ active: plugin.id === activePlugin }"
@click="selectPlugin(plugin)"
>
{{ plugin.name }}
<Icon icon="lucide:check" v-if="plugin.id === activePlugin" class="check-icon" />
</div>
</div>
</transition>
</div>
</div>
</transition>
@@ -102,7 +125,7 @@
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRoute } from 'vue-router';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player';
@@ -110,9 +133,9 @@ import { transformSearchSong } from '../utils/songUtils';
import type { Song } from '../types/song';
const route = useRoute();
const playerStore = usePlayerStore();
// --- State ---
const query = computed(() => route.query.q as string || '');
const currentPage = ref(1);
const showSettings = ref(false);
@@ -124,6 +147,39 @@ const loading = ref(false);
const error = ref(false);
const songs = ref<Song[]>([]);
// Plugin Selector State
const plugins = ref<any[]>([]);
const activePlugin = ref<string>(''); // Plugin ID
const isDropdownOpen = ref(false);
const activePluginName = computed(() => {
const p = plugins.value.find(p => p.id === activePlugin.value);
return p ? p.name : '选择源';
});
const toggleDropdown = () => {
isDropdownOpen.value = !isDropdownOpen.value;
};
const selectPlugin = (plugin: any) => {
if (activePlugin.value !== plugin.id) {
activePlugin.value = plugin.id;
sessionStorage.setItem('qz-active-plugin', plugin.id);
isDropdownOpen.value = false;
} else {
isDropdownOpen.value = false;
}
};
// Close dropdown on click outside
const closeDropdown = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.plugin-select-container')) {
isDropdownOpen.value = false;
}
};
// --- Computed ---
const totalPages = computed(() => {
const t = Number(total.value) || 0;
const l = Number(limit.value) || 30;
@@ -132,9 +188,8 @@ const totalPages = computed(() => {
const visiblePages = computed(() => {
const current = currentPage.value;
// Ensure totalPages is strictly valid
const total = totalPages.value;
const delta = 2; // 2 on each side
const delta = 2;
let start = Math.max(1, current - delta);
let end = Math.min(total, current + delta);
@@ -153,19 +208,41 @@ const visiblePages = computed(() => {
return pages;
});
// --- Methods ---
const loadPlugins = async () => {
try {
if (window.electronAPI?.plugin?.getAll) {
const all = await window.electronAPI.plugin.getAll();
// Filter valid plugins basically (have id and name)
plugins.value = all.filter((p: any) => p.id && p.name);
// Restore selection or default
const saved = sessionStorage.getItem('qz-active-plugin');
if (saved && plugins.value.find(p => p.id === saved)) {
activePlugin.value = saved;
} else if (plugins.value.length > 0) {
// Default to 'wy' if present, else first
const wy = plugins.value.find(p => p.id === 'wy');
activePlugin.value = wy ? 'wy' : plugins.value[0].id;
}
}
} catch (e) {
console.error("Failed to load plugins", e);
}
};
const fetchData = async () => {
if (!query.value) return;
if (!query.value || !activePlugin.value) return;
loading.value = true;
error.value = false;
songs.value = [];
try {
const result = await window.electronAPI.plugin.search('wy', query.value, currentPage.value, limit.value);
const result = await window.electronAPI.plugin.search(activePlugin.value, query.value, currentPage.value, limit.value);
if (result && result.list) {
songs.value = result.list.map((item: any) => transformSearchSong(item));
// Prioritize songCount if available, otherwise fallback to total, but NEVER use just list length as total
total.value = result.songCount || result.total || 0;
} else {
total.value = 0;
@@ -190,27 +267,17 @@ const handlePlaySong = (index: number) => {
const getHighlightRegex = (q: string) => {
const trimmed = q.trim();
if (!trimmed) return null;
// Escape regex characters
const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// If strict or short query, fallback to exact match or simple space split
// "超过2个字相似" -> If > 2 chars, try to match substrings
if (trimmed.length <= 2) {
return new RegExp(escapeRegExp(trimmed), 'gi');
}
// Generate all substrings of length >= 2
const substrings = new Set<string>();
substrings.add(trimmed); // Always include full query
substrings.add(trimmed);
for (let i = 0; i < trimmed.length; i++) {
for (let j = i + 2; j <= trimmed.length; j++) {
substrings.add(trimmed.slice(i, j));
}
}
// Sort by length match (longest first)
const sorted = Array.from(substrings).sort((a, b) => b.length - a.length);
const pattern = sorted.map(s => escapeRegExp(s)).join('|');
return new RegExp(pattern, 'gi');
@@ -218,23 +285,43 @@ const getHighlightRegex = (q: string) => {
const highlight = (text: string) => {
if (!query.value || !text) return text;
const regex = getHighlightRegex(query.value);
if (!regex) return text;
return text.replace(regex, match => `<span class="highlight">${match}</span>`);
};
// --- Watchers & Lifecycle ---
watch(query, () => {
currentPage.value = 1;
fetchData();
}, { immediate: true });
// ensure plugins loaded before fetching? usually mounted happens first
if (activePlugin.value) fetchData();
}, { immediate: false }); // Wait for mount init
watch(limit, (newLimit) => {
localStorage.setItem('qz-search-limit', newLimit.toString());
currentPage.value = 1;
fetchData();
});
watch(activePlugin, (newVal, oldVal) => {
if (newVal && newVal !== oldVal && query.value) {
currentPage.value = 1;
fetchData();
}
});
onMounted(async () => {
document.addEventListener('click', closeDropdown);
await loadPlugins();
// After plugins loaded, if we have a query, fetch data
if (query.value && activePlugin.value) {
fetchData();
}
});
onBeforeUnmount(() => {
document.removeEventListener('click', closeDropdown);
});
</script>
@@ -316,19 +403,115 @@ watch(limit, (newLimit) => {
/* Settings Panel */
.settings-panel {
background: var(--color-bg-secondary);
background: var(--color-bg-primary);
border-radius: var(--radius-lg);
padding: 16px 20px;
margin-bottom: 20px;
border: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
position: relative; /* Context */
}
.setting-item {
.limit-setting {
display: flex;
align-items: center;
gap: 16px;
}
/* Plugin Selector */
.plugin-select-container {
position: relative;
/* ensure it stays on top when options open? No, options use absolute */
z-index: 10;
}
.select-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--color-bg-primary);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.1);
border-radius: var(--radius-md);
color: var(--color-text-primary);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
min-width: 120px;
justify-content: space-between;
}
.select-trigger:hover {
background: var(--color-bg-secondary);
border-color: var(--color-text-muted);
}
.dropdown-icon {
font-size: 16px;
transition: transform 0.2s;
}
.dropdown-icon.open {
transform: rotate(180deg);
}
.select-options {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 160px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
.option {
padding: 8px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
color: var(--color-text-secondary);
display: flex;
align-items: center;
justify-content: space-between;
transition: all 0.2s;
}
.option:hover {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.option.active {
background: var(--color-accent-soft);
color: var(--color-accent);
font-weight: 500;
}
.check-icon {
font-size: 14px;
}
/* Fade util */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.setting-label {
font-size: 14px;
color: var(--color-text-secondary);