diff --git a/package.json b/package.json index 7110ccd..a27bcbc 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "font-list": "^1.5.1", "netease-cloud-music-api-alger": "^4.26.1", "node-id3": "^0.2.9", + "node-machine-id": "^1.1.12", "vue-i18n": "9" }, "devDependencies": { diff --git a/src/main/index.ts b/src/main/index.ts index dfb5584..542d4ae 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,6 +9,7 @@ import { initializeConfig } from './modules/config'; import { initializeFileManager } from './modules/fileManager'; import { initializeFonts } from './modules/fonts'; import { initializeShortcuts, registerShortcuts } from './modules/shortcuts'; +import { initializeStats, setupStatsHandlers } from './modules/statsService'; import { initializeTray, updateCurrentSong, updatePlayState, updateTrayMenu } from './modules/tray'; import { setupUpdateHandlers } from './modules/update'; import { createMainWindow, initializeWindowManager } from './modules/window'; @@ -50,6 +51,12 @@ function initialize() { // 初始化托盘 initializeTray(iconPath, mainWindow); + // 初始化统计服务 + initializeStats(); + + // 设置统计相关的IPC处理程序 + setupStatsHandlers(ipcMain); + // 启动音乐API startMusicApi(); diff --git a/src/main/modules/deviceInfo.ts b/src/main/modules/deviceInfo.ts new file mode 100644 index 0000000..b9f0ed7 --- /dev/null +++ b/src/main/modules/deviceInfo.ts @@ -0,0 +1,63 @@ +import { app } from 'electron'; +import Store from 'electron-store'; +import { machineIdSync } from 'node-machine-id'; +import os from 'os'; + +const store = new Store(); + +/** + * 获取设备唯一标识符 + * 优先使用存储的ID,如果没有则获取机器ID并存储 + */ +export function getDeviceId(): string { + let deviceId = store.get('deviceId') as string | undefined; + + if (!deviceId) { + try { + // 使用node-machine-id获取设备唯一标识 + deviceId = machineIdSync(true); + } catch (error) { + console.error('获取机器ID失败:', error); + // 如果获取失败,使用主机名和MAC地址组合作为备选方案 + const networkInterfaces = os.networkInterfaces(); + let macAddress = ''; + + // 尝试获取第一个非内部网络接口的MAC地址 + Object.values(networkInterfaces).forEach((interfaces) => { + if (interfaces) { + interfaces.forEach((iface) => { + if (!iface.internal && !macAddress && iface.mac !== '00:00:00:00:00:00') { + macAddress = iface.mac; + } + }); + } + }); + + deviceId = `${os.hostname()}-${macAddress}`.replace(/:/g, ''); + } + + // 存储设备ID + if (deviceId) { + store.set('deviceId', deviceId); + } else { + // 如果所有方法都失败,使用随机ID + deviceId = Math.random().toString(36).substring(2, 15); + store.set('deviceId', deviceId); + } + } + + return deviceId; +} + +/** + * 获取系统信息 + */ +export function getSystemInfo() { + return { + osType: os.type(), + osVersion: os.release(), + osArch: os.arch(), + platform: process.platform, + appVersion: app.getVersion() + }; +} diff --git a/src/main/modules/statsService.ts b/src/main/modules/statsService.ts new file mode 100644 index 0000000..bce6d30 --- /dev/null +++ b/src/main/modules/statsService.ts @@ -0,0 +1,122 @@ +import axios from 'axios'; +import { app } from 'electron'; +import Store from 'electron-store'; + +import { getDeviceId, getSystemInfo } from './deviceInfo'; + +const store = new Store(); + +// 统计服务配置 +const STATS_API_URL = 'http://donate.alger.fun/state/api/stats'; + +/** + * 记录应用安装/启动 + */ +export async function recordInstallation(): Promise { + try { + const deviceId = getDeviceId(); + const systemInfo = getSystemInfo(); + + // 发送请求到统计服务器 + await axios.post(`${STATS_API_URL}/installation`, { + deviceId, + osType: systemInfo.osType, + osVersion: systemInfo.osVersion, + appVersion: systemInfo.appVersion + }); + + console.log('应用启动统计已记录'); + + // 记录最后一次启动时间 + store.set('lastStartTime', new Date().toISOString()); + } catch (error) { + console.error('记录应用启动统计失败:', error); + } +} + +/** + * 设置 IPC 处理程序以接收渲染进程的统计请求 + * @param ipcMain Electron IPC主对象 + */ +export function setupStatsHandlers(ipcMain: Electron.IpcMain): void { + // 处理页面访问统计 + ipcMain.handle('record-visit', async (_, page: string, userId?: string) => { + try { + const deviceId = getDeviceId(); + + await axios.post(`${STATS_API_URL}/visit`, { + deviceId, + userId, + page + }); + + return { success: true }; + } catch (error) { + console.error('记录页面访问统计失败:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // 处理播放统计 + ipcMain.handle( + 'record-play', + async ( + _, + songData: { + userId: string | null; + songId: string | number; + songName: string; + artistName: string; + duration?: number; + completedPlay?: boolean; + } + ) => { + try { + const { songId, songName, artistName, duration = 0, completedPlay = false } = songData; + const deviceId = getDeviceId(); + + await axios.post(`${STATS_API_URL}/play`, { + deviceId, + userId: songData.userId, + songId: songId.toString(), + songName, + artistName, + duration, + completedPlay + }); + + return { success: true }; + } catch (error) { + console.error('记录播放统计失败:', error); + return { success: false, error: (error as Error).message }; + } + } + ); + + // 处理获取统计摘要 + ipcMain.handle('get-stats-summary', async () => { + try { + const response = await axios.get(`${STATS_API_URL}/summary`); + return response.data; + } catch (error) { + console.error('获取统计摘要失败:', error); + throw error; + } + }); +} + +/** + * 应用启动时初始化统计服务 + */ +export function initializeStats(): void { + // 记录应用启动统计 + recordInstallation().catch((error) => { + console.error('初始化统计服务失败:', error); + }); + + // 注册应用退出时的回调 + app.on('will-quit', () => { + // 可以在这里添加应用退出时的统计逻辑 + console.log('应用退出'); + }); +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index e55192a..c52f48c 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,31 +1,42 @@ import { ElectronAPI } from '@electron-toolkit/preload'; +interface API { + minimize: () => void; + maximize: () => void; + close: () => void; + dragStart: (data: any) => void; + miniTray: () => void; + miniWindow: () => void; + restore: () => void; + restart: () => void; + resizeWindow: (width: number, height: number) => void; + resizeMiniWindow: (showPlaylist: boolean) => void; + openLyric: () => void; + sendLyric: (data: any) => void; + sendSong: (data: any) => void; + unblockMusic: (id: number, data: any) => Promise; + onLyricWindowClosed: (callback: () => void) => void; + startDownload: (url: string) => void; + onDownloadProgress: (callback: (progress: number, status: string) => void) => void; + onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => void; + onLanguageChanged: (callback: (locale: string) => void) => void; + removeDownloadListeners: () => void; + invoke: (channel: string, ...args: any[]) => Promise; +} + +// 自定义IPC渲染进程通信接口 +interface IpcRenderer { + send: (channel: string, ...args: any[]) => void; + invoke: (channel: string, ...args: any[]) => Promise; + on: (channel: string, listener: (...args: any[]) => void) => () => void; + removeAllListeners: (channel: string) => void; +} + declare global { interface Window { electron: ElectronAPI; - api: { - sendLyric: (data: string) => void; - openLyric: () => void; - minimize: () => void; - maximize: () => void; - close: () => void; - dragStart: (data: string) => void; - miniTray: () => void; - miniWindow: () => void; - restore: () => void; - restart: () => void; - resizeWindow: (width: number, height: number) => void; - resizeMiniWindow: (showPlaylist: boolean) => void; - unblockMusic: (id: number, data: any) => Promise; - onLyricWindowClosed: (callback: () => void) => void; - startDownload: (url: string) => void; - onDownloadProgress: (callback: (progress: number, status: string) => void) => void; - onDownloadComplete: (callback: (success: boolean, filePath: string) => void) => void; - removeDownloadListeners: () => void; - onLanguageChanged: (callback: (locale: string) => void) => void; - invoke: (channel: string, ...args: any[]) => Promise; - sendSong: (data: any) => void; - }; + api: API; + ipcRenderer: IpcRenderer; $message: any; } } diff --git a/src/preload/index.ts b/src/preload/index.ts index dee5014..17a9379 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -47,7 +47,11 @@ const api = { 'get-system-fonts', 'get-cached-lyric', 'cache-lyric', - 'clear-lyric-cache' + 'clear-lyric-cache', + // 统计相关 + 'record-visit', + 'record-play', + 'get-stats-summary' ]; if (validChannels.includes(channel)) { return ipcRenderer.invoke(channel, ...args); @@ -56,6 +60,29 @@ const api = { } }; +// 创建带类型的ipcRenderer对象,暴露给渲染进程 +const ipc = { + // 发送消息到主进程(无返回值) + send: (channel: string, ...args: any[]) => { + ipcRenderer.send(channel, ...args); + }, + // 调用主进程方法(有返回值) + invoke: (channel: string, ...args: any[]) => { + return ipcRenderer.invoke(channel, ...args); + }, + // 监听主进程消息 + on: (channel: string, listener: (...args: any[]) => void) => { + ipcRenderer.on(channel, (_, ...args) => listener(...args)); + return () => { + ipcRenderer.removeListener(channel, listener); + }; + }, + // 移除所有监听器 + removeAllListeners: (channel: string) => { + ipcRenderer.removeAllListeners(channel); + } +}; + // Use `contextBridge` APIs to expose Electron APIs to // renderer only if context isolation is enabled, otherwise // just add to the DOM global. @@ -63,6 +90,7 @@ if (process.contextIsolated) { try { contextBridge.exposeInMainWorld('electron', electronAPI); contextBridge.exposeInMainWorld('api', api); + contextBridge.exposeInMainWorld('ipcRenderer', ipc); } catch (error) { console.error(error); } @@ -71,4 +99,6 @@ if (process.contextIsolated) { window.electron = electronAPI; // @ts-ignore (define in dts) window.api = api; + // @ts-ignore (define in dts) + window.ipcRenderer = ipc; } diff --git a/src/renderer/api/donation.ts b/src/renderer/api/donation.ts index 5c4cf78..fe0898f 100644 --- a/src/renderer/api/donation.ts +++ b/src/renderer/api/donation.ts @@ -15,6 +15,6 @@ export interface Donor { * 获取捐赠列表 */ export const getDonationList = async (): Promise => { - const { data } = await axios.get('http://110.42.251.190:8766/api/donations'); + const { data } = await axios.get('http://donate.alger.fun/api/donations'); return data; }; diff --git a/src/renderer/api/stats.ts b/src/renderer/api/stats.ts new file mode 100644 index 0000000..c7447cf --- /dev/null +++ b/src/renderer/api/stats.ts @@ -0,0 +1,75 @@ +import { isElectron } from '@/utils'; + +import { useUserStore } from '../store/modules/user'; + +/** + * 获取用户ID + * @returns 用户ID或null + */ +function getUserId(): string | null { + const userStore = useUserStore(); + return userStore.user?.userId?.toString() || null; +} + +/** + * 记录页面访问 + * @param page 页面名称或路径 + */ +export async function recordVisit(page: string): Promise { + if (!isElectron) return; + try { + const userId = getUserId(); + await window.api.invoke('record-visit', page, userId); + console.log(`页面访问已记录: ${page}`); + } catch (error) { + console.error('记录页面访问失败:', error); + } +} + +/** + * 记录歌曲播放 + * @param songId 歌曲ID + * @param songName 歌曲名称 + * @param artistName 艺术家名称 + * @param duration 时长(秒) + * @param completedPlay 是否完整播放 + */ +export async function recordPlay( + songId: string | number, + songName: string, + artistName: string, + duration: number = 0, + completedPlay: boolean = false +): Promise { + if (!isElectron) return; + try { + const userId = getUserId(); + + await window.api.invoke('record-play', { + userId, + songId, + songName, + artistName, + duration, + completedPlay + }); + + console.log(`歌曲播放已记录: ${songName}`); + } catch (error) { + console.error('记录歌曲播放失败:', error); + } +} + +/** + * 获取统计摘要 + * @returns 统计数据摘要 + */ +export async function getStatsSummary(): Promise { + if (!isElectron) return null; + try { + return await window.api.invoke('get-stats-summary'); + } catch (error) { + console.error('获取统计摘要失败:', error); + return null; + } +} diff --git a/src/renderer/hooks/MusicHistoryHook.ts b/src/renderer/hooks/MusicHistoryHook.ts index 51dfc3e..263a97a 100644 --- a/src/renderer/hooks/MusicHistoryHook.ts +++ b/src/renderer/hooks/MusicHistoryHook.ts @@ -1,6 +1,7 @@ // musicHistoryHooks import { useLocalStorage } from '@vueuse/core'; +import { recordPlay } from '@/api/stats'; import type { SongResult } from '@/type/music'; export const useMusicHistory = () => { @@ -14,6 +15,25 @@ export const useMusicHistory = () => { } else { musicHistory.value.unshift({ ...music, count: 1 }); } + + // 记录播放统计 + if (music?.id && music?.name) { + // 获取艺术家名称 + let artistName = '未知艺术家'; + + if (music.ar) { + artistName = music.ar.map((artist) => artist.name).join('/'); + } else if (music.song?.artists && music.song.artists.length > 0) { + artistName = music.song.artists.map((artist) => artist.name).join('/'); + } else if (music.artists) { + artistName = music.artists.map((artist) => artist.name).join('/'); + } + + // 发送播放统计 + recordPlay(music.id, music.name, artistName).catch((error) => + console.error('记录播放统计失败:', error) + ); + } }; const delMusic = (music: SongResult) => { diff --git a/src/renderer/router/index.ts b/src/renderer/router/index.ts index 9fe34be..34fbc20 100644 --- a/src/renderer/router/index.ts +++ b/src/renderer/router/index.ts @@ -1,5 +1,6 @@ import { createRouter, createWebHashHistory } from 'vue-router'; +import { recordVisit } from '@/api/stats'; import AppLayout from '@/layout/AppLayout.vue'; import MiniLayout from '@/layout/MiniLayout.vue'; import homeRouter from '@/router/home'; @@ -80,4 +81,13 @@ router.beforeEach((to, _, next) => { } }); +// 添加全局后置钩子,记录页面访问 +router.afterEach((to) => { + const pageName = to.name?.toString() || to.path; + // 使用setTimeout避免阻塞路由导航 + setTimeout(() => { + recordVisit(pageName).catch((error) => console.error('记录页面访问失败:', error)); + }, 100); +}); + export default router;