mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-25 11:29:42 +08:00
add(home) fix(list):ui
This commit is contained in:
3
.idea/vcs.xml
generated
3
.idea/vcs.xml
generated
@@ -2,5 +2,6 @@
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -7,5 +7,6 @@
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
},
|
||||
"editor.fontSize": 14
|
||||
}
|
||||
|
||||
216
docs/audio-pubsub-pattern.md
Normal file
216
docs/audio-pubsub-pattern.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# 音频发布-订阅模式使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
这个改进的发布-订阅模式解决了原有实现中无法单个删除订阅者的问题。新的实现提供了以下特性:
|
||||
|
||||
- ✅ 支持单个订阅者的精确取消
|
||||
- ✅ 自动生成唯一订阅ID
|
||||
- ✅ 类型安全的事件系统
|
||||
- ✅ 错误处理和日志记录
|
||||
- ✅ 内存泄漏防护
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 1. 精确的订阅管理
|
||||
每个订阅都会返回一个取消订阅函数,调用该函数即可精确取消对应的订阅:
|
||||
|
||||
```typescript
|
||||
// 订阅事件
|
||||
const unsubscribe = audioStore.subscribe('ended', () => {
|
||||
console.log('音频播放结束')
|
||||
})
|
||||
|
||||
// 取消订阅
|
||||
unsubscribe()
|
||||
```
|
||||
|
||||
### 2. 支持的事件类型
|
||||
- `ended`: 音频播放结束
|
||||
- `seeked`: 音频拖拽完成
|
||||
- `timeupdate`: 音频时间更新
|
||||
- `play`: 音频开始播放
|
||||
- `pause`: 音频暂停播放
|
||||
|
||||
### 3. 类型安全
|
||||
所有的事件类型和回调函数都有完整的TypeScript类型定义,确保编译时类型检查。
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 基础订阅
|
||||
|
||||
```typescript
|
||||
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
||||
|
||||
const audioStore = ControlAudioStore()
|
||||
|
||||
// 订阅播放结束事件
|
||||
const unsubscribeEnded = audioStore.subscribe('ended', () => {
|
||||
console.log('音频播放结束了')
|
||||
})
|
||||
|
||||
// 订阅时间更新事件
|
||||
const unsubscribeTimeUpdate = audioStore.subscribe('timeupdate', () => {
|
||||
console.log('当前时间:', audioStore.Audio.currentTime)
|
||||
})
|
||||
```
|
||||
|
||||
### 在Vue组件中使用
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { inject, onMounted, onUnmounted } from 'vue'
|
||||
import type { AudioSubscribeMethod, UnsubscribeFunction } from '@renderer/types/audio'
|
||||
|
||||
// 注入订阅方法
|
||||
const audioSubscribe = inject<AudioSubscribeMethod>('audioSubscribe')
|
||||
|
||||
// 存储取消订阅函数
|
||||
const unsubscribeFunctions: UnsubscribeFunction[] = []
|
||||
|
||||
onMounted(() => {
|
||||
if (!audioSubscribe) return
|
||||
|
||||
// 订阅多个事件
|
||||
unsubscribeFunctions.push(
|
||||
audioSubscribe('play', () => console.log('开始播放')),
|
||||
audioSubscribe('pause', () => console.log('暂停播放')),
|
||||
audioSubscribe('ended', () => console.log('播放结束'))
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 组件卸载时取消所有订阅
|
||||
unsubscribeFunctions.forEach(unsubscribe => unsubscribe())
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### 条件订阅和取消
|
||||
|
||||
```typescript
|
||||
let endedUnsubscribe: UnsubscribeFunction | null = null
|
||||
|
||||
// 条件订阅
|
||||
const subscribeToEnded = () => {
|
||||
if (!endedUnsubscribe) {
|
||||
endedUnsubscribe = audioStore.subscribe('ended', handleAudioEnded)
|
||||
}
|
||||
}
|
||||
|
||||
// 条件取消订阅
|
||||
const unsubscribeFromEnded = () => {
|
||||
if (endedUnsubscribe) {
|
||||
endedUnsubscribe()
|
||||
endedUnsubscribe = null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 高级功能
|
||||
|
||||
### 批量管理订阅
|
||||
|
||||
```typescript
|
||||
// 清空特定事件的所有订阅者
|
||||
audioStore.clearEventSubscribers('ended')
|
||||
|
||||
// 清空所有事件的所有订阅者
|
||||
audioStore.clearAllSubscribers()
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
系统内置了错误处理机制,如果某个回调函数执行出错,不会影响其他订阅者:
|
||||
|
||||
```typescript
|
||||
audioStore.subscribe('ended', () => {
|
||||
throw new Error('这个错误不会影响其他订阅者')
|
||||
})
|
||||
|
||||
audioStore.subscribe('ended', () => {
|
||||
console.log('这个回调仍然会正常执行')
|
||||
})
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 及时清理订阅
|
||||
```typescript
|
||||
// ✅ 好的做法:组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
unsubscribeFunctions.forEach(unsubscribe => unsubscribe())
|
||||
})
|
||||
|
||||
// ❌ 不好的做法:忘记清理,可能导致内存泄漏
|
||||
```
|
||||
|
||||
### 2. 使用数组管理多个订阅
|
||||
```typescript
|
||||
// ✅ 好的做法:统一管理
|
||||
const unsubscribeFunctions: UnsubscribeFunction[] = []
|
||||
|
||||
unsubscribeFunctions.push(
|
||||
audioStore.subscribe('play', handlePlay),
|
||||
audioStore.subscribe('pause', handlePause)
|
||||
)
|
||||
|
||||
// 统一清理
|
||||
unsubscribeFunctions.forEach(fn => fn())
|
||||
```
|
||||
|
||||
### 3. 避免在高频事件中执行重操作
|
||||
```typescript
|
||||
// ❌ 不好的做法:在timeupdate中执行重操作
|
||||
audioStore.subscribe('timeupdate', () => {
|
||||
// 这会每秒执行多次,影响性能
|
||||
updateComplexUI()
|
||||
})
|
||||
|
||||
// ✅ 好的做法:使用节流或防抖
|
||||
let lastUpdate = 0
|
||||
audioStore.subscribe('timeupdate', () => {
|
||||
const now = Date.now()
|
||||
if (now - lastUpdate > 100) { // 限制更新频率
|
||||
updateUI()
|
||||
lastUpdate = now
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 从旧版本迁移
|
||||
|
||||
旧版本:
|
||||
```typescript
|
||||
// 旧的实现方式
|
||||
provide('setAudioEnd', setEndCallback)
|
||||
|
||||
function setEndCallback(fn: Function): void {
|
||||
endCallback.push(fn)
|
||||
}
|
||||
```
|
||||
|
||||
新版本:
|
||||
```typescript
|
||||
// 新的实现方式
|
||||
provide('audioSubscribe', audioStore.subscribe)
|
||||
|
||||
// 使用时
|
||||
const unsubscribe = audioSubscribe('ended', () => {
|
||||
// 处理播放结束
|
||||
})
|
||||
|
||||
// 可以精确取消
|
||||
unsubscribe()
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
1. **避免重复订阅**:在订阅前检查是否已经订阅
|
||||
2. **及时取消订阅**:组件卸载或不再需要时立即取消
|
||||
3. **合理使用事件**:避免在高频事件中执行重操作
|
||||
4. **批量操作**:需要清理多个订阅时使用批量清理方法
|
||||
|
||||
这个改进的发布-订阅模式为Ceru Music应用提供了更加灵活和可靠的音频事件管理机制。
|
||||
@@ -1,5 +1,5 @@
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import { defineConfig, externalizeDepsPlugin, bytecodePlugin } from 'electron-vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
@@ -10,14 +10,16 @@ export default defineConfig({
|
||||
plugins: [
|
||||
externalizeDepsPlugin({
|
||||
exclude: ['@electron-toolkit/utils']
|
||||
})
|
||||
}),
|
||||
bytecodePlugin()
|
||||
]
|
||||
},
|
||||
preload: {
|
||||
plugins: [
|
||||
externalizeDepsPlugin({
|
||||
exclude: ['@electron-toolkit/preload']
|
||||
})
|
||||
}),
|
||||
bytecodePlugin()
|
||||
]
|
||||
},
|
||||
renderer: {
|
||||
|
||||
@@ -27,7 +27,6 @@ export default tseslint.config(
|
||||
'vue/require-default-prop': 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'@typescript-eslint/no-unsafe-function-return-type': 'off',
|
||||
'@typescript-eslint/no-unsafe-function-type': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'vue/block-lang': [
|
||||
'error',
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"dev": "electron-vite dev --watch",
|
||||
"build": "pnpm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "pnpm run build && electron-builder --dir",
|
||||
@@ -26,7 +26,6 @@
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@lrc-player/core": "^1.1.5",
|
||||
"@lrc-player/parse": "^1.0.0",
|
||||
"NeteaseCloudMusicApi": "^4.27.0",
|
||||
"axios": "^1.11.0",
|
||||
"ceru-music": "link:",
|
||||
"electron-updater": "^6.3.9",
|
||||
|
||||
935
pnpm-lock.yaml
generated
935
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,78 @@
|
||||
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||
import { app, shell, BrowserWindow, ipcMain, screen, Tray, Menu } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/logo.png?asset'
|
||||
import path from 'node:path'
|
||||
|
||||
let tray: Tray | null = null
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let isQuitting = false
|
||||
|
||||
function createTray(): void {
|
||||
// 创建系统托盘
|
||||
const trayIconPath = path.join(__dirname, '../../resources/logo.png')
|
||||
tray = new Tray(trayIconPath)
|
||||
|
||||
// 创建托盘菜单
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: '显示窗口',
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '播放/暂停',
|
||||
click: () => {
|
||||
// 这里可以添加播放控制逻辑
|
||||
console.log('播放/暂停')
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '退出',
|
||||
click: () => {
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
tray.setContextMenu(contextMenu)
|
||||
tray.setToolTip('Ceru Music')
|
||||
|
||||
// 双击托盘图标显示窗口
|
||||
tray.on('click', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide()
|
||||
} else {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 670,
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1100,
|
||||
height: 750,
|
||||
minWidth: 970,
|
||||
minHeight: 670,
|
||||
show: false,
|
||||
center: true,
|
||||
autoHideMenuBar: true,
|
||||
// alwaysOnTop: true,
|
||||
maxWidth: screen.getPrimaryDisplay()?.workAreaSize.width,
|
||||
maxHeight: screen.getPrimaryDisplay()?.workAreaSize.height,
|
||||
titleBarStyle: 'hidden',
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
// ...(process.platform !== 'darwin' ? { titleBarOverlay: true } : {}),
|
||||
icon: path.join(__dirname, '../../resources/logo.png'),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
@@ -19,9 +80,27 @@ function createWindow(): void {
|
||||
webSecurity: false
|
||||
}
|
||||
})
|
||||
if (process.platform == 'darwin') mainWindow.setWindowButtonVisibility(false)
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
mainWindow?.show()
|
||||
})
|
||||
|
||||
// 阻止窗口关闭,改为隐藏到系统托盘
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!isQuitting) {
|
||||
event.preventDefault()
|
||||
mainWindow?.hide()
|
||||
|
||||
// 显示托盘通知
|
||||
if (tray) {
|
||||
tray.displayBalloon({
|
||||
iconType: 'info',
|
||||
title: 'Ceru Music',
|
||||
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
@@ -43,7 +122,7 @@ function createWindow(): void {
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(() => {
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId('com.electron')
|
||||
electronApp.setAppUserModelId('com.cerulean.music')
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
@@ -55,7 +134,55 @@ app.whenReady().then(() => {
|
||||
// IPC test
|
||||
ipcMain.on('ping', () => console.log('pong'))
|
||||
|
||||
// 窗口控制 IPC 处理
|
||||
ipcMain.on('window-minimize', () => {
|
||||
const window = BrowserWindow.getFocusedWindow()
|
||||
if (window) {
|
||||
window.minimize()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('window-maximize', () => {
|
||||
const window = BrowserWindow.getFocusedWindow()
|
||||
if (window) {
|
||||
if (window.isMaximized()) {
|
||||
window.unmaximize()
|
||||
} else {
|
||||
window.maximize()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('window-close', () => {
|
||||
const window = BrowserWindow.getFocusedWindow()
|
||||
if (window) {
|
||||
window.close()
|
||||
}
|
||||
})
|
||||
|
||||
// Mini 模式 IPC 处理 - 最小化到系统托盘
|
||||
ipcMain.on('window-mini-mode', (_, isMini) => {
|
||||
if (mainWindow) {
|
||||
if (isMini) {
|
||||
// 进入 Mini 模式:隐藏窗口到系统托盘
|
||||
mainWindow.hide()
|
||||
// 显示托盘通知(可选)
|
||||
if (tray) {
|
||||
tray.displayBalloon({
|
||||
title: '澜音 Music',
|
||||
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 退出 Mini 模式:显示窗口
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
createWindow()
|
||||
createTray()
|
||||
|
||||
app.on('activate', function () {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
@@ -64,13 +191,15 @@ app.whenReady().then(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
// 当所有窗口关闭时不退出应用,因为我们有系统托盘
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
// 在 macOS 上,应用通常会保持活跃状态
|
||||
// 在其他平台上,我们也保持应用运行,因为有系统托盘
|
||||
})
|
||||
|
||||
// 应用退出前的清理
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
|
||||
10
src/preload/index.d.ts
vendored
10
src/preload/index.d.ts
vendored
@@ -1,8 +1,16 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
// 自定义 API 接口
|
||||
interface CustomAPI {
|
||||
minimize: () => void
|
||||
maximize: () => void
|
||||
close: () => void
|
||||
setMiniMode: (isMini: boolean) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: unknown
|
||||
api: CustomAPI
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { contextBridge } from 'electron'
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {}
|
||||
const api = {
|
||||
// 窗口控制方法
|
||||
minimize: () => ipcRenderer.send('window-minimize'),
|
||||
maximize: () => ipcRenderer.send('window-maximize'),
|
||||
close: () => ipcRenderer.send('window-close'),
|
||||
setMiniMode: (isMini: boolean) => ipcRenderer.send('window-mini-mode', isMini)
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
// renderer only if context isolation is enabled, otherwise
|
||||
|
||||
4
src/renderer/auto-imports.d.ts
vendored
4
src/renderer/auto-imports.d.ts
vendored
@@ -5,4 +5,6 @@
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {}
|
||||
declare global {
|
||||
|
||||
}
|
||||
|
||||
9
src/renderer/components.d.ts
vendored
9
src/renderer/components.d.ts
vendored
@@ -12,17 +12,14 @@ declare module 'vue' {
|
||||
PlayMusic: typeof import('./src/components/Play/PlayMusic.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SearchComponent: typeof import('./src/components/Search/SearchComponent.vue')['default']
|
||||
TAside: typeof import('tdesign-vue-next')['Aside']
|
||||
TBadge: typeof import('tdesign-vue-next')['Badge']
|
||||
TButton: typeof import('tdesign-vue-next')['Button']
|
||||
TContent: typeof import('tdesign-vue-next')['Content']
|
||||
TFooter: typeof import('tdesign-vue-next')['Footer']
|
||||
TIcon: typeof import('tdesign-vue-next')['Icon']
|
||||
TInput: typeof import('tdesign-vue-next')['Input']
|
||||
TitleBarControls: typeof import('./src/components/TitleBarControls.vue')['default']
|
||||
TLayout: typeof import('tdesign-vue-next')['Layout']
|
||||
TMenu: typeof import('tdesign-vue-next')['Menu']
|
||||
TMenuItem: typeof import('tdesign-vue-next')['MenuItem']
|
||||
TTag: typeof import('tdesign-vue-next')['Tag']
|
||||
TLoading: typeof import('tdesign-vue-next')['Loading']
|
||||
Versions: typeof import('./src/components/Versions.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
// const ipcHandle = (): void => window.electron.ipcRenderer.send('ping')
|
||||
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
||||
const Audio = ControlAudioStore()
|
||||
Audio.Audio.url =
|
||||
'https://m801.music.126.net/20250814224734/9822eac7d8f043a33883fe30796fa0c6/jdymusic/obj/wo3DlMOGwrbDjj7DisKw/32407718767/f58f/b1e1/cf73/d70b39ba746ca874e3d1bd0466ec1227.mp3'
|
||||
import { onMounted } from 'vue'
|
||||
import GlobalAudio from './components/Play/GlobalAudio.vue'
|
||||
|
||||
onMounted(() => {
|
||||
// 设置测试音频URL
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
<t-button @click="Audio.Audio.isPlay ? Audio.stop() : Audio.start()">play</t-button>
|
||||
<GlobalAudio />
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import './icon_font/iconfont.css';
|
||||
|
||||
:root {
|
||||
}
|
||||
|
||||
|
||||
207
src/renderer/src/components/Play/.backup.js
Normal file
207
src/renderer/src/components/Play/.backup.js
Normal file
@@ -0,0 +1,207 @@
|
||||
import { ref, onMounted, reactive, UnwrapRef } from 'vue'
|
||||
import '@lrc-player/core/dist/style.css'
|
||||
import Player from '@lrc-player/core'
|
||||
|
||||
type userAudio = {
|
||||
play: (lengthen?: boolean) => Promise<undefined>
|
||||
pause: (isNeed?: boolean, lengthen?: boolean) => Promise<undefined>
|
||||
} & Omit<HTMLAudioElement, 'pause' | 'play'>
|
||||
|
||||
export interface MusicPlayerInstanceType {
|
||||
orderStatusVal: UnwrapRef<typeof orderStatusVal>
|
||||
el: UnwrapRef<userAudio>
|
||||
isPlay: UnwrapRef<boolean>
|
||||
reset: (val: boolean) => void
|
||||
pause: typeof pause
|
||||
play: typeof play
|
||||
time: number
|
||||
oldTime: number
|
||||
transitionIsPlay: UnwrapRef<boolean>
|
||||
addListener: (listener: ListenerName) => void
|
||||
cutSongHandler: () => void
|
||||
}
|
||||
interface Props {
|
||||
ids?: number[]
|
||||
songs: GetMusicDetailData
|
||||
}
|
||||
const store = useUserInfo()
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['playEnd', 'cutSong'])
|
||||
const isPlay = ref(false)
|
||||
// 1列表循环 2随机播放 3单曲循环
|
||||
const orderStatusVal = ref<1 | 2 | 3>(1)
|
||||
const audio = ref<userAudio>()
|
||||
const music = useMusicAction()
|
||||
const flags = useFlags()
|
||||
const transitionIsPlay = ref(false)
|
||||
const { addListener, executeListener, pauseSomethingListener } = useListener(audio)
|
||||
const { getPlayListDetailFn } = usePlayList()
|
||||
const player = new Player({
|
||||
click
|
||||
})
|
||||
function click(time: number, index: number) {
|
||||
console.log(time, index)
|
||||
audio.value!.currentTime = time
|
||||
}
|
||||
function seeked() {
|
||||
player.syncIndex()
|
||||
}
|
||||
let originPlay: HTMLMediaElement['play']
|
||||
let originPause: HTMLMediaElement['pause']
|
||||
|
||||
onMounted(() => {
|
||||
player.mount(document.querySelector('.lyric-container') as HTMLDivElement, audio.value)
|
||||
originPlay = audio.value!.play as HTMLMediaElement['play']
|
||||
originPause = audio.value!.pause as HTMLMediaElement['pause']
|
||||
// 播放,音量过渡提高
|
||||
audio.value!.play = play
|
||||
// 音量过渡减少为0,然后暂停
|
||||
audio.value!.pause = pause
|
||||
|
||||
audio.value?.addEventListener('error', (event: any) => {
|
||||
// console.log('event.target.error', event.target.error)
|
||||
if (event.target.error.code === 4) {
|
||||
}
|
||||
})
|
||||
})
|
||||
function play(lengthen: boolean = false) {
|
||||
let volume = store.volume
|
||||
player.play()
|
||||
audio.value!.volume = 0
|
||||
originPlay.call(audio.value).catch((err) => {
|
||||
console.error('调用origin.play方法时抛出了错误:', err)
|
||||
})
|
||||
isPlay.value = true
|
||||
timeState.stop = false
|
||||
|
||||
// 开始时直接改变就可以,让逐字歌词跟得上
|
||||
transitionIsPlay.value = true
|
||||
return transitionVolume(volume, true, lengthen).then(() => {})
|
||||
}
|
||||
function pause(isNeed: boolean = true, lengthen: boolean = false) {
|
||||
let volume = store.volume
|
||||
// 是否需要更新暂停标识, 什么时候不需要,就比如切换下一首歌的时候:
|
||||
// 这个时候会先调用pause暂停上一首进行过渡,然后在调用play播放,这个时候就不需要更新暂停标识
|
||||
isNeed && (isPlay.value = false)
|
||||
return transitionVolume(volume, false, lengthen).then(() => {
|
||||
player.pause()
|
||||
// 暂停时应该等待音量过渡完成在改变,让逐字歌词也有一个暂停过渡效果
|
||||
transitionIsPlay.value = false
|
||||
})
|
||||
}
|
||||
let timer: NodeJS.Timer
|
||||
// 当过渡完成时会返回Promise
|
||||
function transitionVolume(
|
||||
volume: number,
|
||||
target: boolean = true,
|
||||
lengthen: boolean = false
|
||||
): Promise<undefined> {
|
||||
clearInterval(timer)
|
||||
const playVolume = lengthen ? 40 : 15
|
||||
const pauseVolume = lengthen ? 20 : 10
|
||||
return new Promise((resolve) => {
|
||||
if (target) {
|
||||
timer = setInterval(() => {
|
||||
audio.value!.volume = Math.min(audio.value!.volume + volume / playVolume, volume)
|
||||
if (audio.value!.volume >= volume) {
|
||||
resolve(undefined)
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 50)
|
||||
return
|
||||
}
|
||||
timer = setInterval(() => {
|
||||
audio.value!.volume = Math.max(audio.value!.volume - volume / pauseVolume, 0)
|
||||
if (audio.value!.volume <= 0) {
|
||||
clearInterval(timer)
|
||||
originPause.call(audio.value)
|
||||
audio.value!.volume = volume
|
||||
resolve(undefined)
|
||||
}
|
||||
}, 50)
|
||||
})
|
||||
}
|
||||
|
||||
const timeState = reactive({
|
||||
stop: false,
|
||||
previousTime: 0 // 新增属性来保存旧的 currentTime
|
||||
})
|
||||
|
||||
const timeupdate = () => {
|
||||
if (timeState.stop || isNaN($audio.el.duration)) {
|
||||
return
|
||||
}
|
||||
// 在更新 currentTime 之前,保存旧的值
|
||||
timeState.previousTime = music.state.currentTime
|
||||
music.state.currentTime = $audio.time
|
||||
}
|
||||
|
||||
const reset = (val: boolean) => {
|
||||
music.state.currentTime = 0
|
||||
isPlay.value = val
|
||||
transitionIsPlay.value = val
|
||||
// 这里需要停止timeupdate的事件监视,因为在暂停音乐时会过渡结束(就相当于还是在播放一段时间),
|
||||
// 这样会导致进度条进度重置不及时
|
||||
timeState.stop = true // 在每次play方法时都会重置stop值
|
||||
}
|
||||
const end = () => {
|
||||
emit('playEnd')
|
||||
}
|
||||
const setOrderHandler = () => {
|
||||
const runtimeList = music.state.runtimeList
|
||||
|
||||
let newValue = (orderStatusVal.value + 1) % orderStatus.length
|
||||
|
||||
// 如果上一次是心动模式并且当前播放的列表是”我喜欢的“,这次切换为其他,则重新获取”我喜欢的“列表,并更新进行时列表
|
||||
if (runtimeList?.specialType === 5 && orderStatusVal.value === 0 && newValue !== 0) {
|
||||
getPlayListDetailFn(runtimeList.id, '', false)
|
||||
music.updateTracks(
|
||||
playListState.playList,
|
||||
playListState.playList.map((item) => item.id)
|
||||
)
|
||||
}
|
||||
|
||||
// 如果当前播放歌单不是”我喜欢的“列表,则心动模式不可用
|
||||
orderStatusVal.value =
|
||||
newValue === 0 && runtimeList?.specialType !== 5 ? 1 : (newValue as typeof orderStatusVal.value)
|
||||
|
||||
music.getIntelliganceListHandler()
|
||||
}
|
||||
|
||||
// 执行切换事件,随后暂停time监听器,等待歌曲加载完成后会打开
|
||||
const cutSongHandler = () => {
|
||||
console.log('music.state.lyric', music.state.lyric)
|
||||
const type = music.state.lrcMode === 1 ? 'lrc' : 'yrc'
|
||||
player.updateAudioLrc(music.state.lyric, type)
|
||||
executeListener('cutSong')
|
||||
}
|
||||
|
||||
const exposeObj = {
|
||||
el: audio,
|
||||
orderStatusVal,
|
||||
isPlay,
|
||||
reset,
|
||||
play,
|
||||
pause,
|
||||
transitionIsPlay,
|
||||
addListener,
|
||||
cutSongHandler
|
||||
}
|
||||
Object.defineProperty(exposeObj, 'time', {
|
||||
get(): number {
|
||||
return audio.value!.currentTime
|
||||
},
|
||||
set(time: number) {
|
||||
try {
|
||||
audio.value!.currentTime = time
|
||||
} catch (e) {
|
||||
console.error('设置time时出现了错误: ', e, ',time: ', time)
|
||||
}
|
||||
}
|
||||
})
|
||||
Object.defineProperty(exposeObj, 'oldTime', {
|
||||
get(): number {
|
||||
return timeState.previousTime
|
||||
}
|
||||
})
|
||||
defineExpose(exposeObj)
|
||||
@@ -1,43 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, provide, ref } from 'vue'
|
||||
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
||||
const Audio = ControlAudioStore()
|
||||
provide('setAudioEnd', setEndCallback)
|
||||
provide('setAudioSeeked', setSeekedCallback)
|
||||
const timeupdate = () => {}
|
||||
let endCallback: Function[] = []
|
||||
let seekedCallback: Function[] = []
|
||||
|
||||
const audioStore = ControlAudioStore()
|
||||
const audioMeta = ref<HTMLAudioElement>()
|
||||
|
||||
// 提供订阅方法给子组件使用
|
||||
provide('audioSubscribe', audioStore.subscribe)
|
||||
|
||||
onMounted(() => {
|
||||
Audio.init(audioMeta.value)
|
||||
console.log('init', audioMeta, '1111')
|
||||
audioStore.init(audioMeta.value)
|
||||
console.log('音频组件初始化完成')
|
||||
})
|
||||
|
||||
function setEndCallback(fn: Function): void {
|
||||
if (typeof endCallback !== 'function') {
|
||||
endCallback.push(fn)
|
||||
} else {
|
||||
throw new Error('Callback must be a function')
|
||||
}
|
||||
}
|
||||
function setSeekedCallback(fn: Function): void {
|
||||
if (typeof seekedCallback !== 'function') {
|
||||
seekedCallback.push(fn)
|
||||
} else {
|
||||
throw new Error('Callback must be a function')
|
||||
// 音频事件处理函数
|
||||
const handleTimeUpdate = (): void => {
|
||||
if (audioMeta.value) {
|
||||
audioStore.setCurrentTime(audioMeta.value.currentTime)
|
||||
audioStore.Audio.duration = audioMeta.value.duration || 0
|
||||
}
|
||||
audioStore.publish('timeupdate')
|
||||
}
|
||||
|
||||
const end = (): void => {
|
||||
endCallback?.forEach((fn) => fn)
|
||||
const handleEnded = (): void => {
|
||||
audioStore.Audio.isPlay = false
|
||||
audioStore.publish('ended')
|
||||
}
|
||||
const seeked = (): void => {
|
||||
seekedCallback?.forEach((fn) => fn)
|
||||
|
||||
const handleSeeked = (): void => {
|
||||
audioStore.publish('seeked')
|
||||
}
|
||||
|
||||
const handlePlay = (): void => {
|
||||
audioStore.Audio.isPlay = true
|
||||
audioStore.publish('play')
|
||||
}
|
||||
|
||||
const handlePause = (): void => {
|
||||
audioStore.Audio.isPlay = false
|
||||
audioStore.publish('pause')
|
||||
}
|
||||
|
||||
const handleError = (event: Event): void => {
|
||||
const target = event.target as HTMLAudioElement
|
||||
console.error('音频加载错误:', target.error)
|
||||
audioStore.Audio.isPlay = false
|
||||
audioStore.publish('error')
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
endCallback = []
|
||||
seekedCallback = []
|
||||
// 组件卸载时清空所有订阅者
|
||||
audioStore.clearAllSubscribers()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -46,10 +59,13 @@ onUnmounted(() => {
|
||||
<audio
|
||||
ref="audioMeta"
|
||||
preload="auto"
|
||||
:src="Audio.Audio.url"
|
||||
@timeupdate="timeupdate"
|
||||
@ended="end"
|
||||
@seeked="seeked"
|
||||
:src="audioStore.Audio.url"
|
||||
@timeupdate="handleTimeUpdate"
|
||||
@ended="handleEnded"
|
||||
@seeked="handleSeeked"
|
||||
@play="handlePlay"
|
||||
@pause="handlePause"
|
||||
@error="handleError"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,233 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive, UnwrapRef } from 'vue'
|
||||
import '@lrc-player/core/dist/style.css'
|
||||
import Player from '@lrc-player/core'
|
||||
|
||||
type userAudio = {
|
||||
play: (lengthen?: boolean) => Promise<undefined>
|
||||
pause: (isNeed?: boolean, lengthen?: boolean) => Promise<undefined>
|
||||
} & Omit<HTMLAudioElement, 'pause' | 'play'>
|
||||
|
||||
export interface MusicPlayerInstanceType {
|
||||
orderStatusVal: UnwrapRef<typeof orderStatusVal>
|
||||
el: UnwrapRef<userAudio>
|
||||
isPlay: UnwrapRef<boolean>
|
||||
reset: (val: boolean) => void
|
||||
pause: typeof pause
|
||||
play: typeof play
|
||||
time: number
|
||||
oldTime: number
|
||||
transitionIsPlay: UnwrapRef<boolean>
|
||||
addListener: (listener: ListenerName) => void
|
||||
cutSongHandler: () => void
|
||||
}
|
||||
interface Props {
|
||||
ids?: number[]
|
||||
songs: GetMusicDetailData
|
||||
}
|
||||
const store = useUserInfo()
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['playEnd', 'cutSong'])
|
||||
const isPlay = ref(false)
|
||||
// 1列表循环 2随机播放 3单曲循环
|
||||
const orderStatusVal = ref<1 | 2 | 3>(1)
|
||||
const audio = ref<userAudio>()
|
||||
const music = useMusicAction()
|
||||
const flags = useFlags()
|
||||
const transitionIsPlay = ref(false)
|
||||
const { addListener, executeListener, pauseSomethingListener } = useListener(audio)
|
||||
const { getPlayListDetailFn } = usePlayList()
|
||||
const player = new Player({
|
||||
click
|
||||
})
|
||||
function click(time: number, index: number) {
|
||||
console.log(time, index)
|
||||
audio.value!.currentTime = time
|
||||
}
|
||||
function seeked() {
|
||||
player.syncIndex()
|
||||
}
|
||||
let originPlay: HTMLMediaElement['play']
|
||||
let originPause: HTMLMediaElement['pause']
|
||||
|
||||
onMounted(() => {
|
||||
player.mount(document.querySelector('.lyric-container') as HTMLDivElement, audio.value)
|
||||
originPlay = audio.value!.play as HTMLMediaElement['play']
|
||||
originPause = audio.value!.pause as HTMLMediaElement['pause']
|
||||
// 播放,音量过渡提高
|
||||
audio.value!.play = play
|
||||
// 音量过渡减少为0,然后暂停
|
||||
audio.value!.pause = pause
|
||||
|
||||
audio.value?.addEventListener('error', (event: any) => {
|
||||
// console.log('event.target.error', event.target.error)
|
||||
if (event.target.error.code === 4) {
|
||||
}
|
||||
})
|
||||
})
|
||||
function play(lengthen: boolean = false) {
|
||||
let volume = store.volume
|
||||
player.play()
|
||||
audio.value!.volume = 0
|
||||
originPlay.call(audio.value).catch((err) => {
|
||||
console.error('调用origin.play方法时抛出了错误:', err)
|
||||
})
|
||||
isPlay.value = true
|
||||
timeState.stop = false
|
||||
|
||||
// 开始时直接改变就可以,让逐字歌词跟得上
|
||||
transitionIsPlay.value = true
|
||||
return transitionVolume(volume, true, lengthen).then(() => {})
|
||||
}
|
||||
function pause(isNeed: boolean = true, lengthen: boolean = false) {
|
||||
let volume = store.volume
|
||||
// 是否需要更新暂停标识, 什么时候不需要,就比如切换下一首歌的时候:
|
||||
// 这个时候会先调用pause暂停上一首进行过渡,然后在调用play播放,这个时候就不需要更新暂停标识
|
||||
isNeed && (isPlay.value = false)
|
||||
return transitionVolume(volume, false, lengthen).then(() => {
|
||||
player.pause()
|
||||
// 暂停时应该等待音量过渡完成在改变,让逐字歌词也有一个暂停过渡效果
|
||||
transitionIsPlay.value = false
|
||||
})
|
||||
}
|
||||
let timer: NodeJS.Timer
|
||||
// 当过渡完成时会返回Promise
|
||||
function transitionVolume(
|
||||
volume: number,
|
||||
target: boolean = true,
|
||||
lengthen: boolean = false
|
||||
): Promise<undefined> {
|
||||
clearInterval(timer)
|
||||
const playVolume = lengthen ? 40 : 15
|
||||
const pauseVolume = lengthen ? 20 : 10
|
||||
return new Promise((resolve) => {
|
||||
if (target) {
|
||||
timer = setInterval(() => {
|
||||
audio.value!.volume = Math.min(audio.value!.volume + volume / playVolume, volume)
|
||||
if (audio.value!.volume >= volume) {
|
||||
resolve(undefined)
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 50)
|
||||
return
|
||||
}
|
||||
timer = setInterval(() => {
|
||||
audio.value!.volume = Math.max(audio.value!.volume - volume / pauseVolume, 0)
|
||||
if (audio.value!.volume <= 0) {
|
||||
clearInterval(timer)
|
||||
originPause.call(audio.value)
|
||||
audio.value!.volume = volume
|
||||
resolve(undefined)
|
||||
}
|
||||
}, 50)
|
||||
})
|
||||
}
|
||||
|
||||
const timeState = reactive({
|
||||
stop: false,
|
||||
previousTime: 0 // 新增属性来保存旧的 currentTime
|
||||
})
|
||||
|
||||
const timeupdate = () => {
|
||||
if (timeState.stop || isNaN($audio.el.duration)) {
|
||||
return
|
||||
}
|
||||
// 在更新 currentTime 之前,保存旧的值
|
||||
timeState.previousTime = music.state.currentTime
|
||||
music.state.currentTime = $audio.time
|
||||
}
|
||||
|
||||
const reset = (val: boolean) => {
|
||||
music.state.currentTime = 0
|
||||
isPlay.value = val
|
||||
transitionIsPlay.value = val
|
||||
// 这里需要停止timeupdate的事件监视,因为在暂停音乐时会过渡结束(就相当于还是在播放一段时间),
|
||||
// 这样会导致进度条进度重置不及时
|
||||
timeState.stop = true // 在每次play方法时都会重置stop值
|
||||
}
|
||||
const end = () => {
|
||||
emit('playEnd')
|
||||
}
|
||||
const setOrderHandler = () => {
|
||||
const runtimeList = music.state.runtimeList
|
||||
|
||||
let newValue = (orderStatusVal.value + 1) % orderStatus.length
|
||||
|
||||
// 如果上一次是心动模式并且当前播放的列表是”我喜欢的“,这次切换为其他,则重新获取”我喜欢的“列表,并更新进行时列表
|
||||
if (runtimeList?.specialType === 5 && orderStatusVal.value === 0 && newValue !== 0) {
|
||||
getPlayListDetailFn(runtimeList.id, '', false)
|
||||
music.updateTracks(
|
||||
playListState.playList,
|
||||
playListState.playList.map((item) => item.id)
|
||||
)
|
||||
}
|
||||
|
||||
// 如果当前播放歌单不是”我喜欢的“列表,则心动模式不可用
|
||||
orderStatusVal.value =
|
||||
newValue === 0 && runtimeList?.specialType !== 5 ? 1 : (newValue as typeof orderStatusVal.value)
|
||||
|
||||
music.getIntelliganceListHandler()
|
||||
}
|
||||
|
||||
// 执行切换事件,随后暂停time监听器,等待歌曲加载完成后会打开
|
||||
const cutSongHandler = () => {
|
||||
console.log('music.state.lyric', music.state.lyric)
|
||||
const type = music.state.lrcMode === 1 ? 'lrc' : 'yrc'
|
||||
player.updateAudioLrc(music.state.lyric, type)
|
||||
executeListener('cutSong')
|
||||
}
|
||||
|
||||
const exposeObj = {
|
||||
el: audio,
|
||||
orderStatusVal,
|
||||
isPlay,
|
||||
reset,
|
||||
play,
|
||||
pause,
|
||||
transitionIsPlay,
|
||||
addListener,
|
||||
cutSongHandler
|
||||
}
|
||||
Object.defineProperty(exposeObj, 'time', {
|
||||
get(): number {
|
||||
return audio.value!.currentTime
|
||||
},
|
||||
set(time: number) {
|
||||
try {
|
||||
audio.value!.currentTime = time
|
||||
} catch (e) {
|
||||
console.error('设置time时出现了错误: ', e, ',time: ', time)
|
||||
}
|
||||
}
|
||||
})
|
||||
Object.defineProperty(exposeObj, 'oldTime', {
|
||||
get(): number {
|
||||
return timeState.previousTime
|
||||
}
|
||||
})
|
||||
defineExpose(exposeObj)
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="bottom-container"></div>
|
||||
<div class="bottom-container">
|
||||
<div>aaa</div>
|
||||
<div>aaa</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.plan-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 15px;
|
||||
position: absolute;
|
||||
top: -8.5px;
|
||||
width: 100%;
|
||||
}
|
||||
.el-overlay {
|
||||
.music-drawer {
|
||||
}
|
||||
}
|
||||
:deep(.el-drawer) {
|
||||
height: 100%;
|
||||
}
|
||||
// .plan-container {
|
||||
// display: flex;
|
||||
// align-items: center;
|
||||
// height: 15px;
|
||||
// position: absolute;
|
||||
// top: -8.5px;
|
||||
// width: 100%;
|
||||
// }
|
||||
|
||||
.bottom-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
86
src/renderer/src/components/Search/SearchComponent.vue
Normal file
86
src/renderer/src/components/Search/SearchComponent.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
const isSearching = ref(false)
|
||||
|
||||
// 搜索类型:1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单
|
||||
|
||||
// 处理搜索事件
|
||||
const handleSearch = async () => {
|
||||
if (!keyword.value.trim()) return
|
||||
|
||||
isSearching.value = true
|
||||
try {
|
||||
// 调用搜索API
|
||||
|
||||
// 跳转到搜索结果页面,并传递搜索结果和关键词
|
||||
router.push({
|
||||
path: '/home/search',
|
||||
query: { keyword: keyword.value },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error)
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理按键事件,按下回车键时触发搜索
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSearch()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-component">
|
||||
<t-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索音乐、歌手"
|
||||
:loading="isSearching"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<template #suffix>
|
||||
<t-button
|
||||
theme="primary"
|
||||
variant="text"
|
||||
shape="square"
|
||||
:disabled="isSearching"
|
||||
@click="handleSearch"
|
||||
>
|
||||
<i class="iconfont icon-sousuo"></i>
|
||||
</t-button>
|
||||
</template>
|
||||
</t-input>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-component {
|
||||
width: 100%;
|
||||
|
||||
:deep(.t-input) {
|
||||
border-radius: 0rem !important;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.t-input__suffix) {
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 1rem;
|
||||
color: #6b7280;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #f97316;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
245
src/renderer/src/components/TitleBarControls.vue
Normal file
245
src/renderer/src/components/TitleBarControls.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
// 定义窗口控制按钮风格类型
|
||||
type ControlStyle = 'traffic-light' | 'windows'
|
||||
|
||||
// 组件属性
|
||||
interface Props {
|
||||
controlStyle?: ControlStyle
|
||||
showSettings?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
controlStyle: 'windows',
|
||||
showSettings: true
|
||||
})
|
||||
|
||||
// Mini 模式现在是直接隐藏到系统托盘,不需要状态跟踪
|
||||
|
||||
// 计算样式类名
|
||||
const controlsClass = computed(() => {
|
||||
return `title-controls ${props.controlStyle}`
|
||||
})
|
||||
|
||||
// 窗口控制方法
|
||||
const handleMinimize = (): void => {
|
||||
window.api?.minimize()
|
||||
}
|
||||
|
||||
const handleMaximize = (): void => {
|
||||
window.api?.maximize()
|
||||
}
|
||||
|
||||
const handleClose = (): void => {
|
||||
window.api?.close()
|
||||
}
|
||||
|
||||
const handleMiniMode = (): void => {
|
||||
// 直接最小化到系统托盘
|
||||
window.api?.setMiniMode(true)
|
||||
console.log('最小化到系统托盘')
|
||||
}
|
||||
|
||||
// 路由实例
|
||||
const router = useRouter()
|
||||
|
||||
const handleSettings = (): void => {
|
||||
// 跳转到设置页面
|
||||
router.push('/settings')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="controlsClass">
|
||||
<!-- 设置按钮 -->
|
||||
<t-button
|
||||
v-if="showSettings"
|
||||
shape="circle"
|
||||
theme="default"
|
||||
variant="text"
|
||||
class="control-btn settings-btn"
|
||||
@click="handleSettings"
|
||||
>
|
||||
<i class="iconfont icon-shezhi"></i>
|
||||
</t-button>
|
||||
|
||||
<!-- 窗口控制按钮组 -->
|
||||
<div class="window-controls">
|
||||
<!-- Mini 模式按钮 -->
|
||||
<t-button
|
||||
shape="circle"
|
||||
theme="default"
|
||||
variant="text"
|
||||
class="control-btn mini-btn"
|
||||
title="最小化到系统托盘"
|
||||
@click="handleMiniMode"
|
||||
>
|
||||
<i class="iconfont icon-dibu"></i>
|
||||
</t-button>
|
||||
|
||||
<!-- 最小化按钮 -->
|
||||
<t-button
|
||||
shape="circle"
|
||||
theme="default"
|
||||
variant="text"
|
||||
class="control-btn minimize-btn"
|
||||
title="最小化"
|
||||
@click="handleMinimize"
|
||||
>
|
||||
<i v-if="controlStyle === 'windows'" class="iconfont icon-zuixiaohua"></i>
|
||||
<div v-else class="traffic-light minimize"></div>
|
||||
</t-button>
|
||||
|
||||
<!-- 最大化按钮 -->
|
||||
<t-button
|
||||
shape="circle"
|
||||
theme="default"
|
||||
variant="text"
|
||||
class="control-btn maximize-btn"
|
||||
title="最大化"
|
||||
@click="handleMaximize"
|
||||
>
|
||||
<i v-if="controlStyle === 'windows'" class="iconfont icon-a-tingzhiwukuang"></i>
|
||||
<div v-else class="traffic-light maximize"></div>
|
||||
</t-button>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<t-button
|
||||
shape="circle"
|
||||
theme="default"
|
||||
variant="text"
|
||||
class="control-btn close-btn"
|
||||
title="关闭"
|
||||
@click="handleClose"
|
||||
>
|
||||
<i v-if="controlStyle === 'windows'" class="iconfont icon-a-quxiaoguanbi"></i>
|
||||
<div v-else class="traffic-light close"></div>
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title-controls {
|
||||
-webkit-app-region: no-drag;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
.control-btn {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
min-width: 2.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
.iconfont {
|
||||
font-size: 1.125rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&:hover .iconfont {
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
margin-right: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.mini-btn {
|
||||
&.active .iconfont {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
.minimize-btn:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.maximize-btn:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background-color: #fee2e2;
|
||||
|
||||
.iconfont {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Windows 风格样式
|
||||
.title-controls.windows {
|
||||
.control-btn {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
// 红绿灯风格样式
|
||||
.title-controls.traffic-light {
|
||||
.control-btn {
|
||||
border-radius: 50%;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
min-width: 2.25rem;
|
||||
}
|
||||
|
||||
.traffic-light {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
|
||||
&.close {
|
||||
background-color: #ff5f57;
|
||||
|
||||
&:hover {
|
||||
background-color: #ff3b30;
|
||||
}
|
||||
}
|
||||
|
||||
&.minimize {
|
||||
background-color: #ffbd2e;
|
||||
|
||||
&:hover {
|
||||
background-color: #ff9500;
|
||||
}
|
||||
}
|
||||
|
||||
&.maximize {
|
||||
background-color: #28ca42;
|
||||
|
||||
&:hover {
|
||||
background-color: #30d158;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.minimize-btn:hover,
|
||||
.maximize-btn:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -8,15 +8,39 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: '/home',
|
||||
name: 'home',
|
||||
redirect: '/home/find',
|
||||
component: () => import('@renderer/views/home/index.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'music',
|
||||
component: () => import('@renderer/views/music/list.vue')
|
||||
redirect: '/home/find'
|
||||
},
|
||||
{
|
||||
path: 'find',
|
||||
name: 'find',
|
||||
component: () => import('@renderer/views/music/find.vue')
|
||||
},
|
||||
{
|
||||
path: 'local',
|
||||
name: 'local',
|
||||
component: () => import('@renderer/views/music/local.vue')
|
||||
},
|
||||
{
|
||||
path: 'recent',
|
||||
name: 'recent',
|
||||
component: () => import('@renderer/views/music/recent.vue')
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
name: 'search',
|
||||
component: () => import('@renderer/views/music/search.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('@renderer/views/settings/index.vue')
|
||||
}
|
||||
]
|
||||
const option: RouterOptions = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LoadingPlugin, NotifyPlugin } from 'tdesign-vue-next'
|
||||
import type { LoadingInstance } from 'tdesign-vue-next'
|
||||
|
||||
import { MusicServiceBase } from './service-base'
|
||||
|
||||
@@ -31,6 +32,170 @@ type GetLyricArgs = {
|
||||
yv?: boolean
|
||||
tv?: boolean
|
||||
}
|
||||
interface Artist {
|
||||
id: number
|
||||
name: string
|
||||
picUrl: string | null
|
||||
alias: string[]
|
||||
albumSize: number
|
||||
picId: number
|
||||
fansGroup: null
|
||||
img1v1Url: string
|
||||
img1v1: number
|
||||
trans: null
|
||||
}
|
||||
|
||||
interface Album {
|
||||
id: number
|
||||
name: string
|
||||
artist: {
|
||||
id: number
|
||||
name: string
|
||||
picUrl: string | null
|
||||
alias: string[]
|
||||
albumSize: number
|
||||
picId: number
|
||||
fansGroup: null
|
||||
img1v1Url: string
|
||||
img1v1: number
|
||||
trans: null
|
||||
}
|
||||
publishTime: number
|
||||
size: number
|
||||
copyrightId: number
|
||||
status: number
|
||||
picId: number
|
||||
alia?: string[]
|
||||
mark: number
|
||||
}
|
||||
|
||||
interface Song {
|
||||
id: number
|
||||
name: string
|
||||
artists: Artist[]
|
||||
album: Album
|
||||
duration: number
|
||||
copyrightId: number
|
||||
status: number
|
||||
alias: string[]
|
||||
rtype: number
|
||||
ftype: number
|
||||
mvid: number
|
||||
fee: number
|
||||
rUrl: null
|
||||
mark: number
|
||||
transNames?: string[]
|
||||
}
|
||||
|
||||
export interface SongResponse {
|
||||
songs: Song[]
|
||||
songCount: number
|
||||
}
|
||||
interface AlbumDetail {
|
||||
name: string
|
||||
id: number
|
||||
type: string
|
||||
size: number
|
||||
picId: number
|
||||
blurPicUrl: string
|
||||
companyId: number
|
||||
pic: number
|
||||
picUrl: string
|
||||
publishTime: number
|
||||
description: string
|
||||
tags: string
|
||||
company: string
|
||||
briefDesc: string
|
||||
artist: {
|
||||
name: string
|
||||
id: number
|
||||
picId: number
|
||||
img1v1Id: number
|
||||
briefDesc: string
|
||||
picUrl: string
|
||||
img1v1Url: string
|
||||
albumSize: number
|
||||
alias: string[]
|
||||
trans: string
|
||||
musicSize: number
|
||||
topicPerson: number
|
||||
}
|
||||
songs: any[]
|
||||
alias: string[]
|
||||
status: number
|
||||
copyrightId: number
|
||||
commentThreadId: string
|
||||
artists: Artist[]
|
||||
subType: string
|
||||
transName: null
|
||||
onSale: boolean
|
||||
mark: number
|
||||
gapless: number
|
||||
dolbyMark: number
|
||||
}
|
||||
interface MusicQuality {
|
||||
name: null
|
||||
id: number
|
||||
size: number
|
||||
extension: string
|
||||
sr: number
|
||||
dfsId: number
|
||||
bitrate: number
|
||||
playTime: number
|
||||
volumeDelta: number
|
||||
}
|
||||
interface SongDetail {
|
||||
name: string
|
||||
id: number
|
||||
position: number
|
||||
alias: string[]
|
||||
status: number
|
||||
fee: number
|
||||
copyrightId: number
|
||||
disc: string
|
||||
no: number
|
||||
artists: Artist[]
|
||||
album: AlbumDetail
|
||||
starred: boolean
|
||||
popularity: number
|
||||
score: number
|
||||
starredNum: number
|
||||
duration: number
|
||||
playedNum: number
|
||||
dayPlays: number
|
||||
hearTime: number
|
||||
sqMusic: MusicQuality
|
||||
hrMusic: null
|
||||
ringtone: null
|
||||
crbt: null
|
||||
audition: null
|
||||
copyFrom: string
|
||||
commentThreadId: string
|
||||
rtUrl: null
|
||||
ftype: number
|
||||
rtUrls: any[]
|
||||
copyright: number
|
||||
transName: null
|
||||
sign: null
|
||||
mark: number
|
||||
originCoverType: number
|
||||
originSongSimpleData: null
|
||||
single: number
|
||||
noCopyrightRcmd: null
|
||||
hMusic: MusicQuality
|
||||
mMusic: MusicQuality
|
||||
lMusic: MusicQuality
|
||||
bMusic: MusicQuality
|
||||
mvid: number
|
||||
mp3Url: null
|
||||
rtype: number
|
||||
rurl: null
|
||||
}
|
||||
export interface SongDetailResponse {
|
||||
songs: SongDetail[]
|
||||
equalizers: Record<string, unknown>
|
||||
code: number
|
||||
}
|
||||
|
||||
// 使用函数重载定义不同的调用方式
|
||||
async function request(
|
||||
@@ -38,13 +203,13 @@ async function request(
|
||||
args: SearchArgs,
|
||||
isLoading?: boolean,
|
||||
showError?: boolean
|
||||
): Promise<any>
|
||||
): Promise<SongResponse>
|
||||
async function request(
|
||||
api: 'getSongDetail',
|
||||
args: GetSongDetailArgs,
|
||||
isLoading?: boolean,
|
||||
showError?: boolean
|
||||
): Promise<any>
|
||||
): Promise<SongDetailResponse['songs']>
|
||||
async function request(
|
||||
api: 'getSongUrl',
|
||||
args: GetSongUrlArgs,
|
||||
@@ -63,8 +228,9 @@ async function request(
|
||||
isLoading = false,
|
||||
showError = true
|
||||
): Promise<any> {
|
||||
let instance: LoadingInstance | null = null
|
||||
if (isLoading) {
|
||||
LoadingPlugin({ fullscreen: true, attach: 'body', preventScrollThrough: true })
|
||||
instance = LoadingPlugin({ fullscreen: true, attach: 'body', preventScrollThrough: true })
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -91,6 +257,8 @@ async function request(
|
||||
|
||||
console.error('请求失败: ', error)
|
||||
throw new Error(error.message)
|
||||
} finally {
|
||||
instance?.hide()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { axiosClient, mobileHeaders, MusicServiceBase } from './service-base'
|
||||
import { axiosClient, MusicServiceBase } from './service-base'
|
||||
import { fieldsSelector } from '@renderer/utils/object'
|
||||
|
||||
const baseUrl: string = 'https://music.163.com'
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive } from 'vue'
|
||||
import { transitionVolume } from '@renderer/utils/volume'
|
||||
import type {
|
||||
AudioEventCallback,
|
||||
AudioEventType,
|
||||
AudioSubscriber,
|
||||
UnsubscribeFunction,
|
||||
ControlAudioState
|
||||
} from '@renderer/types/audio'
|
||||
|
||||
/**
|
||||
* 音频控制状态接口。
|
||||
* @property {HTMLAudioElement | null | undefined} audio - 音频元素实例。
|
||||
* @property {boolean} isPlay - 是否正在播放。
|
||||
* @property {number} currentTime - 当前播放时间(秒)。
|
||||
* @property {number} duration - 音频总时长(秒)。
|
||||
* @property {number} volume - 音量(0-100)。
|
||||
* @property {string} url - 音频URL。
|
||||
*/
|
||||
|
||||
type ControlAudioState = {
|
||||
audio: HTMLAudioElement | null | undefined
|
||||
isPlay: boolean
|
||||
currentTime: number
|
||||
duration: number
|
||||
volume: number
|
||||
url: string
|
||||
}
|
||||
export const ControlAudioStore = defineStore('controlAudio', () => {
|
||||
const Audio = reactive<ControlAudioState>({
|
||||
audio: null,
|
||||
@@ -20,44 +29,170 @@ export const ControlAudioStore = defineStore('controlAudio', () => {
|
||||
url: ''
|
||||
})
|
||||
|
||||
// -------------------------------------------发布订阅逻辑------------------------------------------
|
||||
// 事件订阅者映射表
|
||||
/**
|
||||
* 音频事件订阅与发布逻辑。
|
||||
* @property {Record<AudioEventType, AudioSubscriber[]>} subscribers - 事件订阅者映射表。
|
||||
*/
|
||||
const subscribers = reactive<Record<AudioEventType, AudioSubscriber[]>>({
|
||||
ended: [],
|
||||
seeked: [],
|
||||
timeupdate: [],
|
||||
play: [],
|
||||
pause: [],
|
||||
error: []
|
||||
})
|
||||
// 生成唯一ID
|
||||
const generateId = (): string => {
|
||||
return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
// 订阅事件
|
||||
/**
|
||||
* 订阅音频事件。
|
||||
* @param {AudioEventType} eventType - 事件类型。
|
||||
* @param {AudioEventCallback} callback - 事件回调函数。
|
||||
* @returns {UnsubscribeFunction} 取消订阅的函数。
|
||||
*/
|
||||
const subscribe = (
|
||||
eventType: AudioEventType,
|
||||
callback: AudioEventCallback
|
||||
): UnsubscribeFunction => {
|
||||
const id = generateId()
|
||||
const subscriber: AudioSubscriber = { id, callback }
|
||||
|
||||
subscribers[eventType].push(subscriber)
|
||||
|
||||
// 返回取消订阅函数
|
||||
return () => {
|
||||
const index = subscribers[eventType].findIndex((sub) => sub.id === id)
|
||||
if (index > -1) {
|
||||
subscribers[eventType].splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发布事件
|
||||
const publish = (eventType: AudioEventType): void => {
|
||||
subscribers[eventType].forEach((subscriber) => {
|
||||
try {
|
||||
subscriber.callback()
|
||||
} catch (error) {
|
||||
console.error(`音频事件回调执行错误 [${eventType}]:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 清空所有订阅者
|
||||
const clearAllSubscribers = (): void => {
|
||||
Object.keys(subscribers).forEach((eventType) => {
|
||||
subscribers[eventType as AudioEventType] = []
|
||||
})
|
||||
}
|
||||
|
||||
// 清空特定事件的所有订阅者
|
||||
const clearEventSubscribers = (eventType: AudioEventType): void => {
|
||||
subscribers[eventType] = []
|
||||
}
|
||||
// End-------------------------------------------事件订阅者映射表逻辑------------------------------------------
|
||||
|
||||
// 初始化
|
||||
const init = (el: ControlAudioState['audio']) => {
|
||||
console.log(el, 'init2')
|
||||
console.log(el, '全局音频挂载初始化success')
|
||||
Audio.audio = el
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前播放时间。
|
||||
* @param {number} time - 播放时间(秒)。
|
||||
* @throws {Error} 如果时间不是数字类型。
|
||||
*/
|
||||
const setCurrentTime = (time: number) => {
|
||||
if (typeof time === 'number') {
|
||||
Audio.currentTime = time
|
||||
return
|
||||
}
|
||||
throw new Error('the time is not number')
|
||||
throw new Error('时间必须是数字类型')
|
||||
}
|
||||
const start = () => {
|
||||
/**
|
||||
* 设置音量。
|
||||
* @param {number} volume - 音量(0-100)。
|
||||
* @param {boolean} transition - 是否使用渐变。
|
||||
* @throws {Error} 如果音量不在0-100之间。
|
||||
*/
|
||||
const setVolume = (volume: number, transition: boolean = false) => {
|
||||
if (typeof volume === 'number' && volume >= 0 && volume <= 100) {
|
||||
if (Audio.audio) {
|
||||
if (Audio.isPlay && transition) {
|
||||
transitionVolume(Audio.audio, volume / 100, Audio.volume <= volume)
|
||||
} else {
|
||||
Audio.audio.volume = volume / 100
|
||||
}
|
||||
Audio.volume = volume
|
||||
}
|
||||
} else {
|
||||
throw new Error('音量必须是0-100之间的数字')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置音频URL。
|
||||
* @param {string} url - 音频URL。
|
||||
* @throws {Error} 如果URL为空或无效。
|
||||
*/
|
||||
const setUrl = (url: string) => {
|
||||
if (typeof url !== 'string' || url.trim() === '') {
|
||||
throw new Error('音频URL不能为空')
|
||||
}
|
||||
|
||||
// 停止当前播放
|
||||
if (Audio.isPlay) {
|
||||
stop()
|
||||
}
|
||||
|
||||
Audio.url = url.trim()
|
||||
console.log('音频URL已设置:', Audio.url)
|
||||
}
|
||||
const start = async () => {
|
||||
const volume = Audio.volume
|
||||
console.log(1)
|
||||
console.log('开始播放音频')
|
||||
if (Audio.audio) {
|
||||
console.log(Audio)
|
||||
Audio.audio.volume = 0
|
||||
Audio.audio.play()
|
||||
Audio.isPlay = true
|
||||
return transitionVolume(Audio.audio, volume / 100, true)
|
||||
try {
|
||||
await Audio.audio.play()
|
||||
Audio.isPlay = true
|
||||
return transitionVolume(Audio.audio, volume / 100, true)
|
||||
} catch (error) {
|
||||
Audio.audio.volume = volume
|
||||
console.error('音频播放失败:', error)
|
||||
Audio.isPlay = false
|
||||
throw new Error('音频播放失败,请检查音频URL是否有效')
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
const stop = () => {
|
||||
if (Audio.audio) {
|
||||
Audio.isPlay = false
|
||||
return transitionVolume(Audio.audio, Audio.volume / 100, false).then(() => {
|
||||
Audio.isPlay = false
|
||||
Audio.audio?.pause()
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
// const
|
||||
|
||||
return {
|
||||
Audio,
|
||||
init,
|
||||
setCurrentTime,
|
||||
setVolume,
|
||||
setUrl,
|
||||
start,
|
||||
stop
|
||||
stop,
|
||||
subscribe,
|
||||
publish,
|
||||
clearAllSubscribers,
|
||||
clearEventSubscribers
|
||||
}
|
||||
})
|
||||
|
||||
15
src/renderer/src/store/search.ts
Normal file
15
src/renderer/src/store/search.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const searchValue = defineStore("search", {
|
||||
state: () => ({
|
||||
value: "",
|
||||
}),
|
||||
getters: {
|
||||
getValue: (state) => state.value,
|
||||
},
|
||||
actions: {
|
||||
setValue(value: string) {
|
||||
this.value = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
31
src/renderer/src/types/audio.ts
Normal file
31
src/renderer/src/types/audio.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// 音频事件相关类型定义
|
||||
|
||||
// 事件回调函数类型定义
|
||||
export type AudioEventCallback = () => void
|
||||
|
||||
// 音频事件类型
|
||||
export type AudioEventType = 'ended' | 'seeked' | 'timeupdate' | 'play' | 'pause' | 'error'
|
||||
|
||||
// 订阅者接口
|
||||
export interface AudioSubscriber {
|
||||
id: string
|
||||
callback: AudioEventCallback
|
||||
}
|
||||
|
||||
// 取消订阅函数类型
|
||||
export type UnsubscribeFunction = () => void
|
||||
|
||||
// 音频订阅方法类型
|
||||
export type AudioSubscribeMethod = (
|
||||
eventType: AudioEventType,
|
||||
callback: AudioEventCallback
|
||||
) => UnsubscribeFunction
|
||||
|
||||
export type ControlAudioState = {
|
||||
audio: HTMLAudioElement | null | undefined
|
||||
isPlay: boolean
|
||||
currentTime: number
|
||||
duration: number
|
||||
volume: number
|
||||
url: string
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
let timer: any
|
||||
import type { Ref } from 'vue'
|
||||
// 当过渡完成时会返回Promise
|
||||
|
||||
export function transitionVolume(
|
||||
audio: HTMLAudioElement,
|
||||
@@ -9,7 +7,7 @@ export function transitionVolume(
|
||||
lengthen: boolean = false
|
||||
): Promise<undefined> {
|
||||
clearInterval(timer)
|
||||
const playVolume = lengthen ? 40 : 15
|
||||
const playVolume = lengthen ? 40 : 20
|
||||
const pauseVolume = lengthen ? 20 : 10
|
||||
return new Promise((resolve) => {
|
||||
if (target) {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import PlayMusic from '@renderer/components/Play/PlayMusic.vue'
|
||||
// import PlayMusic from '@renderer/components/Play/PlayMusic.vue'
|
||||
import TitleBarControls from '@renderer/components/TitleBarControls.vue'
|
||||
import { ref } from 'vue'
|
||||
import { SearchIcon } from 'tdesign-icons-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { searchValue } from '@renderer/store/search'
|
||||
|
||||
interface MenuItem {
|
||||
name: string
|
||||
icon: string
|
||||
path: string
|
||||
}
|
||||
|
||||
const osType = ref(1)
|
||||
const menuList: MenuItem[] = [
|
||||
{
|
||||
name: '发现',
|
||||
@@ -25,8 +30,48 @@ const menuList: MenuItem[] = [
|
||||
}
|
||||
]
|
||||
const menuActive = ref(0)
|
||||
const router = useRouter()
|
||||
|
||||
const handleClick = (index: number): void => {
|
||||
menuActive.value = index
|
||||
router.push(menuList[index].path)
|
||||
}
|
||||
|
||||
// 导航历史前进后退功能
|
||||
const goBack = (): void => {
|
||||
router.go(-1)
|
||||
}
|
||||
|
||||
const goForward = (): void => {
|
||||
router.go(1)
|
||||
}
|
||||
|
||||
// 搜索相关
|
||||
const keyword = ref('')
|
||||
|
||||
// 搜索类型:1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单
|
||||
// const searchType = ref(1)
|
||||
|
||||
// 处理搜索事件
|
||||
const handleSearch = async () => {
|
||||
if (!keyword.value.trim()) return
|
||||
const useSearch = searchValue()
|
||||
// 重新设置搜索关键字
|
||||
try {
|
||||
// 跳转到搜索结果页面,并传递搜索结果和关键词
|
||||
useSearch.setValue(keyword.value.trim()) // 设置搜索关键字
|
||||
router.push({
|
||||
path: '/home/search'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error)
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理按键事件,按下回车键时触发搜索
|
||||
const handleKeyDown = () => {
|
||||
handleSearch()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -40,8 +85,10 @@ const handleClick = (index: number): void => {
|
||||
<i class="iconfont icon-music"></i>
|
||||
</div>
|
||||
<p class="app-title">
|
||||
Such Music
|
||||
<span>PC</span>
|
||||
<span style="
|
||||
color:#000;
|
||||
font-weight: 800;
|
||||
">Ceru Music</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -67,10 +114,10 @@ const handleClick = (index: number): void => {
|
||||
<div class="content">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<t-button shape="circle" theme="default" class="nav-btn">
|
||||
<t-button shape="circle" theme="default" class="nav-btn" @click="goBack">
|
||||
<i class="iconfont icon-xiangzuo"></i>
|
||||
</t-button>
|
||||
<t-button shape="circle" theme="default" class="nav-btn">
|
||||
<t-button shape="circle" theme="default" class="nav-btn" @click="goForward">
|
||||
<i class="iconfont icon-xiangyou"></i>
|
||||
</t-button>
|
||||
|
||||
@@ -82,15 +129,36 @@ const handleClick = (index: number): void => {
|
||||
"
|
||||
></use>
|
||||
</svg>
|
||||
<t-input placeholder="搜索音乐、歌手" />
|
||||
<t-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索音乐、歌手"
|
||||
:loading="isSearching"
|
||||
style="width: 100%"
|
||||
@enter="handleKeyDown"
|
||||
>
|
||||
<template #suffix>
|
||||
<t-button
|
||||
theme="primary"
|
||||
variant="text"
|
||||
shape="circle"
|
||||
:disabled="isSearching"
|
||||
style="display: flex; align-items: center; justify-content: center"
|
||||
@click="handleSearch"
|
||||
>
|
||||
<SearchIcon style="font-size: 16px; color: #000" />
|
||||
</t-button>
|
||||
</template>
|
||||
</t-input>
|
||||
</div>
|
||||
<t-button shape="circle" theme="default" class="settings-btn">
|
||||
<i class="iconfont icon-shezhi"></i>
|
||||
</t-button>
|
||||
<TitleBarControls
|
||||
:control-style="osType === 0 ? 'windows' : 'traffic-light'"
|
||||
></TitleBarControls>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<router-view />
|
||||
<div class="mainContent">
|
||||
<router-view v-if="true" />
|
||||
</div>
|
||||
</div>
|
||||
</t-content>
|
||||
</t-layout>
|
||||
@@ -118,6 +186,7 @@ const handleClick = (index: number): void => {
|
||||
padding: 1rem;
|
||||
|
||||
.logo-section {
|
||||
-webkit-app-region: drag;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -193,19 +262,22 @@ const handleClick = (index: number): void => {
|
||||
padding: 0;
|
||||
background: #f6f6f6;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.header {
|
||||
-webkit-app-region: drag;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
|
||||
.nav-btn {
|
||||
-webkit-app-region: no-drag;
|
||||
margin-right: 0.5rem;
|
||||
|
||||
&:last-of-type {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 1rem;
|
||||
color: #3d4043;
|
||||
@@ -220,8 +292,9 @@ const handleClick = (index: number): void => {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
|
||||
justify-content: space-between;
|
||||
.search-input {
|
||||
-webkit-app-region: no-drag;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
@@ -250,5 +323,35 @@ const handleClick = (index: number): void => {
|
||||
}
|
||||
}
|
||||
}
|
||||
.mainContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
height: 0; /* 确保flex子元素能够正确计算高度 */
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.375rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 0.1875rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 0.1875rem;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Firefox 滚动条样式 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 #f1f5f9;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
302
src/renderer/src/views/music/find.vue
Normal file
302
src/renderer/src/views/music/find.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 推荐歌单数据
|
||||
const recommendPlaylists = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '热门流行',
|
||||
description: '最新最热的流行音乐',
|
||||
cover: 'https://via.placeholder.com/200x200/f97316/ffffff?text=热门流行',
|
||||
playCount: '1.2万'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '经典老歌',
|
||||
description: '怀旧经典,永恒旋律',
|
||||
cover: 'https://via.placeholder.com/200x200/3b82f6/ffffff?text=经典老歌',
|
||||
playCount: '8.5万'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '轻音乐',
|
||||
description: '放松心情的轻柔音乐',
|
||||
cover: 'https://via.placeholder.com/200x200/10b981/ffffff?text=轻音乐',
|
||||
playCount: '3.7万'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '摇滚精选',
|
||||
description: '激情澎湃的摇滚乐',
|
||||
cover: 'https://via.placeholder.com/200x200/ef4444/ffffff?text=摇滚精选',
|
||||
playCount: '2.1万'
|
||||
}
|
||||
])
|
||||
|
||||
// 热门歌曲数据
|
||||
const hotSongs = ref([
|
||||
{ id: 1, title: '夜曲', artist: '周杰伦', album: '十一月的萧邦', duration: '3:37' },
|
||||
{ id: 2, title: '青花瓷', artist: '周杰伦', album: '我很忙', duration: '3:58' },
|
||||
{ id: 3, title: '稻香', artist: '周杰伦', album: '魔杰座', duration: '3:43' },
|
||||
{ id: 4, title: '告白气球', artist: '周杰伦', album: '周杰伦的床边故事', duration: '3:34' },
|
||||
{ id: 5, title: '七里香', artist: '周杰伦', album: '七里香', duration: '4:05' }
|
||||
])
|
||||
|
||||
const playPlaylist = (playlist: any): void => {
|
||||
console.log('播放歌单:', playlist.title)
|
||||
}
|
||||
|
||||
const playSong = (song: any): void => {
|
||||
console.log('播放歌曲:', song.title)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="find-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h2>发现音乐</h2>
|
||||
<p>探索最新最热的音乐内容</p>
|
||||
</div>
|
||||
|
||||
<!-- 推荐歌单 -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">推荐歌单</h3>
|
||||
<div class="playlist-grid">
|
||||
<div
|
||||
v-for="playlist in recommendPlaylists"
|
||||
:key="playlist.id"
|
||||
class="playlist-card"
|
||||
@click="playPlaylist(playlist)"
|
||||
>
|
||||
<div class="playlist-cover">
|
||||
<img :src="playlist.cover" :alt="playlist.title" />
|
||||
<div class="play-overlay">
|
||||
<t-button shape="circle" theme="primary" size="large">
|
||||
<i class="iconfont icon-a-tingzhiwukuang"></i>
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="playlist-info">
|
||||
<h4 class="playlist-title">{{ playlist.title }}</h4>
|
||||
<p class="playlist-desc">{{ playlist.description }}</p>
|
||||
<span class="play-count">
|
||||
<i class="iconfont icon-a-tingzhiwukuang"></i>
|
||||
{{ playlist.playCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热门歌曲 -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">热门歌曲</h3>
|
||||
<div class="song-list">
|
||||
<div
|
||||
v-for="(song, index) in hotSongs"
|
||||
:key="song.id"
|
||||
class="song-item"
|
||||
@click="playSong(song)"
|
||||
>
|
||||
<div class="song-index">{{ index + 1 }}</div>
|
||||
<div class="song-info">
|
||||
<div class="song-title">{{ song.title }}</div>
|
||||
<div class="song-artist">{{ song.artist }} - {{ song.album }}</div>
|
||||
</div>
|
||||
<div class="song-duration">{{ song.duration }}</div>
|
||||
<div class="song-actions">
|
||||
<t-button shape="circle" theme="default" variant="text" size="small">
|
||||
<i class="iconfont icon-gengduo"></i>
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.find-container {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h2 {
|
||||
color: #111827;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 3rem;
|
||||
|
||||
.section-title {
|
||||
color: #111827;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.playlist-card {
|
||||
background: #fff;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-cover {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-info {
|
||||
padding: 1rem;
|
||||
|
||||
.playlist-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.playlist-desc {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.5rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.play-count {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
.iconfont {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.song-list {
|
||||
background: #fff;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.song-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.song-index {
|
||||
width: 2rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
|
||||
.song-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.song-duration {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.song-actions {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover .song-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
368
src/renderer/src/views/music/local.vue
Normal file
368
src/renderer/src/views/music/local.vue
Normal file
@@ -0,0 +1,368 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 本地音乐数据
|
||||
const localSongs = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '夜曲',
|
||||
artist: '周杰伦',
|
||||
album: '十一月的萧邦',
|
||||
duration: '3:37',
|
||||
path: '/music/夜曲.mp3',
|
||||
size: '8.5 MB',
|
||||
format: 'MP3',
|
||||
bitrate: '320 kbps'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '青花瓷',
|
||||
artist: '周杰伦',
|
||||
album: '我很忙',
|
||||
duration: '3:58',
|
||||
path: '/music/青花瓷.mp3',
|
||||
size: '9.2 MB',
|
||||
format: 'MP3',
|
||||
bitrate: '320 kbps'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '稻香',
|
||||
artist: '周杰伦',
|
||||
album: '魔杰座',
|
||||
duration: '3:43',
|
||||
path: '/music/稻香.mp3',
|
||||
size: '8.8 MB',
|
||||
format: 'MP3',
|
||||
bitrate: '320 kbps'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '告白气球',
|
||||
artist: '周杰伦',
|
||||
album: '周杰伦的床边故事',
|
||||
duration: '3:34',
|
||||
path: '/music/告白气球.mp3',
|
||||
size: '8.4 MB',
|
||||
format: 'MP3',
|
||||
bitrate: '320 kbps'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '七里香',
|
||||
artist: '周杰伦',
|
||||
album: '七里香',
|
||||
duration: '4:05',
|
||||
path: '/music/七里香.mp3',
|
||||
size: '9.6 MB',
|
||||
format: 'MP3',
|
||||
bitrate: '320 kbps'
|
||||
}
|
||||
])
|
||||
|
||||
// 统计信息
|
||||
const stats = ref({
|
||||
totalSongs: localSongs.value.length,
|
||||
totalDuration: '19:17',
|
||||
totalSize: '44.5 MB'
|
||||
})
|
||||
|
||||
const playSong = (song: any): void => {
|
||||
console.log('播放本地歌曲:', song.title)
|
||||
}
|
||||
|
||||
const importMusic = (): void => {
|
||||
console.log('导入音乐文件')
|
||||
// 这里可以调用 Electron 的文件选择对话框
|
||||
}
|
||||
|
||||
const openMusicFolder = (): void => {
|
||||
console.log('打开音乐文件夹')
|
||||
// 这里可以调用 Electron 的文件夹打开功能
|
||||
}
|
||||
|
||||
const deleteSong = (song: any): void => {
|
||||
console.log('删除歌曲:', song.title)
|
||||
// 这里可以添加删除确认和实际删除逻辑
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="local-container">
|
||||
<!-- 页面标题和操作 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>本地音乐</h2>
|
||||
<div class="stats">
|
||||
<span>{{ stats.totalSongs }} 首歌曲</span>
|
||||
<span>总时长 {{ stats.totalDuration }}</span>
|
||||
<span>总大小 {{ stats.totalSize }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<t-button theme="default" @click="openMusicFolder">
|
||||
<i class="iconfont icon-shouye"></i>
|
||||
打开文件夹
|
||||
</t-button>
|
||||
<t-button theme="primary" @click="importMusic">
|
||||
<i class="iconfont icon-zengjia"></i>
|
||||
导入音乐
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 音乐列表 -->
|
||||
<div class="music-list">
|
||||
<div class="list-header">
|
||||
<div class="header-item index">#</div>
|
||||
<div class="header-item title">标题</div>
|
||||
<div class="header-item artist">艺术家</div>
|
||||
<div class="header-item album">专辑</div>
|
||||
<div class="header-item duration">时长</div>
|
||||
<div class="header-item size">大小</div>
|
||||
<div class="header-item format">格式</div>
|
||||
<div class="header-item actions">操作</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body">
|
||||
<div
|
||||
v-for="(song, index) in localSongs"
|
||||
:key="song.id"
|
||||
class="song-row"
|
||||
@dblclick="playSong(song)"
|
||||
>
|
||||
<div class="row-item index">{{ index + 1 }}</div>
|
||||
<div class="row-item title">
|
||||
<div class="song-title">{{ song.title }}</div>
|
||||
</div>
|
||||
<div class="row-item artist">{{ song.artist }}</div>
|
||||
<div class="row-item album">{{ song.album }}</div>
|
||||
<div class="row-item duration">{{ song.duration }}</div>
|
||||
<div class="row-item size">{{ song.size }}</div>
|
||||
<div class="row-item format">
|
||||
<span class="format-badge">{{ song.format }}</span>
|
||||
<span class="bitrate">{{ song.bitrate }}</span>
|
||||
</div>
|
||||
<div class="row-item actions">
|
||||
<t-button
|
||||
shape="circle"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="播放"
|
||||
@click="playSong(song)"
|
||||
>
|
||||
<i class="iconfont icon-a-tingzhiwukuang"></i>
|
||||
</t-button>
|
||||
<t-button shape="circle" theme="default" variant="text" size="small" title="更多">
|
||||
<i class="iconfont icon-gengduo"></i>
|
||||
</t-button>
|
||||
<t-button
|
||||
shape="circle"
|
||||
theme="danger"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="删除"
|
||||
@click="deleteSong(song)"
|
||||
>
|
||||
<i class="iconfont icon-shanchu"></i>
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="localSongs.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="iconfont icon-music"></i>
|
||||
</div>
|
||||
<h3>暂无本地音乐</h3>
|
||||
<p>点击"导入音乐"按钮添加您的音乐文件</p>
|
||||
<t-button theme="primary" @click="importMusic">
|
||||
<i class="iconfont icon-zengjia"></i>
|
||||
导入音乐
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.local-container {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.header-left {
|
||||
h2 {
|
||||
color: #111827;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
|
||||
span {
|
||||
&:not(:last-child)::after {
|
||||
content: '•';
|
||||
margin-left: 1rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.music-list {
|
||||
background: #fff;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 150px 150px 80px 80px 120px 120px;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
|
||||
.header-item {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
}
|
||||
|
||||
.list-body {
|
||||
.song-row {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 150px 150px 80px 80px 120px 120px;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f9fafb;
|
||||
|
||||
.actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.row-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
|
||||
&.index {
|
||||
justify-content: center;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.title {
|
||||
.song-title {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
&.artist,
|
||||
&.album {
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.duration,
|
||||
&.size {
|
||||
color: #6b7280;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&.format {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.125rem;
|
||||
|
||||
.format-badge {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bitrate {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
&.actions {
|
||||
gap: 0.25rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
.iconfont {
|
||||
font-size: 4rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: #111827;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
445
src/renderer/src/views/music/recent.vue
Normal file
445
src/renderer/src/views/music/recent.vue
Normal file
@@ -0,0 +1,445 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 最近播放数据
|
||||
const recentSongs = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '夜曲',
|
||||
artist: '周杰伦',
|
||||
album: '十一月的萧邦',
|
||||
duration: '3:37',
|
||||
playTime: '2024-01-15 14:30',
|
||||
playCount: 15
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '青花瓷',
|
||||
artist: '周杰伦',
|
||||
album: '我很忙',
|
||||
duration: '3:58',
|
||||
playTime: '2024-01-15 13:45',
|
||||
playCount: 8
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '稻香',
|
||||
artist: '周杰伦',
|
||||
album: '魔杰座',
|
||||
duration: '3:43',
|
||||
playTime: '2024-01-15 12:20',
|
||||
playCount: 12
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '告白气球',
|
||||
artist: '周杰伦',
|
||||
album: '周杰伦的床边故事',
|
||||
duration: '3:34',
|
||||
playTime: '2024-01-14 20:15',
|
||||
playCount: 6
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '七里香',
|
||||
artist: '周杰伦',
|
||||
album: '七里香',
|
||||
duration: '4:05',
|
||||
playTime: '2024-01-14 19:30',
|
||||
playCount: 20
|
||||
}
|
||||
])
|
||||
|
||||
// 最近播放的歌单
|
||||
const recentPlaylists = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '我的收藏',
|
||||
description: '收藏的精选歌曲',
|
||||
cover: 'https://via.placeholder.com/120x120/f97316/ffffff?text=收藏',
|
||||
songCount: 25,
|
||||
playTime: '2024-01-15 14:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '工作音乐',
|
||||
description: '适合工作时听的音乐',
|
||||
cover: 'https://via.placeholder.com/120x120/3b82f6/ffffff?text=工作',
|
||||
songCount: 18,
|
||||
playTime: '2024-01-15 09:30'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '放松时光',
|
||||
description: '轻松愉快的音乐',
|
||||
cover: 'https://via.placeholder.com/120x120/10b981/ffffff?text=放松',
|
||||
songCount: 32,
|
||||
playTime: '2024-01-14 21:00'
|
||||
}
|
||||
])
|
||||
|
||||
const playSong = (song: any): void => {
|
||||
console.log('播放歌曲:', song.title)
|
||||
}
|
||||
|
||||
const playPlaylist = (playlist: any): void => {
|
||||
console.log('播放歌单:', playlist.title)
|
||||
}
|
||||
|
||||
const clearHistory = (): void => {
|
||||
console.log('清空播放历史')
|
||||
// 这里可以添加确认对话框和清空逻辑
|
||||
}
|
||||
|
||||
const formatPlayTime = (timeStr: string): string => {
|
||||
const playTime = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - playTime.getTime()
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffHours < 1) {
|
||||
return '刚刚'
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours} 小时前`
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} 天前`
|
||||
} else {
|
||||
return playTime.toLocaleDateString('zh-CN')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="recent-container">
|
||||
<!-- 页面标题和操作 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>最近播放</h2>
|
||||
<p>您最近听过的音乐和歌单</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<t-button theme="default" variant="outline" @click="clearHistory">
|
||||
<i class="iconfont icon-shanchu"></i>
|
||||
清空历史
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近播放的歌单 -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">最近播放的歌单</h3>
|
||||
<div class="playlist-list">
|
||||
<div
|
||||
v-for="playlist in recentPlaylists"
|
||||
:key="playlist.id"
|
||||
class="playlist-item"
|
||||
@click="playPlaylist(playlist)"
|
||||
>
|
||||
<div class="playlist-cover">
|
||||
<img :src="playlist.cover" :alt="playlist.title" />
|
||||
<div class="play-overlay">
|
||||
<i class="iconfont icon-a-tingzhiwukuang"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="playlist-info">
|
||||
<h4 class="playlist-title">{{ playlist.title }}</h4>
|
||||
<p class="playlist-desc">{{ playlist.description }}</p>
|
||||
<div class="playlist-meta">
|
||||
<span>{{ playlist.songCount }} 首歌曲</span>
|
||||
<span>{{ formatPlayTime(playlist.playTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近播放的歌曲 -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">最近播放的歌曲</h3>
|
||||
<div class="song-list">
|
||||
<div
|
||||
v-for="(song, index) in recentSongs"
|
||||
:key="song.id"
|
||||
class="song-item"
|
||||
@click="playSong(song)"
|
||||
>
|
||||
<div class="song-index">{{ index + 1 }}</div>
|
||||
<div class="song-info">
|
||||
<div class="song-title">{{ song.title }}</div>
|
||||
<div class="song-artist">{{ song.artist }} - {{ song.album }}</div>
|
||||
</div>
|
||||
<div class="song-stats">
|
||||
<div class="play-count">播放 {{ song.playCount }} 次</div>
|
||||
<div class="play-time">{{ formatPlayTime(song.playTime) }}</div>
|
||||
</div>
|
||||
<div class="song-duration">{{ song.duration }}</div>
|
||||
<div class="song-actions">
|
||||
<t-button
|
||||
shape="circle"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="播放"
|
||||
@click.stop="playSong(song)"
|
||||
>
|
||||
<i class="iconfont icon-a-tingzhiwukuang"></i>
|
||||
</t-button>
|
||||
<t-button shape="circle" theme="default" variant="text" size="small" title="更多">
|
||||
<i class="iconfont icon-gengduo"></i>
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="recentSongs.length === 0 && recentPlaylists.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="iconfont icon-shijian"></i>
|
||||
</div>
|
||||
<h3>暂无播放历史</h3>
|
||||
<p>开始播放音乐后,您的播放记录将显示在这里</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.recent-container {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.header-left {
|
||||
h2 {
|
||||
color: #111827;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 3rem;
|
||||
|
||||
.section-title {
|
||||
color: #111827;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.playlist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-cover {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
margin-right: 1rem;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
.iconfont {
|
||||
font-size: 1.5rem;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-info {
|
||||
flex: 1;
|
||||
|
||||
.playlist-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.playlist-desc {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.playlist-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
|
||||
span {
|
||||
&:not(:last-child)::after {
|
||||
content: '•';
|
||||
margin-left: 1rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.song-list {
|
||||
background: #fff;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.song-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f9fafb;
|
||||
|
||||
.song-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.song-index {
|
||||
width: 2rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
|
||||
.song-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.song-stats {
|
||||
margin-right: 2rem;
|
||||
text-align: right;
|
||||
|
||||
.play-count {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.play-time {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.song-duration {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin-right: 1rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.song-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
.iconfont {
|
||||
font-size: 4rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: #111827;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
650
src/renderer/src/views/music/search.vue
Normal file
650
src/renderer/src/views/music/search.vue
Normal file
@@ -0,0 +1,650 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick, onUnmounted, watch } from 'vue'
|
||||
import request from '@renderer/services/music'
|
||||
import { PlayIcon, HeartIcon, DownloadIcon, MoreIcon } from 'tdesign-icons-vue-next'
|
||||
import { searchValue } from '@renderer/store/search'
|
||||
|
||||
const keyword = ref('')
|
||||
const searchResults = ref<any>([])
|
||||
const hoveredSong = ref<any>(null)
|
||||
const loading = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const currentOffset = ref(0)
|
||||
const pageSize = 50
|
||||
|
||||
// 虚拟滚动配置
|
||||
const virtualScrollConfig = ref({
|
||||
containerHeight: 0, // 动态计算容器高度
|
||||
itemHeight: 64, // 每个项目的高度
|
||||
buffer: 5 // 缓冲区项目数量
|
||||
})
|
||||
|
||||
// 虚拟滚动状态
|
||||
const scrollContainer = ref<HTMLElement>()
|
||||
const scrollTop = ref(0)
|
||||
const visibleStartIndex = ref(0)
|
||||
const visibleEndIndex = ref(0)
|
||||
const visibleItems = ref<any[]>([])
|
||||
|
||||
// 计算容器高度
|
||||
const calculateContainerHeight = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const headerHeight = 120 // 搜索标题区域高度
|
||||
const listHeaderHeight = 40 // 表头高度
|
||||
const padding = 80 // 容器内边距
|
||||
const availableHeight = window.innerHeight - headerHeight - listHeaderHeight - padding
|
||||
virtualScrollConfig.value.containerHeight = Math.max(400, availableHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算虚拟滚动的可见项目
|
||||
const updateVisibleItems = () => {
|
||||
const { containerHeight, itemHeight, buffer } = virtualScrollConfig.value
|
||||
const totalItems = searchResults.value.length
|
||||
|
||||
if (totalItems === 0) {
|
||||
visibleItems.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const visibleCount = Math.ceil(containerHeight / itemHeight)
|
||||
const startIndex = Math.floor(scrollTop.value / itemHeight)
|
||||
const endIndex = Math.min(startIndex + visibleCount + buffer * 2, totalItems)
|
||||
|
||||
visibleStartIndex.value = Math.max(0, startIndex - buffer)
|
||||
visibleEndIndex.value = endIndex
|
||||
|
||||
visibleItems.value = searchResults.value.slice(visibleStartIndex.value, visibleEndIndex.value)
|
||||
}
|
||||
|
||||
// 处理滚动事件
|
||||
const handleScroll = (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
scrollTop.value = target.scrollTop
|
||||
updateVisibleItems()
|
||||
|
||||
// 检查是否需要加载更多
|
||||
const { scrollTop: currentScrollTop, scrollHeight, clientHeight } = target
|
||||
if (scrollHeight - currentScrollTop - clientHeight < 100) {
|
||||
onScrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
// 计算总高度
|
||||
const totalHeight = computed(
|
||||
() => searchResults.value.length * virtualScrollConfig.value.itemHeight
|
||||
)
|
||||
|
||||
// 计算偏移量
|
||||
const offsetY = computed(() => visibleStartIndex.value * virtualScrollConfig.value.itemHeight)
|
||||
const search = searchValue()
|
||||
// 从路由参数中获取搜索关键词和初始结果
|
||||
|
||||
onMounted(async () => {
|
||||
// 计算容器高度
|
||||
watch(search,async () => {
|
||||
keyword.value = search.getValue
|
||||
await performSearch(true)
|
||||
// 确保初始渲染显示内容
|
||||
await nextTick()
|
||||
updateVisibleItems()
|
||||
},{ immediate: true })
|
||||
calculateContainerHeight()
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', calculateContainerHeight)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (scrollContainer.value) {
|
||||
scrollContainer.value.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
window.removeEventListener('resize', calculateContainerHeight)
|
||||
})
|
||||
|
||||
// 执行搜索
|
||||
const performSearch = async (reset = false) => {
|
||||
if (loading.value || !keyword.value.trim()) return
|
||||
|
||||
if (reset) {
|
||||
currentOffset.value = 0
|
||||
searchResults.value = []
|
||||
hasMore.value = true
|
||||
}
|
||||
|
||||
if (!hasMore.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result: Record<string, any> = await request('search', {
|
||||
type: 1,
|
||||
keyword: keyword.value,
|
||||
offset: currentOffset.value,
|
||||
limit: pageSize
|
||||
})
|
||||
|
||||
// 获取歌曲详情
|
||||
if (result.songs && result.songs.length > 0) {
|
||||
const songIds = result.songs.map((song) => song.id.toString())
|
||||
const detailResult = await request('getSongDetail', {
|
||||
ids: songIds
|
||||
})
|
||||
if (detailResult && detailResult.length > 0) {
|
||||
for (let i = 0; i < result.songs.length; i++) {
|
||||
result.songs[i].detail = detailResult.find((song) => song.id === result.songs[i].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
searchResults.value = result.songs || []
|
||||
} else {
|
||||
searchResults.value = [...searchResults.value, ...(result.songs || [])]
|
||||
}
|
||||
|
||||
currentOffset.value += pageSize
|
||||
hasMore.value = (result.songs?.length || 0) >= pageSize
|
||||
|
||||
// 更新虚拟滚动
|
||||
await nextTick()
|
||||
updateVisibleItems()
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 虚拟滚动触底加载更多
|
||||
const onScrollToBottom = async () => {
|
||||
if (!loading.value && hasMore.value) {
|
||||
await performSearch(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间显示
|
||||
const formatDuration = (duration: number): string => {
|
||||
const minutes = Math.floor(duration / 60000)
|
||||
const seconds = Math.floor((duration % 60000) / 1000)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 计算是否有搜索结果
|
||||
const hasResults = computed(() => searchResults.value && searchResults.value.length > 0)
|
||||
|
||||
// 播放歌曲
|
||||
const playSong = (song: any): void => {
|
||||
console.log('播放歌曲:', song.name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-container">
|
||||
<!-- 搜索结果标题 -->
|
||||
<div class="search-header">
|
||||
<h2 class="search-title">
|
||||
搜索"<span class="keyword">{{ keyword }}</span
|
||||
>"
|
||||
</h2>
|
||||
<div v-if="hasResults" class="result-info">找到 {{ searchResults.length }} 首单曲</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<div v-if="hasResults" class="song-list-wrapper">
|
||||
<!-- 表头 -->
|
||||
<div class="list-header">
|
||||
<div class="col-index"></div>
|
||||
<div class="col-title">标题</div>
|
||||
<div class="col-album">专辑</div>
|
||||
<div class="col-like">喜欢</div>
|
||||
<div class="col-duration">时长</div>
|
||||
</div>
|
||||
|
||||
<!-- 虚拟滚动列表 -->
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
class="virtual-scroll-container"
|
||||
:style="{ height: virtualScrollConfig.containerHeight + 'px' }"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div class="virtual-scroll-spacer" :style="{ height: totalHeight + 'px' }">
|
||||
<div class="virtual-scroll-content" :style="{ transform: `translateY(${offsetY}px)` }">
|
||||
<div
|
||||
v-for="(song, index) in visibleItems"
|
||||
:key="song.id"
|
||||
class="song-item"
|
||||
:class="{ 'is-playing': false, 'is-hovered': hoveredSong === song.id }"
|
||||
@mouseenter="hoveredSong = song.id"
|
||||
@mouseleave="hoveredSong = null"
|
||||
@dblclick="playSong(song)"
|
||||
>
|
||||
<!-- 序号/播放按钮 -->
|
||||
<div class="col-index">
|
||||
<span v-if="hoveredSong !== song.id" class="track-number">
|
||||
{{ String(visibleStartIndex + index + 1).padStart(2, '0') }}
|
||||
</span>
|
||||
<t-button
|
||||
v-else
|
||||
variant="text"
|
||||
size="small"
|
||||
class="play-btn"
|
||||
@click="playSong(song)"
|
||||
>
|
||||
<play-icon size="30" />
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲信息 -->
|
||||
<div class="col-title">
|
||||
<div
|
||||
v-if="song?.detail?.album?.blurPicUrl || song?.detail?.album?.picUrl"
|
||||
class="song-cover"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
(song.detail?.album?.blurPicUrl || song.detail?.album?.picUrl) +
|
||||
'?param=50y50'
|
||||
"
|
||||
loading="lazy"
|
||||
alt="封面"
|
||||
/>
|
||||
</div>
|
||||
<div class="song-info">
|
||||
<div class="song-name" :title="song.name">{{ song.name }}</div>
|
||||
<div
|
||||
class="artist-name"
|
||||
:title="song.artists ? song.artists.map((a) => a.name).join(' / ') : ''"
|
||||
>
|
||||
<template v-if="song.artists">
|
||||
<span
|
||||
v-for="(artist, idx) in song.artists"
|
||||
:key="artist.id"
|
||||
class="artist-link"
|
||||
>
|
||||
{{ artist.name }}<span v-if="idx < song.artists.length - 1"> / </span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 专辑 -->
|
||||
<div class="col-album">
|
||||
<span class="album-name" :title="song.album?.name">
|
||||
{{ song.album?.name || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 喜欢按钮 -->
|
||||
<div class="col-like">
|
||||
<t-button variant="text" size="small" class="action-btn like-btn" @click.stop>
|
||||
<heart-icon size="16" />
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<!-- 时长和更多操作 -->
|
||||
<div class="col-duration">
|
||||
<div class="duration-wrapper">
|
||||
<span v-if="hoveredSong !== song.id" class="duration">{{ formatDuration(song.duration) }}</span>
|
||||
<div v-else class="action-buttons">
|
||||
<t-button
|
||||
variant="text"
|
||||
size="small"
|
||||
class="action-btn"
|
||||
title="下载"
|
||||
@click.stop
|
||||
>
|
||||
<download-icon size="16" />
|
||||
</t-button>
|
||||
<t-button
|
||||
variant="text"
|
||||
size="small"
|
||||
class="action-btn"
|
||||
title="更多"
|
||||
@click.stop
|
||||
>
|
||||
<more-icon size="16" />
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="!loading" class="empty-state">
|
||||
<div class="empty-content">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<h3>未找到相关歌曲</h3>
|
||||
<p>请尝试其他关键词</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-else class="loading-state">
|
||||
<t-loading size="large" text="搜索中..." />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-container {
|
||||
background: #fafafa;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 30px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.search-title {
|
||||
font-size: 24px;
|
||||
font-weight: normal;
|
||||
color: #333;
|
||||
margin: 0 0 8px 0;
|
||||
|
||||
.keyword {
|
||||
color: #507daf;
|
||||
}
|
||||
}
|
||||
|
||||
.result-info {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.song-list-wrapper {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 200px 60px 80px;
|
||||
padding: 8px 20px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #e9e9e9;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
|
||||
.col-index {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.col-title {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.col-like {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.col-duration {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-scroll-container {
|
||||
background: #fff;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
|
||||
.virtual-scroll-spacer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.virtual-scroll-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.song-item {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 200px 60px 80px;
|
||||
padding: 8px 20px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
height: 64px; /* 固定高度,与虚拟滚动配置一致 */
|
||||
|
||||
&:hover,
|
||||
&.is-hovered {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.is-playing {
|
||||
background: #f0f7ff;
|
||||
color: #507daf;
|
||||
}
|
||||
|
||||
.col-index {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.track-number {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
color: #507daf;
|
||||
|
||||
&:hover {
|
||||
color: #3a5d8f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.col-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 10px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.song-cover {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: 10px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.song-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
.song-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
|
||||
&:hover {
|
||||
color: #507daf;
|
||||
}
|
||||
}
|
||||
|
||||
.artist-name {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
|
||||
.artist-link {
|
||||
&:hover {
|
||||
color: #507daf;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.col-album {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
overflow: hidden;
|
||||
|
||||
.album-name {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
color: #507daf;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.col-like {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.like-btn {
|
||||
color: #ccc;
|
||||
|
||||
&:hover {
|
||||
color: #507daf;
|
||||
background: rgba(80, 125, 175, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.col-duration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.duration-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
.duration {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 35px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
|
||||
.action-btn {
|
||||
color: #ccc;
|
||||
padding: 0 4px;
|
||||
|
||||
&:hover {
|
||||
color: #507daf;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.load-more {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #f5f5f5;
|
||||
background: #fafafa;
|
||||
|
||||
.load-more-tip,
|
||||
.load-complete {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
|
||||
.empty-content {
|
||||
text-align: center;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.search-container {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.list-header,
|
||||
.song-item {
|
||||
grid-template-columns: 50px 1fr 50px 60px;
|
||||
|
||||
.col-album {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.col-title {
|
||||
.song-cover {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
234
src/renderer/src/views/settings/index.vue
Normal file
234
src/renderer/src/views/settings/index.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<script setup lang="ts">
|
||||
import TitleBarControls from '@renderer/components/TitleBarControls.vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 当前选择的控制风格
|
||||
const currentStyle = ref<'windows' | 'traffic-light'>('windows')
|
||||
|
||||
// 切换风格
|
||||
const switchStyle = (style: 'windows' | 'traffic-light'): void => {
|
||||
currentStyle.value = style
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-container">
|
||||
<div class="settings-header">
|
||||
<h2>标题栏控制组件演示</h2>
|
||||
<p>这里展示了两种不同风格的标题栏控制按钮</p>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3>风格选择</h3>
|
||||
<div class="style-buttons">
|
||||
<t-button
|
||||
:theme="currentStyle === 'windows' ? 'primary' : 'default'"
|
||||
@click="switchStyle('windows')"
|
||||
>
|
||||
Windows 风格
|
||||
</t-button>
|
||||
<t-button
|
||||
:theme="currentStyle === 'traffic-light' ? 'primary' : 'default'"
|
||||
@click="switchStyle('traffic-light')"
|
||||
>
|
||||
红绿灯风格
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3>当前风格预览</h3>
|
||||
<div class="preview-container">
|
||||
<div class="mock-titlebar">
|
||||
<div class="mock-title">Ceru Music - 设置</div>
|
||||
<TitleBarControls :control-style="currentStyle" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3>两种风格对比</h3>
|
||||
<div class="comparison-container">
|
||||
<div class="style-demo">
|
||||
<h4>Windows 风格</h4>
|
||||
<div class="mock-titlebar">
|
||||
<div class="mock-title">Windows 风格标题栏</div>
|
||||
<TitleBarControls control-style="windows" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="style-demo">
|
||||
<h4>红绿灯风格 (macOS)</h4>
|
||||
<div class="mock-titlebar">
|
||||
<div class="mock-title">红绿灯风格标题栏</div>
|
||||
<TitleBarControls control-style="traffic-light" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3>功能说明</h3>
|
||||
<div class="feature-list">
|
||||
<div class="feature-item">
|
||||
<i class="iconfont icon-shezhi"></i>
|
||||
<div>
|
||||
<strong>设置按钮</strong>
|
||||
<p>位于控制按钮最左侧,用于打开应用设置</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<i class="iconfont icon-dibu"></i>
|
||||
<div>
|
||||
<strong>Mini 模式</strong>
|
||||
<p>切换到迷你播放模式,节省桌面空间</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<i class="iconfont icon-zuixiaohua"></i>
|
||||
<div>
|
||||
<strong>最小化</strong>
|
||||
<p>将窗口最小化到任务栏</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<i class="iconfont icon-zengjia"></i>
|
||||
<div>
|
||||
<strong>最大化</strong>
|
||||
<p>切换窗口最大化/还原状态</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<i class="iconfont icon-a-quxiaoguanbi"></i>
|
||||
<div>
|
||||
<strong>关闭</strong>
|
||||
<p>关闭应用程序</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.settings-container {
|
||||
padding: 2rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h2 {
|
||||
color: #111827;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
h3 {
|
||||
color: #111827;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.style-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.preview-container,
|
||||
.comparison-container {
|
||||
background: #f9fafb;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.comparison-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.style-demo {
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.mock-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #f6f6f6;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.mock-title {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.375rem;
|
||||
|
||||
.iconfont {
|
||||
font-size: 1.25rem;
|
||||
color: #f97316;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
div {
|
||||
flex: 1;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
color: #111827;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user