feat: 添加插件系统,修复TypeScript错误
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# QZMusic Web
|
||||
|
||||
QZMusic 网页版,基于 Vue 3 + TypeScript + Vite 构建的音乐播放器。
|
||||
QZMusic 网页版,基于 Vue 3 + TypeScript + Vite 构建的音乐播放器,支持插件系统获取音乐资源。
|
||||
|
||||
## 功能特性
|
||||
|
||||
@@ -10,6 +10,7 @@ QZMusic 网页版,基于 Vue 3 + TypeScript + Vite 构建的音乐播放器。
|
||||
- 🎚️ 音量控制
|
||||
- 📊 音频可视化(基于 Web Audio API)
|
||||
- 🔍 搜索功能
|
||||
- 🔌 **插件系统** - 支持通过插件获取音乐资源
|
||||
- 🌐 默认端口:10096
|
||||
|
||||
## 快速开始
|
||||
|
||||
13
src/App.vue
13
src/App.vue
@@ -16,15 +16,14 @@ const showSettings = ref(false);
|
||||
provide('openSettings', () => { showSettings.value = true; });
|
||||
|
||||
// Apply saved theme on app startup
|
||||
onMounted(async () => {
|
||||
if (window.electronAPI?.settings) {
|
||||
const settings = await window.electronAPI.settings.getAll();
|
||||
document.documentElement.setAttribute('data-theme', settings.theme);
|
||||
document.documentElement.style.setProperty('--color-accent', settings.accentColor);
|
||||
}
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem('qz-theme') || 'dark';
|
||||
const savedAccentColor = localStorage.getItem('qz-accent-color') || '#ec4141';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
document.documentElement.style.setProperty('--color-accent', savedAccentColor);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import "styles/main.css";
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -13,9 +13,7 @@
|
||||
<a @click.stop="onAlbumClicked && onAlbumClicked()">{{ album }}</a>
|
||||
</TextMarquee>
|
||||
</div>
|
||||
<!-- MenuButton placeholder or implementation -->
|
||||
<div class="menu-button-placeholder" @click="onMenuButtonClicked">
|
||||
<!-- Icon could go here -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -23,7 +21,7 @@
|
||||
<script setup lang="ts">
|
||||
import TextMarquee from './TextMarquee.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
name?: string;
|
||||
artists?: string[];
|
||||
album?: string;
|
||||
@@ -46,7 +44,6 @@ const props = defineProps<{
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
/* Fixed line-height to prevent layout shifting */
|
||||
line-height: 1.25em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -91,7 +88,6 @@ const props = defineProps<{
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Add separators between artists */
|
||||
.artists :deep(span::after) {
|
||||
content: ", ";
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
className?: string;
|
||||
}>();
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// src/renderer/main.ts
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
import App from './App.vue'
|
||||
import 'tdesign-vue-next/es/style/index.css'
|
||||
import { initPlugins } from './plugins/init'
|
||||
|
||||
const pinia = createPinia()
|
||||
const router = createRouter({
|
||||
@@ -41,4 +41,7 @@ const router = createRouter({
|
||||
const app = createApp(App)
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
initPlugins().then(() => {
|
||||
app.mount('#app')
|
||||
})
|
||||
|
||||
51
src/plugins/impl/defaultPlugin.ts
Normal file
51
src/plugins/impl/defaultPlugin.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { PluginModule, PluginSearchResult } from '../../types/plugin';
|
||||
|
||||
const mockSongs = [
|
||||
{ id: '1', name: '晴天', artist: '周杰伦', albumName: '叶惠美', duration: '04:29', picUrl: 'https://picsum.photos/200/200?random=1' },
|
||||
{ id: '2', name: '夜曲', artist: '周杰伦', albumName: '十一月的萧邦', duration: '04:23', picUrl: 'https://picsum.photos/200/200?random=2' },
|
||||
{ id: '3', name: '稻香', artist: '周杰伦', albumName: '魔杰座', duration: '03:43', picUrl: 'https://picsum.photos/200/200?random=3' },
|
||||
{ id: '4', name: '七里香', artist: '周杰伦', albumName: '七里香', duration: '04:59', picUrl: 'https://picsum.photos/200/200?random=4' },
|
||||
{ id: '5', name: '告白气球', artist: '周杰伦', albumName: '周杰伦的床边故事', duration: '03:35', picUrl: 'https://picsum.photos/200/200?random=5' },
|
||||
{ id: '6', name: '成都', artist: '赵雷', albumName: '无法长大', duration: '05:28', picUrl: 'https://picsum.photos/200/200?random=6' },
|
||||
{ id: '7', name: '理想', artist: '赵雷', albumName: '无法长大', duration: '04:26', picUrl: 'https://picsum.photos/200/200?random=7' },
|
||||
{ id: '8', name: '南方姑娘', artist: '赵雷', albumName: '赵小雷', duration: '05:34', picUrl: 'https://picsum.photos/200/200?random=8' },
|
||||
{ id: '9', name: '平凡之路', artist: '朴树', albumName: '猎户星座', duration: '04:46', picUrl: 'https://picsum.photos/200/200?random=9' },
|
||||
{ id: '10', name: '那些花儿', artist: '朴树', albumName: '我去2000年', duration: '04:57', picUrl: 'https://picsum.photos/200/200?random=10' },
|
||||
];
|
||||
|
||||
const searchSongs = (query: string): typeof mockSongs => {
|
||||
const q = query.toLowerCase();
|
||||
return mockSongs.filter(song =>
|
||||
song.name.toLowerCase().includes(q) ||
|
||||
song.artist.toLowerCase().includes(q) ||
|
||||
song.albumName.toLowerCase().includes(q)
|
||||
);
|
||||
};
|
||||
|
||||
export const defaultPlugin: PluginModule = {
|
||||
info: {
|
||||
id: 'default',
|
||||
name: '默认音源',
|
||||
description: '内置音乐搜索插件',
|
||||
version: '1.0.0',
|
||||
author: 'QZMusic',
|
||||
},
|
||||
async search(query: string, page: number, limit: number): Promise<PluginSearchResult> {
|
||||
const results = searchSongs(query);
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit;
|
||||
return {
|
||||
list: results.slice(start, end),
|
||||
total: results.length,
|
||||
songCount: results.length,
|
||||
};
|
||||
},
|
||||
async getSongUrl(_songId: string): Promise<string> {
|
||||
return 'http://commondatastorage.googleapis.com/codeskulptor-demos/riceracer_assets/music/win.ogg';
|
||||
},
|
||||
async getLyric(_songId: string): Promise<string> {
|
||||
return `[00:00.00]歌曲歌词
|
||||
[00:05.00]暂无歌词数据
|
||||
[00:10.00]---`;
|
||||
},
|
||||
};
|
||||
3
src/plugins/index.ts
Normal file
3
src/plugins/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { pluginManager } from './pluginManager';
|
||||
export { defaultPlugin } from './impl/defaultPlugin';
|
||||
export * from '../types/plugin';
|
||||
10
src/plugins/init.ts
Normal file
10
src/plugins/init.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { pluginManager, defaultPlugin } from './index';
|
||||
|
||||
export const initPlugins = async (): Promise<void> => {
|
||||
await pluginManager.register(async () => defaultPlugin);
|
||||
|
||||
const savedPlugin = sessionStorage.getItem('qz-active-plugin');
|
||||
if (savedPlugin) {
|
||||
pluginManager.setActivePlugin(savedPlugin);
|
||||
}
|
||||
};
|
||||
73
src/plugins/pluginManager.ts
Normal file
73
src/plugins/pluginManager.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { PluginInfo, PluginModule, PluginSearchResult } from '../types/plugin';
|
||||
|
||||
class PluginManager {
|
||||
private plugins: Map<string, PluginModule> = new Map();
|
||||
private activePluginId: string = '';
|
||||
|
||||
async register(loader: () => Promise<PluginModule>): Promise<void> {
|
||||
try {
|
||||
const module = await loader();
|
||||
this.plugins.set(module.info.id, module);
|
||||
if (!this.activePluginId) {
|
||||
this.activePluginId = module.info.id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load plugin:', error);
|
||||
}
|
||||
}
|
||||
|
||||
getAll(): PluginInfo[] {
|
||||
return Array.from(this.plugins.values()).map(p => p.info);
|
||||
}
|
||||
|
||||
get(id: string): PluginModule | undefined {
|
||||
return this.plugins.get(id);
|
||||
}
|
||||
|
||||
getActivePlugin(): PluginModule | undefined {
|
||||
return this.plugins.get(this.activePluginId);
|
||||
}
|
||||
|
||||
setActivePlugin(id: string): boolean {
|
||||
if (this.plugins.has(id)) {
|
||||
this.activePluginId = id;
|
||||
sessionStorage.setItem('qz-active-plugin', id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getActivePluginId(): string {
|
||||
return this.activePluginId;
|
||||
}
|
||||
|
||||
async search(query: string, page: number, limit: number): Promise<PluginSearchResult> {
|
||||
const plugin = this.getActivePlugin();
|
||||
if (!plugin) {
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
return plugin.search(query, page, limit);
|
||||
}
|
||||
|
||||
async getSongUrl(songId: string): Promise<string> {
|
||||
const plugin = this.getActivePlugin();
|
||||
if (!plugin || !plugin.getSongUrl) {
|
||||
throw new Error('No active plugin or plugin does not support getSongUrl');
|
||||
}
|
||||
return plugin.getSongUrl(songId);
|
||||
}
|
||||
|
||||
async getLyric(songId: string): Promise<string> {
|
||||
const plugin = this.getActivePlugin();
|
||||
if (!plugin || !plugin.getLyric) {
|
||||
throw new Error('No active plugin or plugin does not support getLyric');
|
||||
}
|
||||
return plugin.getLyric(songId);
|
||||
}
|
||||
|
||||
hasPlugins(): boolean {
|
||||
return this.plugins.size > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginManager = new PluginManager();
|
||||
@@ -2,7 +2,6 @@ import { defineStore } from 'pinia';
|
||||
import { ref, shallowRef, watch } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import type { Song } from '../types/song';
|
||||
import { parseLyric } from '../utils/lyricUtil'
|
||||
|
||||
export enum PlayMode {
|
||||
List = 'list',
|
||||
|
||||
2
src/types/index.ts
Normal file
2
src/types/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './song';
|
||||
export * from './plugin';
|
||||
30
src/types/plugin.ts
Normal file
30
src/types/plugin.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface PluginInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
author?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface PluginSearchResult {
|
||||
list: any[];
|
||||
total?: number;
|
||||
songCount?: number;
|
||||
}
|
||||
|
||||
export interface PluginAPI {
|
||||
search: (pluginId: string, query: string, page: number, limit: number) => Promise<PluginSearchResult>;
|
||||
getSongUrl: (pluginId: string, songId: string) => Promise<string>;
|
||||
getLyric: (pluginId: string, songId: string) => Promise<string>;
|
||||
getAll: () => Promise<PluginInfo[]>;
|
||||
}
|
||||
|
||||
export type PluginLoader = () => Promise<PluginModule>;
|
||||
|
||||
export interface PluginModule {
|
||||
info: PluginInfo;
|
||||
search: (query: string, page: number, limit: number) => Promise<PluginSearchResult>;
|
||||
getSongUrl?: (songId: string) => Promise<string>;
|
||||
getLyric?: (songId: string) => Promise<string>;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="view-container playlist-view">
|
||||
<div class="content-wrapper">
|
||||
<!-- 动态头部设计 -->
|
||||
<div class="playlist-header">
|
||||
<div class="header-bg" :class="themeClass">
|
||||
<div class="flow-circle c1"></div>
|
||||
@@ -45,7 +44,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<div class="song-list-container">
|
||||
<div class="list-header">
|
||||
<div class="col-index">#</div>
|
||||
@@ -61,10 +59,10 @@
|
||||
<div class="cover-gradient" :class="themeClass"></div>
|
||||
</div>
|
||||
<div class="song-info">
|
||||
<h4 class="song-title">{{ song.title }}</h4>
|
||||
<h4 class="song-title">{{ song.name }}</h4>
|
||||
<p class="song-artist">{{ song.artist }}</p>
|
||||
</div>
|
||||
<div class="song-album">{{ song.album }}</div>
|
||||
<div class="song-album">{{ song.albumName }}</div>
|
||||
<div class="song-duration">{{ song.duration }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,15 +76,14 @@ import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import type { Song } from '../types/song';
|
||||
|
||||
const route = useRoute();
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
// 判断页面类型
|
||||
const isLiked = computed(() => route.path.includes('liked'));
|
||||
const isRecent = computed(() => route.path.includes('recent'));
|
||||
|
||||
// 动态数据
|
||||
const title = computed(() => {
|
||||
if (isLiked.value) return '我喜欢的音乐';
|
||||
if (isRecent.value) return '最近播放';
|
||||
@@ -111,9 +108,8 @@ const coverGradient = computed(() => {
|
||||
return 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
|
||||
});
|
||||
|
||||
// Mock Playlist Data
|
||||
const songs = ref(Array.from({ length: 15 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
const songs = ref<Song[]>(Array.from({ length: 15 }, (_, i) => ({
|
||||
id: String(i + 1),
|
||||
name: `Song Title ${i + 1}`,
|
||||
artist: `Artist Name ${i + 1}`,
|
||||
albumName: `Album Mock`,
|
||||
@@ -149,7 +145,6 @@ const handlePlaySong = (index: number) => {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header Styles */
|
||||
.playlist-header {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
@@ -174,7 +169,6 @@ const handlePlaySong = (index: number) => {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Default Dark Dynamic */
|
||||
.header-bg {
|
||||
background-image: linear-gradient(-45deg, #1e1e1e, #2a2a2a, #3a1c1c, #1a1a1a);
|
||||
}
|
||||
@@ -204,7 +198,7 @@ const handlePlaySong = (index: number) => {
|
||||
.c1 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: #ec4141; /* Default/Liked Color */
|
||||
background: #ec4141;
|
||||
top: -50px;
|
||||
left: -50px;
|
||||
animation-delay: 0s;
|
||||
@@ -219,7 +213,6 @@ const handlePlaySong = (index: number) => {
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
/* Recent Theme Colors */
|
||||
.theme-recent .c1 { background: #a18cd1; }
|
||||
.theme-recent .c2 { background: #fbc2eb; }
|
||||
|
||||
@@ -322,7 +315,7 @@ const handlePlaySong = (index: number) => {
|
||||
}
|
||||
|
||||
.play-all-btn {
|
||||
background: #ec4141; /* Default */
|
||||
background: #ec4141;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
@@ -364,9 +357,6 @@ const handlePlaySong = (index: number) => {
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
/* List Styles */
|
||||
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
padding: 0 16px;
|
||||
@@ -380,8 +370,6 @@ const handlePlaySong = (index: number) => {
|
||||
.col-album { width: 200px; }
|
||||
.col-time { width: 60px; text-align: right; }
|
||||
|
||||
|
||||
|
||||
.song-item {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
@@ -424,7 +412,6 @@ const handlePlaySong = (index: number) => {
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
}
|
||||
|
||||
/* Different gradients for list items too, maybe? */
|
||||
.cover-gradient.theme-liked {
|
||||
background: linear-gradient(45deg, #ff9a9e, #fecfef);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plugin Selector -->
|
||||
<div class="plugin-select-container">
|
||||
<button class="select-trigger" @click.stop="toggleDropdown" :title="'当前源: ' + activePluginName">
|
||||
<span>{{ activePluginName }}</span>
|
||||
@@ -86,7 +85,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination">
|
||||
<button class="pagination-btn" :disabled="currentPage <= 1" @click="changePage(currentPage - 1)">
|
||||
<Icon icon="lucide:chevron-left" />
|
||||
@@ -129,13 +127,12 @@ import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { transformSearchSong } from '../utils/songUtils';
|
||||
import type { Song } from '../types/song';
|
||||
import { pluginManager } from '../plugins/index';
|
||||
import type { PluginInfo, Song } from '../types';
|
||||
|
||||
const route = useRoute();
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
// --- State ---
|
||||
const query = computed(() => route.query.q as string || '');
|
||||
const currentPage = ref(1);
|
||||
const showSettings = ref(false);
|
||||
@@ -147,9 +144,8 @@ 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 plugins = ref<PluginInfo[]>([]);
|
||||
const activePlugin = ref<string>('');
|
||||
const isDropdownOpen = ref(false);
|
||||
|
||||
const activePluginName = computed(() => {
|
||||
@@ -161,17 +157,16 @@ const toggleDropdown = () => {
|
||||
isDropdownOpen.value = !isDropdownOpen.value;
|
||||
};
|
||||
|
||||
const selectPlugin = (plugin: any) => {
|
||||
const selectPlugin = (plugin: PluginInfo) => {
|
||||
if (activePlugin.value !== plugin.id) {
|
||||
activePlugin.value = plugin.id;
|
||||
sessionStorage.setItem('qz-active-plugin', plugin.id);
|
||||
pluginManager.setActivePlugin(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')) {
|
||||
@@ -179,7 +174,6 @@ const closeDropdown = (e: MouseEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
// --- Computed ---
|
||||
const totalPages = computed(() => {
|
||||
const t = Number(total.value) || 0;
|
||||
const l = Number(limit.value) || 30;
|
||||
@@ -208,26 +202,14 @@ 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 loadPlugins = () => {
|
||||
plugins.value = pluginManager.getAll();
|
||||
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) {
|
||||
activePlugin.value = plugins.value[0].id;
|
||||
pluginManager.setActivePlugin(activePlugin.value);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -239,10 +221,20 @@ const fetchData = async () => {
|
||||
songs.value = [];
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.plugin.search(activePlugin.value, query.value, currentPage.value, limit.value);
|
||||
const result = await pluginManager.search(query.value, currentPage.value, limit.value);
|
||||
|
||||
if (result && result.list) {
|
||||
songs.value = result.list.map((item: any) => transformSearchSong(item));
|
||||
songs.value = result.list.map((item: any) => ({
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
artist: item.artist,
|
||||
albumName: item.albumName,
|
||||
duration: item.duration,
|
||||
url: item.url || '',
|
||||
picUrl: item.picUrl || '',
|
||||
source: activePlugin.value,
|
||||
type: 'Remote' as const
|
||||
}));
|
||||
total.value = result.songCount || result.total || 0;
|
||||
} else {
|
||||
total.value = 0;
|
||||
@@ -290,12 +282,10 @@ const highlight = (text: string) => {
|
||||
return text.replace(regex, match => `<span class="highlight">${match}</span>`);
|
||||
};
|
||||
|
||||
// --- Watchers & Lifecycle ---
|
||||
watch(query, () => {
|
||||
currentPage.value = 1;
|
||||
// ensure plugins loaded before fetching? usually mounted happens first
|
||||
if (activePlugin.value) fetchData();
|
||||
}, { immediate: false }); // Wait for mount init
|
||||
}, { immediate: false });
|
||||
|
||||
watch(limit, (newLimit) => {
|
||||
localStorage.setItem('qz-search-limit', newLimit.toString());
|
||||
@@ -310,10 +300,9 @@ watch(activePlugin, (newVal, oldVal) => {
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeDropdown);
|
||||
await loadPlugins();
|
||||
// After plugins loaded, if we have a query, fetch data
|
||||
loadPlugins();
|
||||
if (query.value && activePlugin.value) {
|
||||
fetchData();
|
||||
}
|
||||
@@ -324,7 +313,6 @@ onBeforeUnmount(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.view-container {
|
||||
width: 100%;
|
||||
@@ -334,10 +322,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
.content-wrapper {
|
||||
box-sizing: border-box;
|
||||
padding: 20px 30px; /* Reduced vertical padding, kept horizontal for spacing but flexible */
|
||||
padding: 20px 30px;
|
||||
width: 100%;
|
||||
/* Removed max-width to allow full width usage as requested */
|
||||
/* margin: 0 auto; */
|
||||
}
|
||||
|
||||
.search-header {
|
||||
@@ -401,7 +387,6 @@ onBeforeUnmount(() => {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Settings Panel */
|
||||
.settings-panel {
|
||||
background: var(--color-bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -411,7 +396,7 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative; /* Context */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.limit-setting {
|
||||
@@ -420,10 +405,8 @@ onBeforeUnmount(() => {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Plugin Selector */
|
||||
.plugin-select-container {
|
||||
position: relative;
|
||||
/* ensure it stays on top when options open? No, options use absolute */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@@ -499,7 +482,6 @@ onBeforeUnmount(() => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Fade util */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
@@ -510,8 +492,6 @@ onBeforeUnmount(() => {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.setting-label {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
@@ -580,10 +560,8 @@ onBeforeUnmount(() => {
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* List Grid Layout */
|
||||
.list-header, .song-item {
|
||||
display: grid;
|
||||
/* Define columns: Index | Title (40%) | Album (30%) | Time */
|
||||
grid-template-columns: 50px 4fr 3fr 60px;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
@@ -595,12 +573,10 @@ onBeforeUnmount(() => {
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
/* Header doesn't need hover background usually, but padding ensures alignment */
|
||||
}
|
||||
|
||||
/* Override existing mixins/styles if necessary */
|
||||
.col-index, .col-title, .col-album, .col-time {
|
||||
width: auto; /* Let grid handle width */
|
||||
width: auto;
|
||||
}
|
||||
.col-index { text-align: center; }
|
||||
.col-time { text-align: right; }
|
||||
@@ -611,7 +587,7 @@ onBeforeUnmount(() => {
|
||||
transition: background-color 0.2s;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
width: 100%; /* Fill width */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.song-item:hover {
|
||||
@@ -626,63 +602,17 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.song-cover {
|
||||
/* Cover is not in the top grid definition, wait.
|
||||
The previous request structure had a cover inside.
|
||||
The Grid should account for cover?
|
||||
Previous structure: Index | Cover | Info (Title+Artist) | Album | Time
|
||||
Let's adjust Grid columns to: Index | Cover+Info | Album | Time?
|
||||
Or Index | Info (with Cover flow) | Album | Time.
|
||||
Let's stick to the structure:
|
||||
.song-item > .song-index
|
||||
.song-item > .song-cover (This was separate div in previous template)
|
||||
.song-item > .song-info
|
||||
.song-item > .song-album
|
||||
.song-item > .song-duration
|
||||
|
||||
So we have 5 direct children in .song-item?
|
||||
Checking template:
|
||||
1. .song-index
|
||||
2. .song-cover
|
||||
3. .song-info
|
||||
4. .song-album
|
||||
5. .song-duration
|
||||
|
||||
Yes, 5 columns.
|
||||
Updated Grid: Index(50px) Cover(40px) Info(4fr) Album(3fr) Time(60px)
|
||||
*/
|
||||
display: flex; /* Reset for grid child if needed */
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Refined Grid for 5 columns */
|
||||
.list-header, .song-item {
|
||||
grid-template-columns: 50px 40px 4fr 3fr 60px;
|
||||
}
|
||||
|
||||
/* Header has 4 children (Index, Title, Album, Time).
|
||||
We need to make Title span 2 columns (Cover + Info area) or Insert a spacer?
|
||||
Title header usually aligns with Title text.
|
||||
Let's make Header Grid: 50px (Spacer 40px) Title ...
|
||||
Actually, usually Cover doesn't have a header.
|
||||
Let's align "Title" header to start of Info.
|
||||
|
||||
List Header children:
|
||||
div.col-index
|
||||
div.col-title
|
||||
div.col-album
|
||||
div.col-time
|
||||
|
||||
We need to adjust .col-title to span the Cover+Info space? Or just Info space?
|
||||
If we want "Title" label to align with Song Title text, we should skip the Cover column.
|
||||
*/
|
||||
|
||||
.list-header {
|
||||
/* 50px | 40px spacer | 4fr | 3fr | 60px */
|
||||
grid-template-columns: 50px 40px 4fr 3fr 60px;
|
||||
}
|
||||
/* We need a phantom element or nth-child hacking for header?
|
||||
Easiest is to add a spacer div in template or just use grid-column on col-title?
|
||||
If we say .col-title { grid-column: 3 / 4; } ?
|
||||
*/
|
||||
|
||||
.list-header .col-title {
|
||||
grid-column: 3;
|
||||
}
|
||||
@@ -693,7 +623,6 @@ onBeforeUnmount(() => {
|
||||
grid-column: 5;
|
||||
}
|
||||
|
||||
|
||||
.song-cover {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -713,7 +642,7 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
padding-left: 10px; /* Small gap between cover and text */
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
@@ -746,18 +675,16 @@ onBeforeUnmount(() => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Highlight */
|
||||
:deep(.highlight) {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-start; /* Alignment Change: Left Align */
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-top: 32px;
|
||||
gap: 8px; /* Closer gap */
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
@@ -783,7 +710,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.pagination-btn.active {
|
||||
background: var(--color-accent);
|
||||
color: #fff; /* Ensure readable text on accent */
|
||||
color: #fff;
|
||||
border-color: var(--color-accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -794,7 +721,6 @@ onBeforeUnmount(() => {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* States */
|
||||
.loading-state, .empty-state, .error-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
@@ -837,7 +763,7 @@ onBeforeUnmount(() => {
|
||||
margin-top: 8px;
|
||||
padding: 8px 24px;
|
||||
background: var(--color-accent);
|
||||
color: white; /* Ensure text is readable on accent color */
|
||||
color: white;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
Reference in New Issue
Block a user