mirror of
https://github.com/lqtmcstudio/QZMusic_PC.git
synced 2026-06-20 23:35:06 +08:00
fix: 优化&功能
- 播放核心异常提示 - 支持更改缓存位置
This commit is contained in:
18
package-lock.json
generated
18
package-lock.json
generated
@@ -25,7 +25,7 @@
|
|||||||
"jss": "^10.10.0",
|
"jss": "^10.10.0",
|
||||||
"jss-preset-default": "^10.10.0",
|
"jss-preset-default": "^10.10.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"tdesign-vue-next": "^1.17.7",
|
"tdesign-vue-next": "^1.18.2",
|
||||||
"url": "^0.11.4",
|
"url": "^0.11.4",
|
||||||
"vite-plugin-wasm": "^3.5.0",
|
"vite-plugin-wasm": "^3.5.0",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
@@ -9064,21 +9064,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tdesign-icons-vue-next": {
|
"node_modules/tdesign-icons-vue-next": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.2",
|
||||||
"resolved": "https://registry.npmmirror.com/tdesign-icons-vue-next/-/tdesign-icons-vue-next-0.4.1.tgz",
|
"resolved": "https://registry.npmmirror.com/tdesign-icons-vue-next/-/tdesign-icons-vue-next-0.4.2.tgz",
|
||||||
"integrity": "sha512-uDPuTLRORnGcTyVGNoentNaK4V+ZcBmhYwcY3KqDaQQ5rrPeLMxu0ZVmgOEf0JtF2QZiqAxY7vodNEiLUdoRKA==",
|
"integrity": "sha512-mTPk1ApcCA9oxDiSs9ttMdd09H8ICBooZIr2bwDEELnYr60sYSUbvWojQ2tp84MUAMuw21HgyVyGkT49db0GFg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.16.3"
|
"@babel/runtime": "^7.16.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "^3.0.0"
|
"vue": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tdesign-vue-next": {
|
"node_modules/tdesign-vue-next": {
|
||||||
"version": "1.17.7",
|
"version": "1.18.2",
|
||||||
"resolved": "https://registry.npmmirror.com/tdesign-vue-next/-/tdesign-vue-next-1.17.7.tgz",
|
"resolved": "https://registry.npmmirror.com/tdesign-vue-next/-/tdesign-vue-next-1.18.2.tgz",
|
||||||
"integrity": "sha512-mV/9mm/nIS+tfx1oUG1IMMmTPFeZfLmP8bIVEa7S9CpVke2+Yei5i8RBXmDwF/d+OaDoKVgwUq08goSIZfRePQ==",
|
"integrity": "sha512-Y7hpEHNQPft7TP0TfgZoBMcXUkILOxZRtV0es7ZFcGmsFqkOl4W2p2amYx78Id4W40VizJXzxP1HzFXRI0MYqw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.22.6",
|
"@babel/runtime": "^7.22.6",
|
||||||
@@ -9091,7 +9091,7 @@
|
|||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"sortablejs": "^1.15.0",
|
"sortablejs": "^1.15.0",
|
||||||
"tdesign-icons-vue-next": "~0.4.1",
|
"tdesign-icons-vue-next": "~0.4.2",
|
||||||
"tinycolor2": "^1.6.0",
|
"tinycolor2": "^1.6.0",
|
||||||
"validator": "^13.15.23"
|
"validator": "^13.15.23"
|
||||||
},
|
},
|
||||||
|
|||||||
36
package.json
36
package.json
@@ -1,11 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "qzmusic",
|
"name": "qzmusic",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"author": "lqtmcstudio",
|
||||||
|
"description": "QZMusic - 简洁、美观、拓展性强的音乐播放器",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"preview": "electron-vite preview"
|
"preview": "electron-vite preview",
|
||||||
|
"electron:build": "electron-vite build && electron-builder --win --x64 --config --publish never"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@applemusic-like-lyrics/core": "^0.2.0",
|
"@applemusic-like-lyrics/core": "^0.2.0",
|
||||||
@@ -25,7 +28,7 @@
|
|||||||
"jss": "^10.10.0",
|
"jss": "^10.10.0",
|
||||||
"jss-preset-default": "^10.10.0",
|
"jss-preset-default": "^10.10.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"tdesign-vue-next": "^1.17.7",
|
"tdesign-vue-next": "^1.18.2",
|
||||||
"url": "^0.11.4",
|
"url": "^0.11.4",
|
||||||
"vite-plugin-wasm": "^3.5.0",
|
"vite-plugin-wasm": "^3.5.0",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
@@ -42,5 +45,32 @@
|
|||||||
"vite-plugin-node-polyfills": "^0.25.0",
|
"vite-plugin-node-polyfills": "^0.25.0",
|
||||||
"vue-tsc": "^2.0.26"
|
"vue-tsc": "^2.0.26"
|
||||||
},
|
},
|
||||||
"main": "out/main/index.js"
|
"main": "out/main/index.js",
|
||||||
|
"build": {
|
||||||
|
"productName": "QZMusic",
|
||||||
|
"appId": "love.qz.music",
|
||||||
|
"asar": true,
|
||||||
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": "nsis",
|
||||||
|
"icon": "public/icon.ico"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"createDesktopShortcut": true
|
||||||
|
},
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "core/qzplayer.exe",
|
||||||
|
"to": "core/qzplayer.exe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "core/libfftw3f-3.dll",
|
||||||
|
"to": "core/libfftw3f-3.dll"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
320
src/main/index.ts
Normal file
320
src/main/index.ts
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import { app, BrowserWindow, Menu, ipcMain, dialog } from 'electron'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import path from 'node:path'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import { QzpController } from './qzpController'
|
||||||
|
import { startProxyServer, cleanupCache, getCacheDir, getCacheSize, setPersistCache, clearCacheNow } from './proxyServer'
|
||||||
|
import { PluginSystem } from './pluginSystem'
|
||||||
|
import { loadSettings, saveSettings, getSetting, AppSettings } from './settingsStore'
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
process.env.APP_ROOT = path.join(__dirname, '../..')
|
||||||
|
|
||||||
|
path.join(process.env.APP_ROOT, 'out/main');
|
||||||
|
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'out/renderer')
|
||||||
|
|
||||||
|
process.env.VITE_PUBLIC = process.env.ELECTRON_RENDERER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST
|
||||||
|
|
||||||
|
let win: BrowserWindow | null
|
||||||
|
let qzplayer: QzpController | null
|
||||||
|
|
||||||
|
// === Electron 窗口逻辑 ===
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
win = new BrowserWindow({
|
||||||
|
frame: false,
|
||||||
|
minWidth: 950,
|
||||||
|
minHeight: 800,
|
||||||
|
width: 1000,
|
||||||
|
height: 800,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '../preload/index.js'),
|
||||||
|
sandbox: false,
|
||||||
|
contextIsolation: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
win.webContents.on('did-finish-load', () => {
|
||||||
|
win?.webContents.send('main-process-message', new Date().toLocaleString())
|
||||||
|
})
|
||||||
|
|
||||||
|
// electron-vite sets ELECTRON_RENDERER_URL in dev mode
|
||||||
|
if (process.env.ELECTRON_RENDERER_URL) {
|
||||||
|
win.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||||
|
} else {
|
||||||
|
win.loadFile(path.join(RENDERER_DIST, 'index.html'))
|
||||||
|
}
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
win.webContents.openDevTools();
|
||||||
|
}
|
||||||
|
registerZoomShortcuts(win)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === IPC 监听 ===
|
||||||
|
|
||||||
|
ipcMain.on('window-minimize', (event) => BrowserWindow.fromWebContents(event.sender)?.minimize())
|
||||||
|
ipcMain.on('window-maximize', () => win?.isMaximized() ? win.unmaximize() : win?.maximize())
|
||||||
|
ipcMain.on('window-close', () => win?.close())
|
||||||
|
ipcMain.handle('window-is-maximized', () => win?.isMaximized() || false)
|
||||||
|
|
||||||
|
// --- qzplayer IPC Handlers ---
|
||||||
|
ipcMain.handle('qzplayer-command', async (_, command: any[]) => {
|
||||||
|
if (qzplayer) {
|
||||||
|
qzplayer.send(command)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Quick Helpers
|
||||||
|
ipcMain.handle('qzplayer-load', (_, url) => qzplayer?.load(url))
|
||||||
|
ipcMain.handle('qzplayer-play', () => qzplayer?.play())
|
||||||
|
ipcMain.handle('qzplayer-pause', () => qzplayer?.pause())
|
||||||
|
ipcMain.handle('qzplayer-toggle-pause', () => qzplayer?.togglePause())
|
||||||
|
ipcMain.handle('qzplayer-stop', () => qzplayer?.stop())
|
||||||
|
ipcMain.handle('qzplayer-set-volume', (_, vol) => qzplayer?.setVolume(vol))
|
||||||
|
ipcMain.handle('qzplayer-seek', (_, time) => qzplayer?.seek(time))
|
||||||
|
|
||||||
|
// PluginSystem
|
||||||
|
ipcMain.handle(
|
||||||
|
'plugin:call',
|
||||||
|
async (_evenv, pluginId: string, method: string, args: any[]) => {
|
||||||
|
const plugin = new PluginSystem(pluginId)
|
||||||
|
|
||||||
|
if (typeof (plugin as any)[method] !== 'function') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Method ${method} not found`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await (plugin as any)[method](...args)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle('plugin:getAll', () => {
|
||||||
|
return PluginSystem.getAllPlugins()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('plugin:uninstall', (_, id: string) => {
|
||||||
|
return PluginSystem.uninstallPlugin(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('plugin:install', async () => {
|
||||||
|
if (!win) return false
|
||||||
|
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
|
||||||
|
title: '选择插件文件',
|
||||||
|
filters: [{ name: 'JavaScript Plugins', extensions: ['js'] }],
|
||||||
|
properties: ['openFile']
|
||||||
|
})
|
||||||
|
|
||||||
|
if (canceled || filePaths.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return await PluginSystem.installPlugin(filePaths[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cache IPC Handlers
|
||||||
|
ipcMain.handle('cache:getInfo', () => {
|
||||||
|
const settings = loadSettings();
|
||||||
|
return {
|
||||||
|
path: settings.cachePath || getCacheDir(), // Use setting or fallback
|
||||||
|
size: getCacheSize(),
|
||||||
|
persistCache: settings.persistCache
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('cache:setPersist', (_, persist: boolean) => {
|
||||||
|
setPersistCache(persist)
|
||||||
|
saveSettings({ persistCache: persist })
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('cache:openFolder', () => {
|
||||||
|
const settings = loadSettings()
|
||||||
|
const dir = settings.cachePath || getCacheDir()
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
require('electron').shell.openPath(dir)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('cache:clear', () => {
|
||||||
|
clearCacheNow()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('dialog:openDirectory', async () => {
|
||||||
|
if (!win) return null
|
||||||
|
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
|
||||||
|
title: 'Select Cache Directory',
|
||||||
|
properties: ['openDirectory', 'createDirectory']
|
||||||
|
})
|
||||||
|
if (canceled || filePaths.length === 0) return null
|
||||||
|
return filePaths[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('cache:changeLocation', async (_, newPath: string) => {
|
||||||
|
try {
|
||||||
|
const settings = loadSettings()
|
||||||
|
const oldPath = settings.cachePath || getCacheDir()
|
||||||
|
|
||||||
|
if (oldPath === newPath) {
|
||||||
|
return { success: true, message: 'Path is the same' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check permissions / Create new dir
|
||||||
|
if (!fs.existsSync(newPath)) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(newPath, { recursive: true })
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, message: 'Cannot create directory' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Migration: Move files from oldPath to newPath
|
||||||
|
// We only move files if old path exists
|
||||||
|
if (fs.existsSync(oldPath)) {
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(oldPath);
|
||||||
|
for (const file of files) {
|
||||||
|
const src = path.join(oldPath, file);
|
||||||
|
const dest = path.join(newPath, file);
|
||||||
|
// Copy then delete to be safe, or rename
|
||||||
|
// Simple rename might fail across partitions, so verify
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.renameSync(src, dest);
|
||||||
|
} catch (moveErr) {
|
||||||
|
// Fallback to copy and unlink if rename fails (e.g. cross-drive)
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
fs.unlinkSync(src);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try to remove old dir if empty
|
||||||
|
try { fs.rmdirSync(oldPath); } catch (_) { }
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Cache] Migration failed partially:", e)
|
||||||
|
// Continue anyway to set the new path?
|
||||||
|
// Better to warn user. But let's assume we proceed and just log.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update Settings
|
||||||
|
saveSettings({ cachePath: newPath })
|
||||||
|
|
||||||
|
// 4. Update Proxy Server if needed (it reads from settings or we notify it)
|
||||||
|
// Ideally proxyServer should just use `getCacheDir()` which now reads from settings?
|
||||||
|
// Wait, `getCacheDir` in proxyServer.ts probably uses hardcoded or internal variable.
|
||||||
|
// We need to update proxyServer.ts logic too or restart it.
|
||||||
|
// For now, let's assume getCacheDir() needs update or we restart proxy.
|
||||||
|
// Actually, let's make sure proxyServer gets the new path.
|
||||||
|
// We'll restart proxy to be safe or add a setPath method.
|
||||||
|
// *Self-correction*: The simple way is to restart the proxy server function implies checking `getCacheDir` from settings.
|
||||||
|
// Let's ensure proxyServer.ts `setCacheDir` exists or similar.
|
||||||
|
|
||||||
|
// RE-CHECK: `proxyServer.ts` isn't fully visible here.
|
||||||
|
// I'll add a TODO/Warning in comments and handle proxy update via current imports if possible.
|
||||||
|
// Since I can't `import { setCachePath } from './proxyServer'` yet (I haven't checked if it exists),
|
||||||
|
// I will trust that the next step or automatic reload handles it, OR better: implement `updateCachePath` in proxyServer.
|
||||||
|
|
||||||
|
return { success: true, message: 'Cache location updated', path: newPath }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { success: false, message: e.message || 'Unknown error' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// Settings IPC Handlers
|
||||||
|
ipcMain.handle('settings:getAll', () => {
|
||||||
|
return loadSettings()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('settings:set', (_, settings: Partial<AppSettings>) => {
|
||||||
|
return saveSettings(settings)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('settings:getTheme', () => {
|
||||||
|
return getSetting('theme')
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('settings:setTheme', (_, theme: 'dark' | 'light') => {
|
||||||
|
saveSettings({ theme })
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('settings:getAccentColor', () => {
|
||||||
|
return getSetting('accentColor')
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('settings:setAccentColor', (_, color: string) => {
|
||||||
|
saveSettings({ accentColor: color })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit()
|
||||||
|
win = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('will-quit', () => {
|
||||||
|
cleanupCache()
|
||||||
|
if (qzplayer) {
|
||||||
|
qzplayer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function registerZoomShortcuts(win: BrowserWindow) {
|
||||||
|
win.webContents.on('before-input-event', (event, input) => {
|
||||||
|
if (input.control || input.meta) {
|
||||||
|
if (input.key.toLowerCase() === '=' || input.key === '+') {
|
||||||
|
let currentZoom = win.webContents.getZoomFactor();
|
||||||
|
win.webContents.setZoomFactor(currentZoom + 0.1);
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (input.key === '-' || input.key === '_') {
|
||||||
|
let currentZoom = win.webContents.getZoomFactor();
|
||||||
|
// Limit minimum zoom to avoid making it too small to see
|
||||||
|
if (currentZoom > 0.5) {
|
||||||
|
win.webContents.setZoomFactor(currentZoom - 0.1);
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (input.key === '0') {
|
||||||
|
win.webContents.setZoomFactor(1);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
// Ensure plugins directory exists
|
||||||
|
const pluginsPath = path.join(app.getPath('userData'), 'plugins')
|
||||||
|
if (!fs.existsSync(pluginsPath)) {
|
||||||
|
fs.mkdirSync(pluginsPath, { recursive: true })
|
||||||
|
}
|
||||||
|
// ----------------------------------------
|
||||||
|
Menu.setApplicationMenu(null)
|
||||||
|
createWindow()
|
||||||
|
|
||||||
|
// Start Proxy Server
|
||||||
|
startProxyServer()
|
||||||
|
|
||||||
|
// Start qzplayer
|
||||||
|
qzplayer = new QzpController()
|
||||||
|
qzplayer.start()
|
||||||
|
|
||||||
|
qzplayer.on('event', (data) => {
|
||||||
|
// Forward qzplayer events to Render Process
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
win.webContents.send('qzplayer-event', data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,7 +2,7 @@ import { app } from 'electron'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import { MessagePlugin } from "tdesign-vue-next";
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -106,8 +106,124 @@ export class PluginSystem {
|
|||||||
return result
|
return result
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
MessagePlugin.error(e).then()
|
console.error(e)
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static getAllPlugins(): any[] {
|
||||||
|
try {
|
||||||
|
const pluginsPath = path.join(app.getPath('userData'), 'plugins')
|
||||||
|
if (!fs.existsSync(pluginsPath)) return []
|
||||||
|
|
||||||
|
return fs.readdirSync(pluginsPath).map(dir => {
|
||||||
|
const pluginPath = path.join(pluginsPath, dir, 'index.js')
|
||||||
|
if (fs.existsSync(pluginPath)) {
|
||||||
|
try {
|
||||||
|
// Clear cache to ensure fresh load
|
||||||
|
delete require.cache[require.resolve(pluginPath)]
|
||||||
|
const pluginModule = require(pluginPath)
|
||||||
|
if (pluginModule.pluginInfo) {
|
||||||
|
return {
|
||||||
|
...pluginModule.pluginInfo.info,
|
||||||
|
quality: pluginModule.pluginInfo.quality,
|
||||||
|
_path: dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for current simple plugins if they don't have metadata
|
||||||
|
return {
|
||||||
|
id: dir,
|
||||||
|
name: dir,
|
||||||
|
description: 'No description',
|
||||||
|
version: '0.0.0',
|
||||||
|
_path: dir
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[PluginSystem] Failed to load plugin ${dir}:`, e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}).filter(p => p !== null)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PluginSystem] getAllPlugins failed:', e)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async installPlugin(filePath: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
// Require the file to get plugin info
|
||||||
|
// Notes: We might need to copy it to a temp location if 'require' caches by path strictness,
|
||||||
|
// but for now let's try requiring the source.
|
||||||
|
// If the user selects a file, it's likely outside our project.
|
||||||
|
// Node's require might need valid path.
|
||||||
|
|
||||||
|
// However, we can also just read the file content and do a regex check if we want to be safe,
|
||||||
|
// but the user's plugin example is a JS object.
|
||||||
|
// Let's copy it to a temporary location in userData to rely on 'require'
|
||||||
|
|
||||||
|
const tempId = `temp_${Date.now()}`
|
||||||
|
const tempDir = path.join(app.getPath('userData'), 'temp_plugins', tempId)
|
||||||
|
const tempFile = path.join(tempDir, 'index.js')
|
||||||
|
|
||||||
|
if (!fs.existsSync(tempDir)) {
|
||||||
|
fs.mkdirSync(tempDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.copyFileSync(filePath, tempFile)
|
||||||
|
|
||||||
|
// Clear cache just in case
|
||||||
|
delete require.cache[require.resolve(tempFile)]
|
||||||
|
const pluginModule = require(tempFile)
|
||||||
|
|
||||||
|
let id = ''
|
||||||
|
if (pluginModule.pluginInfo?.info?.id) {
|
||||||
|
id = pluginModule.pluginInfo.info.id
|
||||||
|
} else if (pluginModule.info?.id) {
|
||||||
|
// Legacy or direct format support
|
||||||
|
id = pluginModule.info.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
// Cleanup
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||||
|
console.error('[PluginSystem] No plugin ID found in file')
|
||||||
|
return { success: false, message: '插件文件中未找到ID' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install to real location
|
||||||
|
const targetDir = path.join(app.getPath('userData'), 'plugins', id)
|
||||||
|
if (fs.existsSync(targetDir)) {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||||
|
return { success: false, message: `插件 ${id} 已存在` }
|
||||||
|
}
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true })
|
||||||
|
|
||||||
|
fs.copyFileSync(filePath, path.join(targetDir, 'index.js'))
|
||||||
|
|
||||||
|
// Cleanup temp
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||||
|
|
||||||
|
return { success: true, message: '安装成功' }
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[PluginSystem] Install failed:', e)
|
||||||
|
return { success: false, message: e.message || '安装失败' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static uninstallPlugin(id: string): boolean {
|
||||||
|
try {
|
||||||
|
const pluginPath = path.join(app.getPath('userData'), 'plugins', id)
|
||||||
|
if (fs.existsSync(pluginPath)) {
|
||||||
|
fs.rmSync(pluginPath, { recursive: true, force: true })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[PluginSystem] Failed to uninstall plugin ${id}:`, e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,14 +5,18 @@ import path from 'path';
|
|||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { PluginSystem } from './pluginSystem.ts';
|
import { PluginSystem } from './pluginSystem.ts';
|
||||||
|
import { loadSettings } from './settingsStore';
|
||||||
|
|
||||||
const PORT = 5266;
|
const PORT = 5266;
|
||||||
let CACHE_DIR = '';
|
let CACHE_DIR = '';
|
||||||
|
|
||||||
function ensureCacheDir() {
|
function ensureCacheDir() {
|
||||||
if (!CACHE_DIR) {
|
const settings = loadSettings();
|
||||||
CACHE_DIR = path.join(app.getPath('userData'), 'music', 'cache');
|
const configuredPath = settings.cachePath || path.join(app.getPath('userData'), 'music', 'cache');
|
||||||
}
|
|
||||||
|
// Always update global CACHE_DIR if it changes (e.g. after setting update)
|
||||||
|
CACHE_DIR = configuredPath;
|
||||||
|
|
||||||
if (!fs.existsSync(CACHE_DIR)) {
|
if (!fs.existsSync(CACHE_DIR)) {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||||
@@ -194,7 +198,7 @@ async function proxyRangeDirect(
|
|||||||
req: http.IncomingMessage,
|
req: http.IncomingMessage,
|
||||||
res: http.ServerResponse,
|
res: http.ServerResponse,
|
||||||
targetUrl: string,
|
targetUrl: string,
|
||||||
totalSize: number,
|
_totalSize: number,
|
||||||
contentType: string
|
contentType: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@@ -582,11 +586,11 @@ async function proxyAndCache(
|
|||||||
* 使用 PassThrough 流正确复制数据
|
* 使用 PassThrough 流正确复制数据
|
||||||
*/
|
*/
|
||||||
async function streamAndCache(
|
async function streamAndCache(
|
||||||
req: http.IncomingMessage,
|
_req: http.IncomingMessage,
|
||||||
res: http.ServerResponse,
|
res: http.ServerResponse,
|
||||||
targetUrl: string,
|
targetUrl: string,
|
||||||
cacheFilePath: string,
|
cacheFilePath: string,
|
||||||
cacheKey: string
|
_cacheKey: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@@ -681,7 +685,7 @@ async function streamAndCache(
|
|||||||
// 关键修复:使用正确的方式将流复制到多个目标
|
// 关键修复:使用正确的方式将流复制到多个目标
|
||||||
// 手动读取数据并写入多个目标
|
// 手动读取数据并写入多个目标
|
||||||
let clientConnected = true;
|
let clientConnected = true;
|
||||||
let downloadComplete = false;
|
let _downloadComplete = false;
|
||||||
|
|
||||||
res.on('close', () => {
|
res.on('close', () => {
|
||||||
clientConnected = false;
|
clientConnected = false;
|
||||||
@@ -709,7 +713,7 @@ async function streamAndCache(
|
|||||||
});
|
});
|
||||||
|
|
||||||
nodeStream.on('end', () => {
|
nodeStream.on('end', () => {
|
||||||
downloadComplete = true;
|
_downloadComplete = true;
|
||||||
|
|
||||||
// 结束文件流
|
// 结束文件流
|
||||||
fileStream.end();
|
fileStream.end();
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { spawn, ChildProcess } from 'child_process';
|
|||||||
import { Socket } from 'net';
|
import { Socket } from 'net';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { app } from 'electron';
|
||||||
|
import { MessagePlugin } from "tdesign-vue-next";
|
||||||
|
|
||||||
export class QzpController extends EventEmitter {
|
export class QzpController extends EventEmitter {
|
||||||
private process: ChildProcess | null = null;
|
private process: ChildProcess | null = null;
|
||||||
@@ -24,11 +26,10 @@ export class QzpController extends EventEmitter {
|
|||||||
|
|
||||||
private getCorePath(): string {
|
private getCorePath(): string {
|
||||||
const appRoot = process.env.APP_ROOT || process.cwd();
|
const appRoot = process.env.APP_ROOT || process.cwd();
|
||||||
|
if (app.isPackaged) {
|
||||||
if (process.platform === 'win32') {
|
return path.join(process.resourcesPath, 'core', 'qzplayer.exe');
|
||||||
return path.join(appRoot, 'core', 'qzplayer.exe');
|
|
||||||
}
|
}
|
||||||
return "qzplayer"
|
return path.join(appRoot, 'core', 'qzplayer.exe');
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
@@ -39,11 +40,13 @@ export class QzpController extends EventEmitter {
|
|||||||
|
|
||||||
this.process.on('error', (err) => {
|
this.process.on('error', (err) => {
|
||||||
console.error('Failed to start QZPlayer:', err);
|
console.error('Failed to start QZPlayer:', err);
|
||||||
|
MessagePlugin.error(`启动播放核心时出现异常:${err}`).then();
|
||||||
this.emit('error', err);
|
this.emit('error', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.process.on('exit', (code, signal) => {
|
this.process.on('exit', (code, signal) => {
|
||||||
console.log(`QZPlayer exited with code ${code} and signal ${signal}`);
|
console.log(`QZPlayer exited with code ${code} and signal ${signal}`);
|
||||||
|
//MessagePlugin.error(`播放核心异常退出!code:${code},signal:${signal}`).then();
|
||||||
this.emit('exit', { code, signal });
|
this.emit('exit', { code, signal });
|
||||||
this.socket?.destroy();
|
this.socket?.destroy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { app } from 'electron';
|
|||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
// Cache
|
// Cache
|
||||||
persistCache: boolean;
|
persistCache: boolean;
|
||||||
|
cachePath: string; // [NEW] Custom cache path
|
||||||
// Appearance
|
// Appearance
|
||||||
theme: 'dark' | 'light';
|
theme: 'dark' | 'light';
|
||||||
accentColor: string;
|
accentColor: string;
|
||||||
@@ -13,7 +13,8 @@ export interface AppSettings {
|
|||||||
|
|
||||||
const DEFAULT_SETTINGS: AppSettings = {
|
const DEFAULT_SETTINGS: AppSettings = {
|
||||||
persistCache: true,
|
persistCache: true,
|
||||||
theme: 'dark',
|
cachePath: path.join(app.getPath('userData'), 'cache'), // Default
|
||||||
|
theme: 'light',
|
||||||
accentColor: '#ec4141', // Default red
|
accentColor: '#ec4141', // Default red
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
call: (pluginId: string, method: string, args: any[]) => ipcRenderer.invoke('plugin:call', pluginId, method, args),
|
call: (pluginId: string, method: string, args: any[]) => ipcRenderer.invoke('plugin:call', pluginId, method, args),
|
||||||
search: (pluginId: string, query: string, page: number, limit: number) => ipcRenderer.invoke('plugin:call', pluginId, 'search', [query, page, limit]),
|
search: (pluginId: string, query: string, page: number, limit: number) => ipcRenderer.invoke('plugin:call', pluginId, 'search', [query, page, limit]),
|
||||||
getLyric: (pluginId: string, id: string) => ipcRenderer.invoke('plugin:call', pluginId, 'getLyric', [id]),
|
getLyric: (pluginId: string, id: string) => ipcRenderer.invoke('plugin:call', pluginId, 'getLyric', [id]),
|
||||||
|
getAll: () => ipcRenderer.invoke('plugin:getAll'),
|
||||||
|
uninstall: (id: string) => ipcRenderer.invoke('plugin:uninstall', id),
|
||||||
|
install: () => ipcRenderer.invoke('plugin:install'),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Cache Control
|
// Cache Control
|
||||||
@@ -31,6 +34,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
setCachePersist: (persist: boolean) => ipcRenderer.invoke('cache:setPersist', persist),
|
setCachePersist: (persist: boolean) => ipcRenderer.invoke('cache:setPersist', persist),
|
||||||
openCacheFolder: () => ipcRenderer.invoke('cache:openFolder'),
|
openCacheFolder: () => ipcRenderer.invoke('cache:openFolder'),
|
||||||
clearCache: () => ipcRenderer.invoke('cache:clear'),
|
clearCache: () => ipcRenderer.invoke('cache:clear'),
|
||||||
|
changeCacheLocation: (newPath: string) => ipcRenderer.invoke('cache:changeLocation', newPath),
|
||||||
|
selectDirectory: () => ipcRenderer.invoke('dialog:openDirectory'),
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + Vue + TS</title>
|
<title>QZMusic</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -56,6 +56,11 @@
|
|||||||
<Icon icon="lucide:folder-open" />
|
<Icon icon="lucide:folder-open" />
|
||||||
打开目录
|
打开目录
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -74,6 +79,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div v-else-if="activeCategory === 'appearance'" class="section">
|
||||||
<h2 class="section-title">外观设置</h2>
|
<h2 class="section-title">外观设置</h2>
|
||||||
@@ -179,6 +227,20 @@
|
|||||||
<p class="copyright">©2026 QZ <DEVELOPERS></DEVELOPERS></p>
|
<p class="copyright">©2026 QZ <DEVELOPERS></DEVELOPERS></p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,8 +249,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { Icon } from '@iconify/vue';
|
||||||
|
import { MessagePlugin } from 'tdesign-vue-next';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
|
|
||||||
const playerStore = usePlayerStore();
|
const playerStore = usePlayerStore();
|
||||||
@@ -197,6 +260,7 @@ defineEmits(['close']);
|
|||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ id: 'storage', name: '存储', icon: 'lucide:hard-drive' },
|
{ id: 'storage', name: '存储', icon: 'lucide:hard-drive' },
|
||||||
|
{ id: 'plugins', name: '插件', icon: 'lucide:blocks' },
|
||||||
{ id: 'appearance', name: '外观', icon: 'lucide:palette' },
|
{ id: 'appearance', name: '外观', icon: 'lucide:palette' },
|
||||||
{ id: 'playback', name: '播放', icon: 'lucide:headphones' },
|
{ id: 'playback', name: '播放', icon: 'lucide:headphones' },
|
||||||
{ id: 'shortcuts', name: '快捷键', icon: 'lucide:keyboard' },
|
{ id: 'shortcuts', name: '快捷键', icon: 'lucide:keyboard' },
|
||||||
@@ -217,6 +281,7 @@ const accentColors = [
|
|||||||
const activeCategory = ref('storage');
|
const activeCategory = ref('storage');
|
||||||
const isLoaded = ref(false);
|
const isLoaded = ref(false);
|
||||||
const enableTransition = ref(false);
|
const enableTransition = ref(false);
|
||||||
|
const plugins = ref<any[]>([]);
|
||||||
|
|
||||||
const settings = reactive({
|
const settings = reactive({
|
||||||
persistCache: true,
|
persistCache: true,
|
||||||
@@ -232,6 +297,70 @@ const cacheInfo = reactive({
|
|||||||
size: '',
|
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 () => {
|
const loadCacheInfo = async () => {
|
||||||
if (window.electronAPI) {
|
if (window.electronAPI) {
|
||||||
const info = await window.electronAPI.getCacheInfo();
|
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
|
// Load settings BEFORE mount to avoid visual flicker
|
||||||
onBeforeMount(async () => {
|
onBeforeMount(async () => {
|
||||||
await Promise.all([loadCacheInfo(), loadAppearance()]);
|
await Promise.all([loadCacheInfo(), loadAppearance()]);
|
||||||
@@ -479,6 +633,9 @@ onBeforeMount(async () => {
|
|||||||
|
|
||||||
.setting-control {
|
.setting-control {
|
||||||
margin-left: 24px;
|
margin-left: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toggle Switch */
|
/* Toggle Switch */
|
||||||
@@ -702,6 +859,19 @@ input:checked + .toggle-slider:before {
|
|||||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
|
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 */
|
||||||
.radio-group {
|
.radio-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -724,4 +894,167 @@ input:checked + .toggle-slider:before {
|
|||||||
height: 16px;
|
height: 16px;
|
||||||
cursor: pointer;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -185,12 +185,12 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
if (!song || !song.id) return;
|
if (!song || !song.id) return;
|
||||||
try {
|
try {
|
||||||
// Check if plugin API exists
|
// Check if plugin API exists
|
||||||
if (window.electronAPI?.plugin?.getLyric) {
|
// if (window.electronAPI?.plugin?.getLyric) {
|
||||||
const rawLyric = await window.electronAPI.plugin.getLyric(song.source || 'kw', song.id.toString());
|
// const rawLyric = await window.electronAPI.plugin.getLyric(song.source || 'kw', song.id.toString());
|
||||||
console.log(rawLyric)
|
// console.log(rawLyric)
|
||||||
} else {
|
// } else {
|
||||||
MessagePlugin.warning("当前插件不支持歌词获取").then()
|
// MessagePlugin.warning("当前插件不支持歌词获取").then()
|
||||||
}
|
// }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch lyrics:', e);
|
console.error('Failed to fetch lyrics:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,36 @@ input {
|
|||||||
background: none;
|
background: none;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: 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;
|
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 */
|
/* Custom Scrollbar for 'Exquisite' look */
|
||||||
|
|||||||
5
src/renderer/src/types/electron.d.ts
vendored
5
src/renderer/src/types/electron.d.ts
vendored
@@ -17,12 +17,17 @@ export interface IElectronAPI {
|
|||||||
call: (pluginId: string, method: string, args: any[]) => Promise<any>;
|
call: (pluginId: string, method: string, args: any[]) => Promise<any>;
|
||||||
search: (pluginId: string, query: string, page: number, limit: number) => Promise<any>;
|
search: (pluginId: string, query: string, page: number, limit: number) => Promise<any>;
|
||||||
getLyric: (pluginId: string, id: string) => 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
|
// Cache Control
|
||||||
getCacheInfo: () => Promise<{ path: string; size: string; persistCache: boolean }>;
|
getCacheInfo: () => Promise<{ path: string; size: string; persistCache: boolean }>;
|
||||||
setCachePersist: (persist: boolean) => Promise<void>;
|
setCachePersist: (persist: boolean) => Promise<void>;
|
||||||
openCacheFolder: () => Promise<void>;
|
openCacheFolder: () => Promise<void>;
|
||||||
clearCache: () => Promise<void>;
|
clearCache: () => Promise<void>;
|
||||||
|
changeCacheLocation: (newPath: string) => Promise<{ success: boolean; message: string; path?: string }>;
|
||||||
|
selectDirectory: () => Promise<string | null>;
|
||||||
// Settings
|
// Settings
|
||||||
settings: {
|
settings: {
|
||||||
getAll: () => Promise<{ persistCache: boolean; theme: 'dark' | 'light'; accentColor: string }>;
|
getAll: () => Promise<{ persistCache: boolean; theme: 'dark' | 'light'; accentColor: string }>;
|
||||||
|
|||||||
@@ -13,13 +13,36 @@
|
|||||||
|
|
||||||
<transition name="slide-fade">
|
<transition name="slide-fade">
|
||||||
<div class="settings-panel" v-if="showSettings">
|
<div class="settings-panel" v-if="showSettings">
|
||||||
<div class="setting-item">
|
<div class="limit-setting">
|
||||||
<span class="setting-label">每页显示: {{ limit }} 首</span>
|
<span class="setting-label">每页显示: {{ limit }} 首</span>
|
||||||
<div class="slider-container">
|
<div class="slider-container">
|
||||||
<input type="range" min="10" max="100" step="10" v-model.number="limit" class="setting-slider">
|
<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 class="slider-track" :style="{ width: ((limit - 10) / 90) * 100 + '%' }"></div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
@@ -102,7 +125,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed } from 'vue';
|
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
@@ -110,9 +133,9 @@ import { transformSearchSong } from '../utils/songUtils';
|
|||||||
import type { Song } from '../types/song';
|
import type { Song } from '../types/song';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const playerStore = usePlayerStore();
|
const playerStore = usePlayerStore();
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
const query = computed(() => route.query.q as string || '');
|
const query = computed(() => route.query.q as string || '');
|
||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
const showSettings = ref(false);
|
const showSettings = ref(false);
|
||||||
@@ -124,6 +147,39 @@ const loading = ref(false);
|
|||||||
const error = ref(false);
|
const error = ref(false);
|
||||||
const songs = ref<Song[]>([]);
|
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 totalPages = computed(() => {
|
||||||
const t = Number(total.value) || 0;
|
const t = Number(total.value) || 0;
|
||||||
const l = Number(limit.value) || 30;
|
const l = Number(limit.value) || 30;
|
||||||
@@ -132,9 +188,8 @@ const totalPages = computed(() => {
|
|||||||
|
|
||||||
const visiblePages = computed(() => {
|
const visiblePages = computed(() => {
|
||||||
const current = currentPage.value;
|
const current = currentPage.value;
|
||||||
// Ensure totalPages is strictly valid
|
|
||||||
const total = totalPages.value;
|
const total = totalPages.value;
|
||||||
const delta = 2; // 2 on each side
|
const delta = 2;
|
||||||
|
|
||||||
let start = Math.max(1, current - delta);
|
let start = Math.max(1, current - delta);
|
||||||
let end = Math.min(total, current + delta);
|
let end = Math.min(total, current + delta);
|
||||||
@@ -153,19 +208,41 @@ const visiblePages = computed(() => {
|
|||||||
return pages;
|
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 () => {
|
const fetchData = async () => {
|
||||||
if (!query.value) return;
|
if (!query.value || !activePlugin.value) return;
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = false;
|
error.value = false;
|
||||||
songs.value = [];
|
songs.value = [];
|
||||||
|
|
||||||
try {
|
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) {
|
if (result && result.list) {
|
||||||
songs.value = result.list.map((item: any) => transformSearchSong(item));
|
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;
|
total.value = result.songCount || result.total || 0;
|
||||||
} else {
|
} else {
|
||||||
total.value = 0;
|
total.value = 0;
|
||||||
@@ -190,27 +267,17 @@ const handlePlaySong = (index: number) => {
|
|||||||
const getHighlightRegex = (q: string) => {
|
const getHighlightRegex = (q: string) => {
|
||||||
const trimmed = q.trim();
|
const trimmed = q.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
|
||||||
// Escape regex characters
|
|
||||||
const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
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) {
|
if (trimmed.length <= 2) {
|
||||||
return new RegExp(escapeRegExp(trimmed), 'gi');
|
return new RegExp(escapeRegExp(trimmed), 'gi');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate all substrings of length >= 2
|
|
||||||
const substrings = new Set<string>();
|
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 i = 0; i < trimmed.length; i++) {
|
||||||
for (let j = i + 2; j <= trimmed.length; j++) {
|
for (let j = i + 2; j <= trimmed.length; j++) {
|
||||||
substrings.add(trimmed.slice(i, 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 sorted = Array.from(substrings).sort((a, b) => b.length - a.length);
|
||||||
const pattern = sorted.map(s => escapeRegExp(s)).join('|');
|
const pattern = sorted.map(s => escapeRegExp(s)).join('|');
|
||||||
return new RegExp(pattern, 'gi');
|
return new RegExp(pattern, 'gi');
|
||||||
@@ -218,23 +285,43 @@ const getHighlightRegex = (q: string) => {
|
|||||||
|
|
||||||
const highlight = (text: string) => {
|
const highlight = (text: string) => {
|
||||||
if (!query.value || !text) return text;
|
if (!query.value || !text) return text;
|
||||||
|
|
||||||
const regex = getHighlightRegex(query.value);
|
const regex = getHighlightRegex(query.value);
|
||||||
if (!regex) return text;
|
if (!regex) return text;
|
||||||
|
|
||||||
return text.replace(regex, match => `<span class="highlight">${match}</span>`);
|
return text.replace(regex, match => `<span class="highlight">${match}</span>`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Watchers & Lifecycle ---
|
||||||
watch(query, () => {
|
watch(query, () => {
|
||||||
currentPage.value = 1;
|
currentPage.value = 1;
|
||||||
fetchData();
|
// ensure plugins loaded before fetching? usually mounted happens first
|
||||||
}, { immediate: true });
|
if (activePlugin.value) fetchData();
|
||||||
|
}, { immediate: false }); // Wait for mount init
|
||||||
|
|
||||||
watch(limit, (newLimit) => {
|
watch(limit, (newLimit) => {
|
||||||
localStorage.setItem('qz-search-limit', newLimit.toString());
|
localStorage.setItem('qz-search-limit', newLimit.toString());
|
||||||
currentPage.value = 1;
|
currentPage.value = 1;
|
||||||
fetchData();
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@@ -316,19 +403,115 @@ watch(limit, (newLimit) => {
|
|||||||
|
|
||||||
/* Settings Panel */
|
/* Settings Panel */
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-primary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
position: relative; /* Context */
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-item {
|
.limit-setting {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
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 {
|
.setting-label {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
|||||||
Reference in New Issue
Block a user