diff --git a/src/components/Settings.vue b/src/components/Settings.vue
index 7e79ff3..1f9324a 100644
--- a/src/components/Settings.vue
+++ b/src/components/Settings.vue
@@ -113,6 +113,123 @@
+
+
关于
@@ -134,6 +251,8 @@
import { ref, reactive, onBeforeMount, nextTick } from 'vue';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player';
+import { pluginManager } from '../plugins/index';
+import type { PluginInfo } from '../types';
const playerStore = usePlayerStore();
@@ -142,6 +261,7 @@ defineEmits(['close']);
const categories = [
{ id: 'appearance', name: '外观', icon: 'lucide:palette' },
{ id: 'playback', name: '播放', icon: 'lucide:headphones' },
+ { id: 'plugins', name: '音源', icon: 'lucide:plug' },
{ id: 'about', name: '关于', icon: 'lucide:info' },
];
@@ -165,6 +285,129 @@ const appearance = reactive({
accentColor: '#ec4141',
});
+// 插件管理相关
+const installedPlugins = ref
([]);
+const activePluginId = ref('');
+const pluginUrl = ref('');
+const pluginCode = ref('');
+const fileInput = ref(null);
+const selectedFile = ref(null);
+
+const refreshPlugins = () => {
+ installedPlugins.value = pluginManager.getAll();
+ activePluginId.value = pluginManager.getActivePluginId();
+};
+
+const activatePlugin = (id: string) => {
+ pluginManager.setActivePlugin(id);
+ refreshPlugins();
+};
+
+const removePlugin = (id: string) => {
+ if (confirm('确定要删除这个音源吗?')) {
+ pluginManager.unregister(id);
+ refreshPlugins();
+ }
+};
+
+const importFromUrl = async () => {
+ if (!pluginUrl.value) return;
+ try {
+ const response = await fetch(pluginUrl.value);
+ const data = await response.json();
+ await importPluginData(data);
+ pluginUrl.value = '';
+ } catch (e) {
+ alert('导入失败:' + (e as Error).message);
+ }
+};
+
+const triggerFileUpload = () => {
+ fileInput.value?.click();
+};
+
+const handleFileUpload = async (event: Event) => {
+ const target = event.target as HTMLInputElement;
+ const file = target.files?.[0];
+ if (!file) return;
+
+ selectedFile.value = file;
+
+ try {
+ const text = await file.text();
+ const data = JSON.parse(text);
+ await importPluginData(data);
+ selectedFile.value = null;
+ target.value = '';
+ } catch (e) {
+ alert('文件解析失败:' + (e as Error).message);
+ }
+};
+
+const importFromCode = async () => {
+ if (!pluginCode.value) return;
+ try {
+ const data = JSON.parse(pluginCode.value);
+ await importPluginData(data);
+ pluginCode.value = '';
+ } catch (e) {
+ alert('代码解析失败:' + (e as Error).message);
+ }
+};
+
+const importPluginData = async (data: any) => {
+ // 验证必要字段
+ if (!data.id || !data.name) {
+ alert('插件数据缺少必要字段:id 和 name');
+ return;
+ }
+
+ // 标记为用户插件
+ data.source = 'user';
+
+ // 创建插件模块
+ const module = {
+ info: {
+ id: data.id,
+ name: data.name,
+ description: data.description || '',
+ version: data.version || '1.0.0',
+ author: data.author || '未知',
+ source: 'user' as const,
+ },
+ search: async (query: string, page: number, limit: number) => {
+ if (data.searchFunction) {
+ try {
+ const fn = new Function('query', 'page', 'limit', data.searchFunction);
+ return await fn(query, page, limit);
+ } catch (e) {
+ console.error('Search function error:', e);
+ return { list: [], total: 0 };
+ }
+ }
+ return { list: [], total: 0 };
+ },
+ getSongUrl: data.getSongUrlFunction ? async (songId: string) => {
+ const fn = new Function('songId', data.getSongUrlFunction);
+ return await fn(songId);
+ } : undefined,
+ getLyric: data.getLyricFunction ? async (songId: string) => {
+ const fn = new Function('songId', data.getLyricFunction);
+ return await fn(songId);
+ } : undefined,
+ };
+
+ pluginManager.registerModule(module);
+
+ // 保存到localStorage
+ const saved = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]');
+ saved.push(data);
+ localStorage.setItem('qz-user-plugins', JSON.stringify(saved));
+
+ refreshPlugins();
+ alert(`音源 "${data.name}" 导入成功!`);
+};
+
const applyTheme = (theme: 'dark' | 'light') => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('qz-theme', theme);
@@ -203,6 +446,7 @@ const loadAppearance = () => {
// Load settings BEFORE mount to avoid visual flicker
onBeforeMount(async () => {
loadAppearance();
+ refreshPlugins();
isLoaded.value = true;
// Enable transition after initial render
nextTick(() => {
@@ -540,4 +784,277 @@ onBeforeMount(async () => {
height: 16px;
cursor: pointer;
}
+
+/* ===== 插件管理样式 ===== */
+.subsection-title {
+ font-size: var(--font-size-base);
+ font-weight: 600;
+ color: var(--color-text-primary);
+ margin-bottom: 16px;
+ margin-top: 24px;
+}
+
+.plugin-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.plugin-card {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 16px;
+ background: var(--color-bg-secondary);
+ border-radius: var(--radius-lg);
+ border: 2px solid transparent;
+ transition: all 0.2s ease;
+}
+
+.plugin-card:hover {
+ background: var(--color-bg-tertiary);
+}
+
+.plugin-card.active {
+ border-color: var(--color-accent);
+ background: var(--color-accent-soft);
+}
+
+.plugin-icon {
+ width: 48px;
+ height: 48px;
+ border-radius: var(--radius-md);
+ background: var(--color-accent);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-size: 24px;
+ flex-shrink: 0;
+}
+
+.plugin-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.plugin-name {
+ font-size: var(--font-size-base);
+ font-weight: 600;
+ color: var(--color-text-primary);
+ margin-bottom: 4px;
+}
+
+.plugin-desc {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ margin-bottom: 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.plugin-meta {
+ display: flex;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--color-text-muted);
+}
+
+.version {
+ background: var(--color-bg-tertiary);
+ padding: 2px 8px;
+ border-radius: var(--radius-sm);
+}
+
+.author {
+ color: var(--color-text-secondary);
+}
+
+.plugin-actions {
+ display: flex;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.action-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 16px;
+ border-radius: var(--radius-md);
+ border: none;
+ cursor: pointer;
+ font-size: var(--font-size-sm);
+ transition: all 0.2s ease;
+}
+
+.action-btn.activate {
+ background: var(--color-bg-tertiary);
+ color: var(--color-text-secondary);
+}
+
+.action-btn.activate:hover {
+ background: var(--color-accent-soft);
+ color: var(--color-accent);
+}
+
+.action-btn.activate.active {
+ background: var(--color-accent);
+ color: white;
+}
+
+.action-btn.delete {
+ background: transparent;
+ color: var(--color-text-muted);
+ padding: 8px;
+}
+
+.action-btn.delete:hover {
+ background: rgba(239, 68, 68, 0.1);
+ color: #ef4444;
+}
+
+/* 添加插件区域 */
+.add-plugin-section {
+ margin-top: 32px;
+}
+
+.add-method {
+ background: var(--color-bg-secondary);
+ border-radius: var(--radius-lg);
+ padding: 20px;
+ margin-bottom: 16px;
+}
+
+.method-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: var(--font-size-base);
+ font-weight: 600;
+ color: var(--color-text-primary);
+ margin-bottom: 4px;
+}
+
+.method-desc {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ margin-bottom: 12px;
+}
+
+.input-group {
+ display: flex;
+ gap: 8px;
+}
+
+.plugin-input {
+ flex: 1;
+ padding: 10px 14px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--color-border);
+ background: var(--color-bg-primary);
+ color: var(--color-text-primary);
+ font-size: var(--font-size-sm);
+ outline: none;
+ transition: border-color 0.2s ease;
+}
+
+.plugin-input:focus {
+ border-color: var(--color-accent);
+}
+
+.plugin-input::placeholder {
+ color: var(--color-text-muted);
+}
+
+.add-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 10px 20px;
+ background: var(--color-accent);
+ color: white;
+ border: none;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+}
+
+.add-btn:hover:not(:disabled) {
+ filter: brightness(1.1);
+}
+
+.add-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.add-btn.full-width {
+ width: 100%;
+ justify-content: center;
+ margin-top: 12px;
+}
+
+/* 文件上传 */
+.file-upload {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.file-input {
+ display: none;
+}
+
+.upload-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 10px 20px;
+ background: var(--color-bg-tertiary);
+ color: var(--color-text-primary);
+ border: 1px dashed var(--color-border);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ font-size: var(--font-size-sm);
+ transition: all 0.2s ease;
+}
+
+.upload-btn:hover {
+ border-color: var(--color-accent);
+ color: var(--color-accent);
+}
+
+.file-name {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+}
+
+/* 代码文本框 */
+.code-textarea {
+ width: 100%;
+ padding: 12px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--color-border);
+ background: var(--color-bg-primary);
+ color: var(--color-text-primary);
+ font-size: 13px;
+ font-family: 'Courier New', monospace;
+ resize: vertical;
+ outline: none;
+ transition: border-color 0.2s ease;
+ box-sizing: border-box;
+}
+
+.code-textarea:focus {
+ border-color: var(--color-accent);
+}
+
+.code-textarea::placeholder {
+ color: var(--color-text-muted);
+}
diff --git a/src/plugins/impl/defaultPlugin.ts b/src/plugins/impl/defaultPlugin.ts
index ab06568..67d6e1d 100644
--- a/src/plugins/impl/defaultPlugin.ts
+++ b/src/plugins/impl/defaultPlugin.ts
@@ -29,6 +29,7 @@ export const defaultPlugin: PluginModule = {
description: '内置音乐搜索插件',
version: '1.0.0',
author: 'QZMusic',
+ source: 'built-in',
},
async search(query: string, page: number, limit: number): Promise {
const results = searchSongs(query);
diff --git a/src/plugins/pluginManager.ts b/src/plugins/pluginManager.ts
index 948c3c5..59bdad8 100644
--- a/src/plugins/pluginManager.ts
+++ b/src/plugins/pluginManager.ts
@@ -16,6 +16,29 @@ class PluginManager {
}
}
+ // 直接注册插件模块
+ registerModule(module: PluginModule): boolean {
+ if (!module.info || !module.info.id) {
+ console.error('Plugin module must have info.id');
+ return false;
+ }
+ this.plugins.set(module.info.id, module);
+ if (!this.activePluginId) {
+ this.activePluginId = module.info.id;
+ }
+ return true;
+ }
+
+ // 卸载插件
+ unregister(id: string): boolean {
+ if (this.activePluginId === id) {
+ // 如果卸载的是当前激活的插件,切换到第一个可用的
+ const remaining = this.getAll();
+ this.activePluginId = remaining.length > 0 && remaining[0].id !== id ? remaining[0].id : '';
+ }
+ return this.plugins.delete(id);
+ }
+
getAll(): PluginInfo[] {
return Array.from(this.plugins.values()).map(p => p.info);
}
@@ -68,6 +91,54 @@ class PluginManager {
hasPlugins(): boolean {
return this.plugins.size > 0;
}
+
+ // 从localStorage加载用户添加的插件
+ loadUserPlugins(): void {
+ try {
+ const saved = localStorage.getItem('qz-user-plugins');
+ if (saved) {
+ const pluginList = JSON.parse(saved);
+ pluginList.forEach((pluginData: any) => {
+ this.loadPluginFromData(pluginData);
+ });
+ }
+ } catch (e) {
+ console.error('Failed to load user plugins:', e);
+ }
+ }
+
+ // 保存用户插件到localStorage
+ saveUserPlugins(): void {
+ const userPlugins = this.getAll().filter(p => p.source === 'user');
+ localStorage.setItem('qz-user-plugins', JSON.stringify(userPlugins));
+ }
+
+ // 从数据加载插件(用于用户上传的插件)
+ private loadPluginFromData(data: any): void {
+ try {
+ // 创建一个简单的插件模块
+ const module: PluginModule = {
+ info: {
+ id: data.id,
+ name: data.name,
+ description: data.description,
+ version: data.version,
+ author: data.author,
+ },
+ search: async (query: string, page: number, limit: number) => {
+ // 如果插件有自定义搜索逻辑,使用eval执行
+ if (data.searchFunction) {
+ const fn = new Function('query', 'page', 'limit', data.searchFunction);
+ return await fn(query, page, limit);
+ }
+ return { list: [], total: 0 };
+ },
+ };
+ this.registerModule(module);
+ } catch (e) {
+ console.error('Failed to load plugin from data:', e);
+ }
+ }
}
export const pluginManager = new PluginManager();
diff --git a/src/types/plugin.ts b/src/types/plugin.ts
index ece025c..d1cf742 100644
--- a/src/types/plugin.ts
+++ b/src/types/plugin.ts
@@ -5,6 +5,7 @@ export interface PluginInfo {
version?: string;
author?: string;
icon?: string;
+ source?: 'built-in' | 'user';
}
export interface PluginSearchResult {