forked from miao-moe/QZMusic_PC
feat:
- 搜索页UI - 插件系统支持搜索单曲接口 - 修复音量初始化和状态 - 搜索关键词匹配优化
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,5 +22,4 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
core/mpv.exe
|
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class QzpController extends EventEmitter {
|
|||||||
this.send(["observe_property", 3, "duration"]);
|
this.send(["observe_property", 3, "duration"]);
|
||||||
this.send(["observe_property", 4, "idle-active"]);
|
this.send(["observe_property", 4, "idle-active"]);
|
||||||
this.send(["observe_property", 5, "eof-reached"]);
|
this.send(["observe_property", 5, "eof-reached"]);
|
||||||
|
this.send(["set_property", "volume", 50]);
|
||||||
});
|
});
|
||||||
this.socket.on("data", (data) => {
|
this.socket.on("data", (data) => {
|
||||||
this.handleData(data);
|
this.handleData(data);
|
||||||
@@ -148,6 +149,27 @@ class PluginSystem {
|
|||||||
this.pluginId = pluginId;
|
this.pluginId = pluginId;
|
||||||
this.loadPlugin();
|
this.loadPlugin();
|
||||||
}
|
}
|
||||||
|
async search(query, page, limit) {
|
||||||
|
var _a, _b;
|
||||||
|
if (!((_b = (_a = this.plugin) == null ? void 0 : _a.musicSearch) == null ? void 0 : _b.search)) {
|
||||||
|
return {
|
||||||
|
list: [],
|
||||||
|
total: 0,
|
||||||
|
allPage: 0,
|
||||||
|
error: "Search not implemented"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await this.plugin.musicSearch.search(query, page, limit);
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
list: [],
|
||||||
|
total: 0,
|
||||||
|
allPage: 0,
|
||||||
|
error: e.message || "Plugin search error"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
loadPlugin() {
|
loadPlugin() {
|
||||||
try {
|
try {
|
||||||
const pluginPath = path.join(
|
const pluginPath = path.join(
|
||||||
@@ -175,7 +197,17 @@ class PluginSystem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await this.plugin.getUrl(id, quality);
|
const url = await this.plugin.getUrl(id, quality);
|
||||||
|
if (typeof url !== "string" || !url.startsWith("http")) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Invalid URL scheme"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
url
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
},
|
},
|
||||||
// Plugin System
|
// Plugin System
|
||||||
plugin: {
|
plugin: {
|
||||||
call: (pluginId, method, args) => electron.ipcRenderer.invoke("plugin:call", pluginId, method, args)
|
call: (pluginId, method, args) => electron.ipcRenderer.invoke("plugin:call", pluginId, method, args),
|
||||||
|
search: (pluginId, query, page, limit) => electron.ipcRenderer.invoke("plugin:call", pluginId, "search", [query, page, limit])
|
||||||
},
|
},
|
||||||
// Cache Control
|
// Cache Control
|
||||||
getCacheInfo: () => electron.ipcRenderer.invoke("cache:getInfo"),
|
getCacheInfo: () => electron.ipcRenderer.invoke("cache:getInfo"),
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ ipcMain.handle(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Cache IPC Handlers
|
// Cache IPC Handlers
|
||||||
ipcMain.handle('cache:getInfo', () => {
|
ipcMain.handle('cache:getInfo', () => {
|
||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// Plugin System
|
// Plugin System
|
||||||
plugin: {
|
plugin: {
|
||||||
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]),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Cache Control
|
// Cache Control
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export class QzpController extends EventEmitter {
|
|||||||
this.send(['observe_property', 3, 'duration']);
|
this.send(['observe_property', 3, 'duration']);
|
||||||
this.send(['observe_property', 4, 'idle-active']);
|
this.send(['observe_property', 4, 'idle-active']);
|
||||||
this.send(['observe_property', 5, 'eof-reached']);
|
this.send(['observe_property', 5, 'eof-reached']);
|
||||||
|
this.send(['set_property', 'volume', 50])
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('data', (data) => {
|
this.socket.on('data', (data) => {
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -27,6 +27,7 @@
|
|||||||
"tdesign-vue-next": "^1.17.7",
|
"tdesign-vue-next": "^1.17.7",
|
||||||
"url": "^0.11.4",
|
"url": "^0.11.4",
|
||||||
"vite-plugin-electron-renderer": "^0.14.6",
|
"vite-plugin-electron-renderer": "^0.14.6",
|
||||||
|
"vite-plugin-wasm": "^3.5.0",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
@@ -8992,6 +8993,15 @@
|
|||||||
"vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-plugin-wasm": {
|
||||||
|
"version": "3.5.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/vite-plugin-wasm/-/vite-plugin-wasm-3.5.0.tgz",
|
||||||
|
"integrity": "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vm-browserify": {
|
"node_modules/vm-browserify": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/vm-browserify/-/vm-browserify-1.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/vm-browserify/-/vm-browserify-1.1.2.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"tdesign-vue-next": "^1.17.7",
|
"tdesign-vue-next": "^1.17.7",
|
||||||
"url": "^0.11.4",
|
"url": "^0.11.4",
|
||||||
"vite-plugin-electron-renderer": "^0.14.6",
|
"vite-plugin-electron-renderer": "^0.14.6",
|
||||||
|
"vite-plugin-wasm": "^3.5.0",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export interface UrlResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PluginModule = {
|
type PluginModule = {
|
||||||
getUrl?: (id: string, quality: string) => Promise<UrlResponse> | UrlResponse
|
getUrl?: (id: string, quality: string) => Promise<string> | string,
|
||||||
|
musicSearch?: {
|
||||||
|
search: (query: string, page: number, limit: number) => Promise<any> | any
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PluginSystem {
|
export class PluginSystem {
|
||||||
@@ -22,6 +25,28 @@ export class PluginSystem {
|
|||||||
this.pluginId = pluginId
|
this.pluginId = pluginId
|
||||||
this.loadPlugin()
|
this.loadPlugin()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async search(query: string, page: number, limit: number): Promise<any> {
|
||||||
|
if (!this.plugin?.musicSearch?.search) {
|
||||||
|
return {
|
||||||
|
list: [],
|
||||||
|
total: 0,
|
||||||
|
allPage: 0,
|
||||||
|
error: 'Search not implemented'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await this.plugin.musicSearch.search(query, page, limit)
|
||||||
|
} catch (e: any) {
|
||||||
|
return {
|
||||||
|
list: [],
|
||||||
|
total: 0,
|
||||||
|
allPage: 0,
|
||||||
|
error: e.message || 'Plugin search error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private loadPlugin() {
|
private loadPlugin() {
|
||||||
try {
|
try {
|
||||||
const pluginPath = path.join(
|
const pluginPath = path.join(
|
||||||
@@ -53,7 +78,21 @@ export class PluginSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.plugin.getUrl(id, quality)
|
// New behavior: plugin returns raw url string or throws
|
||||||
|
const url = await this.plugin.getUrl(id, quality)
|
||||||
|
|
||||||
|
if (typeof url !== 'string' || !url.startsWith('http')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid URL scheme'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
url
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索音乐、歌手、专辑..."
|
placeholder="搜索音乐、歌手、专辑..."
|
||||||
class="search-input"
|
class="search-input"
|
||||||
|
v-model="searchQuery"
|
||||||
|
@keydown.enter="handleSearch"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,10 +57,19 @@ import { Icon } from '@iconify/vue';
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isMaximized = ref(false);
|
const isMaximized = ref(false);
|
||||||
|
const searchQuery = ref('');
|
||||||
|
|
||||||
const goBack = () => router.back();
|
const goBack = () => router.back();
|
||||||
const goForward = () => router.forward();
|
const goForward = () => router.forward();
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
if (!searchQuery.value.trim()) return;
|
||||||
|
router.push({
|
||||||
|
name: 'Search',
|
||||||
|
query: { q: searchQuery.value }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
const openSettings = inject<() => void>('openSettings', () => {});
|
const openSettings = inject<() => void>('openSettings', () => {});
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ const router = createRouter({
|
|||||||
path: '/recent',
|
path: '/recent',
|
||||||
name: 'Recent',
|
name: 'Recent',
|
||||||
component: () => import('./views/Playlist.vue')
|
component: () => import('./views/Playlist.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/search',
|
||||||
|
name: 'Search',
|
||||||
|
component: () => import('./views/Search.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -72,5 +77,5 @@ const testSong2: Song = {
|
|||||||
|
|
||||||
// Auto Play
|
// Auto Play
|
||||||
playerStore.playSong(testSong).then(() => {
|
playerStore.playSong(testSong).then(() => {
|
||||||
playerStore.setPlaylist([testSong,testSong2]);
|
playerStore.setPlaylist([testSong, testSong2]);
|
||||||
});
|
});
|
||||||
@@ -24,7 +24,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
// State
|
// State
|
||||||
const isPlaying = ref(false);
|
const isPlaying = ref(false);
|
||||||
const currentSong = ref<Song | null>(null);
|
const currentSong = ref<Song | null>(null);
|
||||||
const volume = ref(100);
|
const volume = ref(50);
|
||||||
const duration = ref(0);
|
const duration = ref(0);
|
||||||
const currentTime = ref(0);
|
const currentTime = ref(0);
|
||||||
|
|
||||||
|
|||||||
1
src/renderer/types/electron.d.ts
vendored
1
src/renderer/types/electron.d.ts
vendored
@@ -15,6 +15,7 @@ export interface IElectronAPI {
|
|||||||
};
|
};
|
||||||
plugin: {
|
plugin: {
|
||||||
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>;
|
||||||
};
|
};
|
||||||
// Cache Control
|
// Cache Control
|
||||||
getCacheInfo: () => Promise<{ path: string; size: string; persistCache: boolean }>;
|
getCacheInfo: () => Promise<{ path: string; size: string; persistCache: boolean }>;
|
||||||
|
|||||||
25
src/renderer/utils/songUtils.ts
Normal file
25
src/renderer/utils/songUtils.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Song } from '../types/song';
|
||||||
|
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformSearchSong(raw: any): Song {
|
||||||
|
return {
|
||||||
|
id: String(raw.songmid),
|
||||||
|
name: raw.name,
|
||||||
|
artist: raw.singer,
|
||||||
|
picUrl: raw.img || raw.m_img || raw.s_img,
|
||||||
|
url: '', // Empty initially
|
||||||
|
duration: formatDuration(raw.interval ? Number(raw.interval) : 0),
|
||||||
|
source: raw.source,
|
||||||
|
albumId: raw.albumId ? String(raw.albumId) : null,
|
||||||
|
albumName: raw.albumName,
|
||||||
|
type: 'Remote',
|
||||||
|
quality: 'auto',
|
||||||
|
types: raw.types // Store raw types for quality selection later
|
||||||
|
};
|
||||||
|
}
|
||||||
472
src/renderer/views/Search.vue
Normal file
472
src/renderer/views/Search.vue
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
<template>
|
||||||
|
<div class="view-container search-view">
|
||||||
|
<div class="content-wrapper">
|
||||||
|
<div class="search-header">
|
||||||
|
<h1 class="search-title">搜索: "{{ query }}"</h1>
|
||||||
|
<span class="result-count">找到 {{ total }} 个结果</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="song-list-container" v-if="!loading && songs.length > 0">
|
||||||
|
<div class="list-header">
|
||||||
|
<div class="col-index">#</div>
|
||||||
|
<div class="col-title">标题</div>
|
||||||
|
<div class="col-album">专辑</div>
|
||||||
|
<div class="col-time">时长</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="song-list">
|
||||||
|
<div
|
||||||
|
class="song-item"
|
||||||
|
v-for="(song, i) in songs"
|
||||||
|
:key="song.id"
|
||||||
|
@click="handlePlaySong(i)"
|
||||||
|
>
|
||||||
|
<div class="song-index">{{ (currentPage - 1) * limit + i + 1 }}</div>
|
||||||
|
<div class="song-cover">
|
||||||
|
<img v-if="song.picUrl" :src="song.picUrl" loading="lazy" />
|
||||||
|
<div v-else class="cover-placeholder"></div>
|
||||||
|
</div>
|
||||||
|
<div class="song-info">
|
||||||
|
<h4 class="song-title" v-html="highlight(song.name)"></h4>
|
||||||
|
<p class="song-artist" v-html="highlight(song.artist)"></p>
|
||||||
|
</div>
|
||||||
|
<div class="song-album">{{ song.albumName || '-' }}</div>
|
||||||
|
<div class="song-duration">{{ song.duration }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="pagination">
|
||||||
|
<button class="pagination-btn" :disabled="currentPage <= 1" @click="changePage(currentPage - 1)">
|
||||||
|
<Icon icon="lucide:chevron-left" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-for="page in visiblePages"
|
||||||
|
:key="page"
|
||||||
|
class="pagination-btn"
|
||||||
|
:class="{ active: page === currentPage }"
|
||||||
|
@click="changePage(page)"
|
||||||
|
>
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="pagination-btn" :disabled="currentPage >= totalPages" @click="changePage(currentPage + 1)">
|
||||||
|
<Icon icon="lucide:chevron-right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loading-state" v-if="loading">
|
||||||
|
<Icon icon="lucide:loader-2" class="spin" />
|
||||||
|
<span>正在搜索...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state" v-if="!loading && songs.length === 0">
|
||||||
|
<span>未找到相关歌曲</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { usePlayerStore } from '../stores/player';
|
||||||
|
import { transformSearchSong } from '../utils/songUtils';
|
||||||
|
import type { Song } from '../types/song';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const playerStore = usePlayerStore();
|
||||||
|
|
||||||
|
const query = computed(() => route.query.q as string || '');
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const limit = ref(30);
|
||||||
|
const total = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const songs = ref<Song[]>([]);
|
||||||
|
|
||||||
|
const totalPages = computed(() => {
|
||||||
|
const t = Number(total.value) || 0;
|
||||||
|
const l = Number(limit.value) || 30;
|
||||||
|
return Math.ceil(t / l) || 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const visiblePages = computed(() => {
|
||||||
|
const current = currentPage.value;
|
||||||
|
// Ensure totalPages is strictly valid
|
||||||
|
const total = totalPages.value;
|
||||||
|
const delta = 2; // 2 on each side
|
||||||
|
|
||||||
|
let start = Math.max(1, current - delta);
|
||||||
|
let end = Math.min(total, current + delta);
|
||||||
|
|
||||||
|
if (current - delta < 1) {
|
||||||
|
end = Math.min(total, end + (1 - (current - delta)));
|
||||||
|
}
|
||||||
|
if (current + delta > total) {
|
||||||
|
start = Math.max(1, start - ((current + delta) - total));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = [];
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
if (!query.value) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
songs.value = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.plugin.search('wy', query.value, currentPage.value, limit.value);
|
||||||
|
|
||||||
|
if (result && result.list) {
|
||||||
|
songs.value = result.list.map((item: any) => transformSearchSong(item));
|
||||||
|
// Prioritize songCount if available, otherwise fallback to total, but NEVER use just list length as total
|
||||||
|
total.value = result.songCount || result.total || 0;
|
||||||
|
} else {
|
||||||
|
total.value = 0;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Search failed", e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePage = (page: number) => {
|
||||||
|
currentPage.value = page;
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlaySong = (index: number) => {
|
||||||
|
playerStore.setPlaylist(songs.value, index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHighlightRegex = (q: string) => {
|
||||||
|
const trimmed = q.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
// Escape regex characters
|
||||||
|
const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
|
// If strict or short query, fallback to exact match or simple space split
|
||||||
|
// "超过2个字相似" -> If > 2 chars, try to match substrings
|
||||||
|
if (trimmed.length <= 2) {
|
||||||
|
return new RegExp(escapeRegExp(trimmed), 'gi');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate all substrings of length >= 2
|
||||||
|
const substrings = new Set<string>();
|
||||||
|
substrings.add(trimmed); // Always include full query
|
||||||
|
|
||||||
|
for (let i = 0; i < trimmed.length; i++) {
|
||||||
|
for (let j = i + 2; j <= trimmed.length; j++) {
|
||||||
|
substrings.add(trimmed.slice(i, j));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by length match (longest first)
|
||||||
|
const sorted = Array.from(substrings).sort((a, b) => b.length - a.length);
|
||||||
|
const pattern = sorted.map(s => escapeRegExp(s)).join('|');
|
||||||
|
return new RegExp(pattern, 'gi');
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlight = (text: string) => {
|
||||||
|
if (!query.value || !text) return text;
|
||||||
|
|
||||||
|
const regex = getHighlightRegex(query.value);
|
||||||
|
if (!regex) return text;
|
||||||
|
|
||||||
|
return text.replace(regex, match => `<span class="highlight">${match}</span>`);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(query, () => {
|
||||||
|
currentPage.value = 1;
|
||||||
|
fetchData();
|
||||||
|
}, { immediate: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.view-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
padding: 20px 30px; /* Reduced vertical padding, kept horizontal for spacing but flexible */
|
||||||
|
width: 100%;
|
||||||
|
/* Removed max-width to allow full width usage as requested */
|
||||||
|
/* margin: 0 auto; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-title::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 70%;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
.col-index { text-align: center; }
|
||||||
|
.col-time { text-align: right; }
|
||||||
|
|
||||||
|
.song-item {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
width: 100%; /* Fill width */
|
||||||
|
box-sizing: border-box; /* Include padding in width */
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item:hover {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-index {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
.list-header .col-album {
|
||||||
|
grid-column: 4;
|
||||||
|
}
|
||||||
|
.list-header .col-time {
|
||||||
|
grid-column: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.song-cover {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding-left: 10px; /* Small gap between cover and text */
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-title {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-artist {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-album {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-duration {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlight */
|
||||||
|
:deep(.highlight) {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start; /* Alignment Change: Left Align */
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 32px;
|
||||||
|
gap: 8px; /* Closer gap */
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn {
|
||||||
|
min-width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border-color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn.active {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff; /* Ensure readable text on accent */
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* States */
|
||||||
|
.loading-state, .empty-state {
|
||||||
|
padding: 60px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,7 @@ import vueJsx from "@vitejs/plugin-vue-jsx";
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import electron from 'vite-plugin-electron/simple'
|
import electron from 'vite-plugin-electron/simple'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import wasm from "vite-plugin-wasm";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -14,6 +15,7 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
vueJsx(),
|
vueJsx(),
|
||||||
|
wasm(),
|
||||||
electron({
|
electron({
|
||||||
main: {
|
main: {
|
||||||
// Shortcut of `build.lib.entry`.
|
// Shortcut of `build.lib.entry`.
|
||||||
|
|||||||
Reference in New Issue
Block a user