25 Commits

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
348d39cf42 Update: 补充构建信息 2025-08-08 21:39:43 +08:00
1f954aad35 Update: 使用 howler 优化音效播放逻辑 2025-08-08 20:06:52 +08:00
86a59aff1f Fix: 修复依赖相关问题 2025-08-08 17:42:41 +08:00
18548ef9fb Fix: 修复 assets 的路径和播放问题 2025-08-08 17:35:33 +08:00
27dfb0ae69 Update: 修复 package.json 和一些依赖问题 2025-08-08 16:25:56 +08:00
d1682f1b1c Update: 音效播放功能 2025-08-07 01:15:18 +08:00
d57b5fb540 初步实现功能 2025-08-06 14:15:15 +08:00
cbe3f72d1c 调整项目结构:前端和 Electron 核心 2025-08-06 07:44:19 +08:00
499f6205fa 调整项目结构 2025-08-06 07:41:41 +08:00
50bfdfbbe9 Init node proj. 2025-08-05 23:30:00 +08:00
40 changed files with 13041 additions and 2 deletions

144
.gitignore vendored Normal file
View File

@ -0,0 +1,144 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
core/*build*/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
.DS_Store
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist/
renderer/dist/
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
.vscode/
.idea/
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

View File

@ -1,6 +1,6 @@
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
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

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

324
main/main.js Normal file
View File

@ -0,0 +1,324 @@
const { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage } = require("electron");
const path = require("path");
const { spawn } = require("child_process");
const fs = require("fs");
app.disableHardwareAcceleration(); // 高 DPI 缩放修复
let tray = null;
let isQuiting = false;
// 音效播放器
function playAudioFile(filePath) {
if (process.platform === "win32") {
spawn("cmd", ["/c", `start "" "${filePath}"`]);
} else if (process.platform === "darwin") {
spawn("afplay", [filePath]);
} else {
spawn("aplay", [filePath]);
}
}
ipcMain.on("play-sound", (_, soundFile) => {
let soundPath;
// 判断是开发环境还是生产环境
if (process.env.NODE_ENV === "development") {
// 在开发模式下,直接指向 renderer/public 里的文件
soundPath = path.join(
__dirname,
"../renderer/public/sounds",
soundFile
);
} else {
// 在生产模式下Vite 会把 public 里的文件复制到 dist 文件夹
soundPath = path.join(
__dirname,
"../renderer/dist/sounds",
soundFile
);
}
console.log("Main process trying to play sound at path:", soundPath);
if (require("fs").existsSync(soundPath)) {
playAudioFile(soundPath);
} else {
console.error("Sound file not found:", soundPath);
}
});
ipcMain.handle("get-sound-path", (_, soundFile) => {
let soundPath;
if (process.env.NODE_ENV === "development") {
soundPath = path.join(
__dirname,
"../renderer/public/sounds",
soundFile
);
} else {
soundPath = path.join(
__dirname,
"../renderer/dist/sounds",
soundFile
);
}
if (require("fs").existsSync(soundPath)) {
// 返回一个可供 web 环境使用的 file 协议 URL
return `file://${soundPath}`;
} else {
console.error("Sound file not found:", soundPath);
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;
function createWindow() {
mainWindow = new BrowserWindow({
width: 200,
height: 200,
transparent: true, // 开启透明窗口
frame: false, // 无边框窗口
resizable: false, // 禁止调整大小
title: "说的道理桌面宠物(前端)",
alwaysOnTop: isAlwaysOnTop, // 窗口始终在最上层
skipTaskbar: true, // 不在任务栏显示
icon: path.join(__dirname, "../build/icon.png"),
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
webSecurity: false,
},
});
// 创建托盘图标
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") {
mainWindow.loadURL("http://localhost:5173");
} else {
mainWindow.loadFile(path.join(__dirname, "../renderer/dist/index.html"));
}
// 启动时显示窗口(允许随后最小化到托盘)
try { mainWindow.show(); } catch (e) {}
// 最小化时隐藏到托盘
mainWindow.on('minimize', (event) => {
event.preventDefault();
mainWindow.hide();
});
// 关闭窗口时隐藏到托盘,除非用户选择真正退出
mainWindow.on('close', (event) => {
if (!isQuiting) {
event.preventDefault();
mainWindow.hide();
}
});
}
app.whenReady().then(createWindow);

28
main/preload.js Normal file
View File

@ -0,0 +1,28 @@
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
playSound: (soundFile) => {
try {
ipcRenderer.send('play-sound', soundFile)
} catch (err) {
console.error('播放音效失败:', err)
}
},
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),
onUpdatePosition: (callback) => {
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')
})

6208
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

91
package.json Normal file
View File

@ -0,0 +1,91 @@
{
"name": "shuodedaoli-deskpet",
"version": "1.0.0",
"description": "A cute desktop pet of 'Shuodedaoli' built with Electron and Vue 3.",
"main": "main/main.js",
"scripts": {
"dev": "concurrently \"cd renderer && npm run dev\" \"wait-on http://localhost:5173 && cross-env NODE_ENV=development electron .\"",
"build": "npm run build:renderer && electron-builder",
"start": "electron .",
"build:renderer": "cd renderer && npm run build",
"build:win": "npm run build:renderer && electron-builder --win",
"build:linux": "npm run build:renderer && electron-builder --linux",
"build:all": "npm run build:renderer && electron-builder --win --linux"
},
"build": {
"appId": "com.kisechan.deskpet",
"productName": "说的道理桌面宠物",
"compression": "maximum",
"copyright": "Copyright © 2025 Kisechan",
"directories": {
"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": [
"main/",
"renderer/dist/",
"package.json"
],
"publish": {
"provider": "github",
"owner": "Kisechan",
"repo": "Shuodedaoli-Deskpet"
},
"win": {
"target": "nsis",
"icon": "assets/icon.png"
},
"mac": {
"target": "dmg",
"icon": "assets/icon.png"
},
"linux": {
"target": "AppImage",
"icon": "assets/icon.png"
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/Kisechan/Shuodedaoli-Deskpet.git.git"
},
"keywords": [
"electron",
"vue3",
"desktop-pet",
"desktop-widget",
"shuodedaoli",
"meme",
"fun-project",
"opensource"
],
"author": "Kisechan",
"license": "MIT",
"bugs": {
"url": "https://github.com/Kisechan/Shuodedaoli-Deskpet.git/issues"
},
"homepage": "https://github.com/Kisechan/Shuodedaoli-Deskpet.git#readme",
"dependencies": {
"fs-extra": "^11.3.1",
"howler": "^2.2.4",
"vue": "^3.5.18"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"concurrently": "^9.2.0",
"cross-env": "^10.0.0",
"electron": "^37.2.6",
"electron-builder": "^26.0.12",
"vite": "^7.1.1",
"wait-on": "^8.0.4"
}
}

24
renderer/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
renderer/README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
renderer/index.html Normal file
View File

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

5713
renderer/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
renderer/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "renderer",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.17"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"@vue/tsconfig": "^0.7.0",
"concurrently": "^9.2.0",
"electron-builder": "^26.0.12",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"vue-tsc": "^2.2.12",
"wait-on": "^8.0.4"
}
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

260
renderer/src/App.vue Normal file
View File

@ -0,0 +1,260 @@
<script setup>
import { ref, onMounted, reactive, computed } from "vue";
import { Howl } from "howler";
import defaultPet from "/public/pets/说的道理.gif";
// 状态
const soundFiles = ref([]);
const showTooltip = ref(false);
const currentTooltip = ref("");
const isLoading = ref(true);
const isPlaying = ref(false);
// 处理宠物素材选择
const assetPreviews = ref([]); // { name, fileName, url }
const selectedAsset = ref(null); // 将保存为 URL
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 {
if (window.electronAPI && typeof window.electronAPI.getPetSelection === 'function') {
const fileName = await window.electronAPI.getPetSelection();
if (fileName) {
const match = assetPreviews.value.find(p => p.fileName === fileName);
if (match) {
selectedAsset.value = match.url;
return;
}
// 如果 assetPreviews 中没有,但文件名存在,尝试直接请求 URL
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
}
}
async function saveSelection(assetPath) {
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);
}
// 加载并应用保存的选择(依赖于 assetPreviews 已构建)
await loadSavedSelection();
// 监听主进程通过右键菜单发来的选择变更
if (window.electronAPI && typeof window.electronAPI.onPetSelectionChanged === 'function') {
window.electronAPI.onPetSelectionChanged(async (fileName) => {
const match = assetPreviews.value.find(p => p.fileName === fileName);
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>
<template>
<div
class="pet-container"
@mousedown="handleMouseDown"
@contextmenu.prevent="handleRightClick"
>
<transition name="fade">
<div v-if="showTooltip" class="tooltip">
{{ currentTooltip }}
</div>
</transition>
<img :src="petGifUrl" class="pet-gif" />
</div>
</template>
<style>
/* 全局样式保持不变 */
html, body, #app {
background-color: transparent !important;
margin: 0;
padding: 0;
overflow: hidden;
}
</style>
<style scoped>
/* 样式大大简化 */
.pet-container {
width: 200px;
height: 200px;
position: relative;
cursor: pointer;
user-select: none; /* 防止拖动时选中文本 */
}
.pet-gif {
width: 100%;
height: 100%;
display: block;
/* 图片现在不接收任何鼠标事件,所有事件都由父容器处理 */
pointer-events: none;
}
.tooltip {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
white-space: nowrap;
pointer-events: none; /* 提示框也不响应鼠标 */
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>

5
renderer/src/main.ts Normal file
View File

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

5
renderer/src/shims.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

79
renderer/src/style.css Normal file
View File

@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

1
renderer/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,15 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
renderer/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

11
renderer/vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
base: './',
build: {
assetsInlineLimit: 0
// 强制所有资源作为文件输出
}
})