15 Commits
v0.1.0 ... main

Author SHA1 Message Date
897a660289 Feat: “说的道莉”素材追加 2025-09-10 22:21:47 +08:00
c119fbaf7e Update: 修改 README.md,增添更多 TODOs 2025-09-10 22:06:49 +08:00
0cf2e1cf15 Feat: 添加托盘功能和响应的托盘菜单 2025-09-10 19:35:19 +08:00
14148ee1ec Fix: 解决打包后的应用找不到素材的问题 2025-09-10 17:09:13 +08:00
51c66cd498 Feat: 素材追加
Fix: 修正打包文件路径错误
2025-09-09 20:58:57 +08:00
27c65820e6 Feat: 允许切换不同形态的道理 2025-09-09 20:46:03 +08:00
2fea99a7e2 Update: 规范化项目结构 2025-09-03 21:02:41 +08:00
d3e9cf5240 Feat: 优化音频播放逻辑,添加设置页面 2025-09-03 20:42:04 +08:00
656c276f1e Fix: 修复和补充打包的部分功能和问题 2025-09-03 19:44:46 +08:00
4eff008fd9 Update README.md: 完善构建说明 2025-09-03 16:25:15 +08:00
57b268008d Fix: 修复拖动/点击桌宠时,主程序发生移动的问题 2025-09-03 15:53:05 +08:00
a1183a35d3 Fix: 修复不能移动界面的 bug,兼容移动和鼠标点击,并将窗口大小修改为 200px 2025-08-26 18:58:07 +08:00
3e770368b5 Feat: 自动检索音效文件夹下的素材随机播放,并配备对应文案 2025-08-22 13:59:36 +08:00
182768afcb Fix: 修复背景不透明的 bug 2025-08-09 23:32:55 +08:00
77ce0b8f69 Update: 完善 README.md ,添加项目描述和使用流程 2025-08-09 11:36:31 +08:00
26 changed files with 573 additions and 123 deletions

View File

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 Kisechan Copyright (c) 2025 Kisechan <admin@kisechan.space>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@ -1 +1,58 @@
# Pet # Shuodedaoli-Deskpet
[![Static Badge](https://img.shields.io/badge/HTML5-%23E34F26?style=flat-square&logo=html5&logoColor=white)](https://www.w3.org/TR/2011/WD-html5-20110405/index.html)
[![Static Badge](https://img.shields.io/badge/CSS-%23663399?style=flat-square&logo=css&logoColor=white)](https://www.w3.org/Style/CSS/Overview.en.html)
[![Static Badge](https://img.shields.io/badge/JavaScript-%23F7DF1E?style=flat-square&logo=javascript&logoColor=white)](https://www.javascript.com/)
[![Static Badge](https://img.shields.io/badge/Vue.js-%234FC08D?style=flat-square&logo=vuedotjs&logoColor=white)](https://vuejs.org/)
[![Static Badge](https://img.shields.io/badge/Vite-%23646CFF?style=flat-square&logo=vite&logoColor=white)](https://cn.vite.dev/)
[![Static Badge](https://img.shields.io/badge/Electron-%2347848F?style=flat-square&logo=electron&logoColor=white)](https://www.electronjs.org/zh/)
一只非常非常可爱的**说的道理**桌宠(可鬼叫)。
仅供娱乐。
## 主要功能
- **非常可爱**,哇这个好可爱鸭。
- **哇袄!**
- **跨平台**(支持 Windows Linux 和 MacOS
## 使用流程
1. **复制项目**
```bash
git clone https://github.com/Kisechan/Shuodedaoli-Deskpet.git
```
2. **安装依赖**(需要 npm
```bash
npm install
cd renderer
npm install
# 本项目的主进程和渲染进程是分离的,需要分别下载环境
```
3. **运行项目**
```bash
npm run start
```
## 贡献和反馈
欢迎提交 [Issue](https://github.com/Kisechan/Shuodedaoli-Deskpet/issues) 或 [PR](https://github.com/Kisechan/Shuodedaoli-Deskpet/pulls)。
## 许可证
[MIT](./LICENSE).
## TODO
- [x] 更美观的说的道理,优化 UI
- [x] 更多的哇袄
- [x] 更换不同的道理
- [ ] 更完善的托盘
- [ ] 实用功能,更贴心的道理

BIN
assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@ -1,101 +1,323 @@
const { app, BrowserWindow, ipcMain } = require("electron"); const { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage } = require("electron");
const path = require("path"); const path = require("path");
const { spawn } = require('child_process') const { spawn } = require("child_process");
const fs = require("fs");
app.disableHardwareAcceleration(); // 高 DPI 缩放修复
let tray = null;
let isQuiting = false;
// 音效播放器 // 音效播放器
function playAudioFile(filePath) { function playAudioFile(filePath) {
if (process.platform === 'win32') { if (process.platform === "win32") {
spawn('cmd', ['/c', `start "" "${filePath}"`]) spawn("cmd", ["/c", `start "" "${filePath}"`]);
} else if (process.platform === 'darwin') { } else if (process.platform === "darwin") {
spawn('afplay', [filePath]) spawn("afplay", [filePath]);
} else { } else {
spawn('aplay', [filePath]) spawn("aplay", [filePath]);
} }
} }
ipcMain.on('play-sound', (_, soundFile) => { ipcMain.on("play-sound", (_, soundFile) => {
let soundPath; let soundPath;
// 判断是开发环境还是生产环境 // 判断是开发环境还是生产环境
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
// 在开发模式下,直接指向 renderer/public 里的文件 // 在开发模式下,直接指向 renderer/public 里的文件
soundPath = path.join(__dirname, '../renderer/public/assets/sounds', soundFile); soundPath = path.join(
__dirname,
"../renderer/public/sounds",
soundFile
);
} else { } else {
// 在生产模式下Vite 会把 public 里的文件复制到 dist 文件夹 // 在生产模式下Vite 会把 public 里的文件复制到 dist 文件夹
soundPath = path.join(__dirname, '../renderer/dist/assets/sounds', soundFile); soundPath = path.join(
__dirname,
"../renderer/dist/sounds",
soundFile
);
} }
console.log('Main process trying to play sound at path:', soundPath); console.log("Main process trying to play sound at path:", soundPath);
if (require('fs').existsSync(soundPath)) { if (require("fs").existsSync(soundPath)) {
playAudioFile(soundPath); playAudioFile(soundPath);
} else { } else {
console.error('Sound file not found:', soundPath); console.error("Sound file not found:", soundPath);
} }
}); });
ipcMain.handle('get-sound-path', (_, soundFile) => { ipcMain.handle("get-sound-path", (_, soundFile) => {
let soundPath; let soundPath;
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
soundPath = path.join(__dirname, '../renderer/public/assets/sounds', soundFile); soundPath = path.join(
__dirname,
"../renderer/public/sounds",
soundFile
);
} else { } else {
soundPath = path.join(__dirname, '../renderer/dist/assets/sounds', soundFile); soundPath = path.join(
__dirname,
"../renderer/dist/sounds",
soundFile
);
} }
if (require('fs').existsSync(soundPath)) { if (require("fs").existsSync(soundPath)) {
// 返回一个可供 web 环境使用的 file 协议 URL // 返回一个可供 web 环境使用的 file 协议 URL
return `file://${soundPath}`; return `file://${soundPath}`;
} else { } else {
console.error('Sound file not found:', soundPath); console.error("Sound file not found:", soundPath);
return null; return null;
} }
}); });
ipcMain.handle("get-sound-files", async () => {
const soundDir =
process.env.NODE_ENV === "development"
? path.join(__dirname, "../renderer/public/sounds")
: path.join(__dirname, "../renderer/dist/sounds");
try {
// 读取目录下的所有文件名
const files = await fs.promises.readdir(soundDir);
// 筛选出 .mp3 文件并返回
return files.filter((file) => file.endsWith(".mp3"));
} catch (error) {
console.error("无法读取声音目录:", error);
return []; // 如果出错则返回空数组
}
});
// 持久化用户设置 (用于保存所选宠物素材文件名)
const settingsFile = path.join(app.getPath('userData') || __dirname, 'settings.json');
ipcMain.handle('get-pet-selection', async () => {
try {
if (fs.existsSync(settingsFile)) {
const data = JSON.parse(await fs.promises.readFile(settingsFile, 'utf8'));
return data.petAsset || null;
}
} catch (err) {
console.error('读取设置失败:', err);
}
return null;
});
ipcMain.handle('set-pet-selection', async (_, petAsset) => {
try {
let data = {};
if (fs.existsSync(settingsFile)) {
try {
data = JSON.parse(await fs.promises.readFile(settingsFile, 'utf8')) || {};
} catch (e) {
data = {};
}
}
data.petAsset = petAsset;
await fs.promises.writeFile(settingsFile, JSON.stringify(data, null, 2), 'utf8');
return true;
} catch (err) {
console.error('写入设置失败:', err);
return false;
}
});
ipcMain.on("show-context-menu", async () => {
// 尝试动态读取 renderer 下的 public/pets 目录,开发/生产路径均尝试
const devAssets = path.join(__dirname, "../renderer/public/pets");
const prodAssets = path.join(__dirname, "../renderer/dist/pets");
let assetsDir = null;
if (fs.existsSync(devAssets)) assetsDir = devAssets;
else if (fs.existsSync(prodAssets)) assetsDir = prodAssets;
// 读取当前保存的选择(文件名)
let currentSelection = null;
try {
if (fs.existsSync(settingsFile)) {
const data = JSON.parse(await fs.promises.readFile(settingsFile, 'utf8')) || {};
currentSelection = data.petAsset || null;
}
} catch (e) {
console.error('读取当前选择失败:', e);
}
// 构建素材菜单项,如果无法读取目录,则提供一个默认值
let assetItems = [];
try {
if (assetsDir) {
const files = await fs.promises.readdir(assetsDir);
const imgs = files.filter(f => /\.(png|jpg|jpeg|gif)$/i.test(f));
assetItems = imgs.map((f) => {
const nameWithoutExt = f.replace(/\.[^.]+$/, '');
const displayLabel = nameWithoutExt + (/\.gif$/i.test(f) ? '(可动)' : '');
return ({
label: displayLabel,
type: 'radio',
checked: f === currentSelection,
click: async () => {
try {
// 写入 settings.json
let data = {};
if (fs.existsSync(settingsFile)) {
try { data = JSON.parse(await fs.promises.readFile(settingsFile, 'utf8')) || {}; } catch (e) { data = {}; }
}
data.petAsset = f;
await fs.promises.writeFile(settingsFile, JSON.stringify(data, null, 2), 'utf8');
// 通知渲染进程更新
if (mainWindow && mainWindow.webContents) {
mainWindow.webContents.send('pet-selection-changed', f);
}
} catch (err) {
console.error('写入选择失败:', err);
}
}
});
});
}
} catch (err) {
console.error('读取 assets 目录失败:', err);
assetItems = [];
}
// 如果没有任何可用素材,提供占位项
if (assetItems.length === 0) {
assetItems = [
{ label: '(无可用素材)', enabled: false }
];
}
const template = [
{
label: "置顶显示",
type: "checkbox",
checked: isAlwaysOnTop,
click: () => {
isAlwaysOnTop = !isAlwaysOnTop;
if (mainWindow) mainWindow.setAlwaysOnTop(isAlwaysOnTop);
},
},
{ type: 'separator' },
{
label: '选择素材',
submenu: assetItems,
},
{ type: 'separator' },
{
label: "退出",
click: () => { isQuiting = true; app.quit(); },
},
];
const menu = Menu.buildFromTemplate(template);
menu.popup({ window: mainWindow });
});
// 提供给渲染进程:列出 public/pets 中的素材文件(开发/生产路径)
ipcMain.handle('get-pet-files', async () => {
const devDir = path.join(__dirname, '../renderer/public/pets');
const prodDir = path.join(__dirname, '../renderer/dist/pets');
const dir = fs.existsSync(devDir) ? devDir : (fs.existsSync(prodDir) ? prodDir : null);
if (!dir) return [];
try {
const files = await fs.promises.readdir(dir);
return files.filter(f => /\.(png|jpg|jpeg|gif)$/i.test(f));
} catch (e) {
console.error('读取 pets 目录失败:', e);
return [];
}
});
ipcMain.handle('get-pet-url', (_, fileName) => {
// 在开发时public 文件由 dev server 以根路径提供
if (process.env.NODE_ENV === 'development') {
return `http://localhost:5173/pets/${encodeURIComponent(fileName)}`;
}
// 生产时,从 dist/pets 返回 file:// URL
const prodPath = path.join(__dirname, '../renderer/dist/pets', fileName);
if (fs.existsSync(prodPath)) return `file://${prodPath}`;
// fallback: try public path in source tree
const devPath = path.join(__dirname, '../renderer/public/pets', fileName);
if (fs.existsSync(devPath)) return `file://${devPath}`;
return null;
});
let isAlwaysOnTop = true;
let mainWindow; let mainWindow;
function createWindow() { function createWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 300, width: 200,
height: 300, height: 200,
transparent: true, transparent: true, // 开启透明窗口
frame: false, frame: false, // 无边框窗口
resizable: false, // 固定大小 resizable: false, // 禁止调整大小
title: "说的道理桌面宠物(前端)",
alwaysOnTop: isAlwaysOnTop, // 窗口始终在最上层
skipTaskbar: true, // 不在任务栏显示
icon: path.join(__dirname, "../build/icon.png"),
webPreferences: { webPreferences: {
preload: path.join(__dirname, "preload.js"), preload: path.join(__dirname, "preload.js"),
contextIsolation: true, contextIsolation: true,
webSecurity: false, // 信任应用,并允许加载本地资源 webSecurity: false,
}, },
}); });
// 开发模式加载Vite服务器 // 创建托盘图标
try {
// 修改托盘图标路径以确保在开发和生产环境中正确加载
const iconPath = process.env.NODE_ENV === "development"
? path.join(__dirname, "../assets/icon.ico") // 开发环境路径
: path.join(__dirname, "../renderer/public/images/icon.ico"); // 生产环境路径
const trayIcon = nativeImage.createFromPath(iconPath);
if (trayIcon.isEmpty()) {
console.error("托盘图标加载失败,路径:", iconPath);
} else {
tray = new Tray(trayIcon);
}
} catch (e) {
console.error('创建托盘失败:', e);
}
// 当渲染进程传来这个事件时,就移动窗口
ipcMain.on("move-window", (event, { x, y }) => {
// 使用 Math.round 避免非整数坐标可能带来的问题
mainWindow.setPosition(Math.round(x), Math.round(y), false);
});
// 添加一个 handle用于响应前端获取窗口位置的请求
ipcMain.handle("get-window-position", () => {
if (mainWindow) {
const [x, y] = mainWindow.getPosition();
return { x, y };
}
return { x: 0, y: 0 };
});
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
mainWindow.loadURL("http://localhost:5173"); mainWindow.loadURL("http://localhost:5173");
} else { } else {
mainWindow.loadFile(path.join(__dirname, "../renderer/dist/index.html")); mainWindow.loadFile(path.join(__dirname, "../renderer/dist/index.html"));
} }
// 窗口拖拽功能 // 启动时显示窗口(允许随后最小化到托盘)
let isDragging = false; try { mainWindow.show(); } catch (e) {}
mainWindow.webContents.on("before-input-event", (_, input) => {
if (input.type === "mouseDown") { // 最小化时隐藏到托盘
isDragging = true; mainWindow.on('minimize', (event) => {
mainWindow.webContents.executeJavaScript(` event.preventDefault();
window.dragOffset = { x: ${input.x}, y: ${input.y} } mainWindow.hide();
`);
} else if (input.type === "mouseUp") {
isDragging = false;
}
}); });
mainWindow.on("moved", () => { // 关闭窗口时隐藏到托盘,除非用户选择真正退出
if (isDragging) { mainWindow.on('close', (event) => {
mainWindow.webContents.executeJavaScript(` if (!isQuiting) {
window.electronAPI.updatePosition() event.preventDefault();
`); mainWindow.hide();
} }
const [x, y] = mainWindow.getPosition();
mainWindow.webContents.send("update-position", { x, y });
}); });
} }

View File

@ -9,8 +9,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
} }
}, },
getSoundPath: (soundFile) => ipcRenderer.invoke('get-sound-path', soundFile), getSoundPath: (soundFile) => ipcRenderer.invoke('get-sound-path', soundFile),
getSoundFiles: () => ipcRenderer.invoke('get-sound-files'),
getPetSelection: () => ipcRenderer.invoke('get-pet-selection'),
setPetSelection: (petAsset) => ipcRenderer.invoke('set-pet-selection', petAsset),
onPetSelectionChanged: (callback) => {
ipcRenderer.on('pet-selection-changed', (_, fileName) => callback(fileName));
},
getPetFiles: () => ipcRenderer.invoke('get-pet-files'),
getPetUrl: (fileName) => ipcRenderer.invoke('get-pet-url', fileName),
showTooltip: (text) => ipcRenderer.send('show-tooltip', text), showTooltip: (text) => ipcRenderer.send('show-tooltip', text),
onUpdatePosition: (callback) => { onUpdatePosition: (callback) => {
ipcRenderer.on('update-position', (_, position) => callback(position)) ipcRenderer.on('update-position', (_, position) => callback(position))
} },
moveWindow: (position) => ipcRenderer.send('move-window', position),
// 暴露获取窗口位置的函数
getWindowPosition: () => ipcRenderer.invoke('get-window-position'),
showContextMenu: () => ipcRenderer.send('show-context-menu')
}) })

View File

@ -1,6 +1,6 @@
{ {
"name": "shuodedaoli-deskpet", "name": "shuodedaoli-deskpet",
"version": "0.1.0", "version": "1.0.0",
"description": "A cute desktop pet of 'Shuodedaoli' built with Electron and Vue 3.", "description": "A cute desktop pet of 'Shuodedaoli' built with Electron and Vue 3.",
"main": "main/main.js", "main": "main/main.js",
"scripts": { "scripts": {
@ -15,26 +15,43 @@
"build": { "build": {
"appId": "com.kisechan.deskpet", "appId": "com.kisechan.deskpet",
"productName": "说的道理桌面宠物", "productName": "说的道理桌面宠物",
"compression": "maximum",
"copyright": "Copyright © 2025 Kisechan", "copyright": "Copyright © 2025 Kisechan",
"directories": { "directories": {
"output": "out" "output": "out"
}, },
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"perMachine": false,
"allowElevation": false,
"installerIcon": "assets/icon.ico",
"uninstallerIcon": "assets/icon.ico",
"installerHeaderIcon": "assets/icon.ico",
"installerLanguages": ["zh_CN", "en_US"],
"language": "2052"
},
"files": [ "files": [
"main/", "main/",
"renderer/dist/", "renderer/dist/",
"package.json" "package.json"
], ],
"publish": {
"provider": "github",
"owner": "Kisechan",
"repo": "Shuodedaoli-Deskpet"
},
"win": { "win": {
"target": "nsis", "target": "nsis",
"icon": "build/icon.png" "icon": "assets/icon.png"
}, },
"mac": { "mac": {
"target": "dmg", "target": "dmg",
"icon": "build/icon.png" "icon": "assets/icon.png"
}, },
"linux": { "linux": {
"target": "AppImage", "target": "AppImage",
"icon": "build/icon.png" "icon": "assets/icon.png"
} }
}, },
"repository": { "repository": {

View File

@ -2,11 +2,11 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="assets/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title> <title>说的道理桌宠</title>
</head> </head>
<body> <body style="background-color: transparent;">
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 677 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

Before

Width:  |  Height:  |  Size: 6.5 MiB

After

Width:  |  Height:  |  Size: 6.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 MiB

View File

@ -1,102 +1,245 @@
<script setup> <script setup>
import { ref, onMounted } from "vue"; import { ref, onMounted, reactive, computed } from "vue";
import petGif from "./assets/pet.gif"; import { Howl } from "howler";
import { Howl } from 'howler';
// 配置数据 import defaultPet from "/public/pets/说的道理.gif";
const tooltips = [
"说的道理~",
"尊尼获加",
"为什么不开大!!",
"(凤鸣)",
];
const soundFiles = [
"cnmb.mp3",
"冲刺,冲.mp3",
"哎你怎么死了.mp3",
"哎,猪逼.mp3",
"啊啊啊我草你妈呀.mp3",
"嘟嘟嘟.mp3",
"韭菜盒子.mp3",
"哇袄.mp3"
];
// 状态管理 // 状态
const soundFiles = ref([]);
const showTooltip = ref(false); const showTooltip = ref(false);
const currentTooltip = ref(""); const currentTooltip = ref("");
const position = ref({ x: 0, y: 0 }); const isLoading = ref(true);
const isPlaying = ref(false);
// 点击事件处理 // 处理宠物素材选择
const handleClick = async () => { const assetPreviews = ref([]); // { name, fileName, url }
const randomSoundFile = soundFiles[Math.floor(Math.random() * soundFiles.length)]; const selectedAsset = ref(null); // 将保存为 URL
console.log('请求播放:', randomSoundFile); const showSettings = ref(false);
const selectedAssetName = computed(() => {
if (!selectedAsset.value) return null;
const match = assetPreviews.value.find(p => p.url === selectedAsset.value);
if (match) return match.name;
// fallback: 从 URL 中取最后一段
const parts = selectedAsset.value.split('/');
return parts[parts.length - 1];
});
async function loadSavedSelection() {
// 优先从主进程读取持久化选择(返回 fileName
try { try {
const audioUrl = await window.electronAPI?.getSoundPath(randomSoundFile); if (window.electronAPI && typeof window.electronAPI.getPetSelection === 'function') {
const fileName = await window.electronAPI.getPetSelection();
if (audioUrl) { if (fileName) {
const sound = new Howl({ const match = assetPreviews.value.find(p => p.fileName === fileName);
src: [audioUrl], if (match) {
format: ['mp3'] selectedAsset.value = match.url;
}); return;
sound.play(); }
} else { // 如果 assetPreviews 中没有,但文件名存在,尝试直接请求 URL
console.error('无法获取音频路径:', randomSoundFile); if (window.electronAPI && typeof window.electronAPI.getPetUrl === 'function') {
const url = await window.electronAPI.getPetUrl(fileName);
if (url) { selectedAsset.value = url; return; }
}
}
} }
} catch (e) {
console.error('读取保存的宠物素材失败:', e);
}
// fallback: localStorage
try {
const ls = localStorage.getItem('petAsset');
if (ls) {
const match = assetPreviews.value.find(p => p.fileName === ls);
if (match) { selectedAsset.value = match.url; return; }
if (window.electronAPI && typeof window.electronAPI.getPetUrl === 'function') {
const url = await window.electronAPI.getPetUrl(ls);
if (url) selectedAsset.value = url;
}
}
} catch (e) {
// ignore
}
}
} catch (err) { async function saveSelection(assetPath) {
console.error('播放失败:', err); const fileName = assetPath.replace(/^.*\//, '');
// 保存到主进程
if (window.electronAPI && typeof window.electronAPI.setPetSelection === 'function') {
try {
await window.electronAPI.setPetSelection(fileName);
} catch (e) {
console.error('保存宠物素材失败:', e);
}
}
// fallback: localStorage
try { localStorage.setItem('petAsset', fileName); } catch (_) {}
}
// 初始化
onMounted(async () => {
// 加载声音文件列表
if (window.electronAPI && typeof window.electronAPI.getSoundFiles === 'function') {
try {
soundFiles.value = await window.electronAPI.getSoundFiles();
} catch (err) {
console.error("获取声音文件列表失败:", err);
} finally {
isLoading.value = false;
}
}
// 从主进程读取 public/pets 下的素材文件名,然后为每个文件请求可用 URL
try {
if (window.electronAPI && typeof window.electronAPI.getPetFiles === 'function') {
const files = await window.electronAPI.getPetFiles();
const previews = await Promise.all(files.map(async (file) => {
const url = await window.electronAPI.getPetUrl(file);
const name = file.replace(/\.[^.]+$/, '');
const displayName = name + (/\.gif$/i.test(file) ? '(可动)' : '');
return { fileName: file, url, name: displayName };
}));
assetPreviews.value = previews;
}
} catch (e) {
console.error('获取 pets 列表失败:', e);
} }
currentTooltip.value = tooltips[Math.floor(Math.random() * tooltips.length)]; // 加载并应用保存的选择(依赖于 assetPreviews 已构建)
showTooltip.value = true; await loadSavedSelection();
setTimeout(() => (showTooltip.value = false), 2000);
};
// 初始化位置监听 // 监听主进程通过右键菜单发来的选择变更
onMounted(() => { if (window.electronAPI && typeof window.electronAPI.onPetSelectionChanged === 'function') {
if (window.electronAPI) { window.electronAPI.onPetSelectionChanged(async (fileName) => {
window.electronAPI.onUpdatePosition((pos) => { const match = assetPreviews.value.find(p => p.fileName === fileName);
position.value = pos; if (match) selectedAsset.value = match.url;
}); });
} }
}); });
const petGifUrl = computed(() => selectedAsset.value || defaultPet);
const playRandomSound = async () => {
if (isPlaying.value) return;
if (isLoading.value || soundFiles.value.length === 0) return;
const randomSoundFile = soundFiles.value[Math.floor(Math.random() * soundFiles.value.length)];
isPlaying.value = true;
try {
const audioUrl = await window.electronAPI.getSoundPath(randomSoundFile);
if (audioUrl) {
new Howl({
src: [audioUrl],
format: ["mp3"],
onend: function() {
showTooltip.value = false;
isPlaying.value = false;
}
}).play();
} else {
isPlaying.value = false;
}
} catch (err) {
isPlaying.value = false;
console.error("播放失败:", err);
}
currentTooltip.value = randomSoundFile.replace(/\.mp3$/, '');
showTooltip.value = true;
};
const dragState = reactive({
isDragging: false,
hasMoved: false,
mouseStartX: 0,
mouseStartY: 0,
windowStartX: 0,
windowStartY: 0,
});
async function handleMouseDown(event) {
if (event.button !== 0) return;
const { x, y } = await window.electronAPI.getWindowPosition();
dragState.windowStartX = x;
dragState.windowStartY = y;
dragState.mouseStartX = event.screenX;
dragState.mouseStartY = event.screenY;
dragState.isDragging = true;
dragState.hasMoved = false;
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
function handleMouseMove(event) {
if (!dragState.isDragging) return;
const deltaX = event.screenX - dragState.mouseStartX;
const deltaY = event.screenY - dragState.mouseStartY;
if (!dragState.hasMoved && (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5)) dragState.hasMoved = true;
const newWindowX = dragState.windowStartX + deltaX;
const newWindowY = dragState.windowStartY + deltaY;
window.electronAPI.moveWindow({ x: newWindowX, y: newWindowY });
}
function handleMouseUp() {
if (dragState.isDragging && !dragState.hasMoved) playRandomSound();
dragState.isDragging = false;
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
}
function handleRightClick() { window.electronAPI.showContextMenu(); }
async function chooseAsset(entry) {
// entry is { fileName, url, name }
selectedAsset.value = entry.url;
await saveSelection(entry.fileName);
showSettings.value = false;
}
</script> </script>
<template> <template>
<div <div
class="pet-container" class="pet-container"
:style="{ left: `${position.x}px`, top: `${position.y}px` }" @mousedown="handleMouseDown"
@contextmenu.prevent="handleRightClick"
> >
<img :src="petGif" class="pet-gif" @click="handleClick" draggable="false" />
<transition name="fade"> <transition name="fade">
<div v-if="showTooltip" class="tooltip"> <div v-if="showTooltip" class="tooltip">
{{ currentTooltip }} {{ currentTooltip }}
</div> </div>
</transition> </transition>
<img :src="petGifUrl" class="pet-gif" />
</div> </div>
</template> </template>
<style>
/* 全局样式保持不变 */
html, body, #app {
background-color: transparent !important;
margin: 0;
padding: 0;
overflow: hidden;
}
</style>
<style scoped> <style scoped>
/* 样式大大简化 */
.pet-container { .pet-container {
position: absolute; width: 200px;
width: 300px; height: 200px;
height: 300px; position: relative;
-webkit-app-region: no-drag; cursor: pointer;
user-select: none; /* 防止拖动时选中文本 */
} }
.pet-gif { .pet-gif {
width: 100%; width: 100%;
height: 100%; height: 100%;
cursor: pointer; display: block;
user-select: none; /* 图片现在不接收任何鼠标事件,所有事件都由父容器处理 */
-webkit-user-drag: none; pointer-events: none;
} }
.tooltip { .tooltip {
position: absolute; position: absolute;
bottom: -40px; bottom: 10px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7); background: rgba(0, 0, 0, 0.7);
@ -105,14 +248,13 @@ onMounted(() => {
border-radius: 20px; border-radius: 20px;
font-size: 14px; font-size: 14px;
white-space: nowrap; white-space: nowrap;
pointer-events: none; /* 提示框也不响应鼠标 */
} }
.fade-enter-active, .fade-enter-active, .fade-leave-active {
.fade-leave-active {
transition: opacity 0.3s; transition: opacity 0.3s;
} }
.fade-enter-from, .fade-enter-from, .fade-leave-to {
.fade-leave-to {
opacity: 0; opacity: 0;
} }
</style> </style>