@ -0,0 +1,13 @@ | |||
2 | |||
Cargo.lock | |||
bin/ | |||
icons/ | |||
# Generated by Cargo | |||
# will have compiled files and executables | |||
/target/ | |||
# Generated by Tauri | |||
# will have schema files for capabilities auto-completion | |||
/gen/schemas |
@ -0,0 +1,32 @@ | |||
[package] | |||
name = "Lazyeat" | |||
version = "0.3.11" | |||
description = "Lazyeat 手势识别" | |||
authors = ["https://github.com/maplelost"] | |||
edition = "2021" | |||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | |||
[lib] | |||
# The `_lib` suffix may seem redundant but it is necessary | |||
# to make the lib name unique and wouldn't conflict with the bin name. | |||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 | |||
name = "tauri_app_lib" | |||
crate-type = ["staticlib", "cdylib", "rlib"] | |||
[build-dependencies] | |||
tauri-build = { version = "2", features = [] } | |||
[dependencies] | |||
tauri = { version = "2", features = ["devtools"] } | |||
tauri-plugin-opener = "2" | |||
serde = { version = "1", features = ["derive"] } | |||
serde_json = "1" | |||
tauri-plugin-shell = "2" | |||
tauri-plugin-store = "2" | |||
tauri-plugin-notification = "2" | |||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] | |||
tauri-plugin-autostart = "2" | |||
tauri-plugin-window-state = "2" | |||
@ -0,0 +1,8 @@ | |||
<?xml version="1.0" encoding="UTF-8"?> | |||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |||
<plist version="1.0"> | |||
<dict> | |||
<key>NSCameraUsageDescription</key> | |||
<string>请允许本程序访问您的摄像头</string> | |||
</dict> | |||
</plist> |
@ -0,0 +1,3 @@ | |||
fn main() { | |||
tauri_build::build() | |||
} |
@ -0,0 +1,41 @@ | |||
{ | |||
"$schema": "../gen/schemas/desktop-schema.json", | |||
"identifier": "default", | |||
"description": "Capability for the main window", | |||
"windows": ["main", "NewWindow_2"], | |||
"permissions": [ | |||
"core:default", | |||
"shell:default", | |||
"shell:allow-execute", | |||
"shell:allow-spawn", | |||
"core:path:default", | |||
"core:event:default", | |||
"core:window:default", | |||
"core:app:default", | |||
"core:resources:default", | |||
"core:menu:default", | |||
"core:tray:default", | |||
"core:window:allow-destroy", | |||
"core:window:allow-set-title", | |||
"store:default", | |||
"notification:default", | |||
"notification:allow-is-permission-granted", | |||
"notification:allow-notify", | |||
"notification:allow-show", | |||
"notification:allow-request-permission", | |||
"opener:default", | |||
"opener:allow-open-path", | |||
"opener:allow-reveal-item-in-dir", | |||
"opener:allow-default-urls", | |||
"opener:allow-open-url", | |||
"core:webview:default", | |||
"core:window:allow-show", | |||
"core:window:allow-hide", | |||
"core:webview:allow-create-webview-window", | |||
"core:window:allow-set-position", | |||
"core:window:allow-set-size" | |||
] | |||
} |
@ -0,0 +1,17 @@ | |||
{ | |||
"identifier": "desktop-capability", | |||
"platforms": [ | |||
"macOS", | |||
"windows", | |||
"linux" | |||
], | |||
"windows": [ | |||
"main" | |||
], | |||
"permissions": [ | |||
"autostart:allow-enable", | |||
"autostart:allow-disable", | |||
"autostart:allow-is-enabled", | |||
"window-state:default" | |||
] | |||
} |
@ -0,0 +1,58 @@ | |||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ | |||
#[tauri::command] | |||
fn greet(name: &str) -> String { | |||
format!("Hello, {}! You've been greeted from Rust!", name) | |||
} | |||
// 提取sidecar启动逻辑到单独的函数 | |||
async fn start_sidecar(app: tauri::AppHandle) -> Result<String, String> { | |||
let sidecar = app | |||
.shell() | |||
.sidecar("Lazyeat Backend") | |||
.map_err(|e| format!("无法找到sidecar: {}", e))?; | |||
let (_rx, _child) = sidecar | |||
.spawn() | |||
.map_err(|e| format!("无法启动sidecar: {}", e))?; | |||
Ok("Sidecar已启动".to_string()) | |||
} | |||
// 保留命令供可能的手动调用 | |||
#[tauri::command] | |||
async fn run_sidecar(app: tauri::AppHandle) -> Result<String, String> { | |||
start_sidecar(app).await | |||
} | |||
use tauri_plugin_autostart::MacosLauncher; | |||
use tauri_plugin_autostart::ManagerExt; | |||
use tauri_plugin_shell::process::CommandEvent; | |||
use tauri_plugin_shell::ShellExt; | |||
#[cfg_attr(mobile, tauri::mobile_entry_point)] | |||
pub fn run() { | |||
tauri::Builder::default() | |||
.plugin(tauri_plugin_notification::init()) | |||
// .plugin(tauri_plugin_window_state::Builder::new().build()) // 窗口状态管理,启用了导致 sub-window 无法设置decorations | |||
.plugin(tauri_plugin_store::Builder::new().build()) | |||
.plugin(tauri_plugin_autostart::init( | |||
MacosLauncher::LaunchAgent, | |||
Some(vec!["--flag1", "--flag2"]), | |||
)) | |||
.plugin(tauri_plugin_shell::init()) | |||
.plugin(tauri_plugin_opener::init()) | |||
.setup(|app| { | |||
// 在应用启动时自动启动sidecar | |||
let app_handle = app.handle().clone(); | |||
tauri::async_runtime::spawn(async move { | |||
match start_sidecar(app_handle).await { | |||
Ok(msg) => println!("{}", msg), | |||
Err(e) => eprintln!("启动sidecar失败: {}", e), | |||
} | |||
}); | |||
Ok(()) | |||
}) | |||
.invoke_handler(tauri::generate_handler![greet, run_sidecar]) | |||
.run(tauri::generate_context!()) | |||
.expect("error while running tauri application"); | |||
} |
@ -0,0 +1,6 @@ | |||
// Prevents additional console window on Windows in release, DO NOT REMOVE!! | |||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] | |||
fn main() { | |||
tauri_app_lib::run() | |||
} |
@ -0,0 +1,61 @@ | |||
{ | |||
"$schema": "https://schema.tauri.app/config/2", | |||
"productName": "Lazyeat", | |||
"version": "0.3.11", | |||
"identifier": "com.Lazyeat.maplelost", | |||
"build": { | |||
"beforeDevCommand": "npm run dev", | |||
"devUrl": "http://localhost:1420", | |||
"beforeBuildCommand": "npm run build", | |||
"frontendDist": "../dist" | |||
}, | |||
"app": { | |||
"windows": [ | |||
{ | |||
"title": "Lazyeat", | |||
"width": 800, | |||
"height": 600, | |||
"devtools": true | |||
} | |||
], | |||
"security": { | |||
"csp": null | |||
} | |||
}, | |||
"bundle": { | |||
"active": true, | |||
"externalBin": ["bin/backend-py/Lazyeat Backend"], | |||
"resources": { | |||
"bin/backend-py/_internal": "_internal/", | |||
"../model": "model/", | |||
"../debug backend.bat": "/" | |||
}, | |||
"macOS": { | |||
"dmg": { | |||
"appPosition": { | |||
"x": 180, | |||
"y": 170 | |||
}, | |||
"applicationFolderPosition": { | |||
"x": 480, | |||
"y": 170 | |||
}, | |||
"windowSize": { | |||
"height": 400, | |||
"width": 660 | |||
} | |||
}, | |||
"files": {}, | |||
"hardenedRuntime": true, | |||
"minimumSystemVersion": "10.13" | |||
}, | |||
"targets": ["dmg", "msi"], | |||
"icon": [ | |||
"icons/32x32.png", | |||
"icons/128x128.png", | |||
"icons/128x128@2x.png", | |||
"icons/mac/icon.icns", | |||
"icons/icon.ico" | |||
] | |||
} | |||
} |
@ -0,0 +1,13 @@ | |||
{ | |||
"$schema": "https://schema.tauri.app/config/2", | |||
"identifier": "com.Lazyeat.maplelost", | |||
"bundle": { | |||
"resources": [], | |||
"macOS": { | |||
"files": { | |||
"Resources/model": "../model", | |||
"Frameworks": "./bin/backend-py/_internal" | |||
} | |||
} | |||
} | |||
} |
@ -0,0 +1,34 @@ | |||
<template> | |||
<n-message-provider> | |||
<div class="app-container"> | |||
<h1>MediaPipe</h1> | |||
<div class="detection-container"> | |||
<hand-landmark-detection /> | |||
</div> | |||
</div> | |||
</n-message-provider> | |||
</template> | |||
<script setup lang="ts"> | |||
import HandLandmarkDetection from "@/hand_landmark/VideoDetector.vue"; | |||
</script> | |||
<style scoped> | |||
.app-container { | |||
padding: 20px; | |||
display: flex; | |||
flex-direction: column; | |||
align-items: center; | |||
} | |||
h1 { | |||
color: #333; | |||
margin-bottom: 20px; | |||
} | |||
.detection-container { | |||
width: 100%; | |||
max-width: 640px; | |||
margin: 0 auto; | |||
} | |||
</style> |
@ -0,0 +1,31 @@ | |||
<script setup lang="ts"> | |||
import { Power } from "@icon-park/vue-next"; | |||
import { disable, enable } from "@tauri-apps/plugin-autostart"; | |||
import { watch } from "vue"; | |||
import { use_app_store } from "@/store/app"; | |||
const app_store = use_app_store(); | |||
watch( | |||
() => app_store.config.auto_start, | |||
async (value) => { | |||
if (value) { | |||
await enable(); | |||
} else { | |||
await disable(); | |||
} | |||
} | |||
); | |||
</script> | |||
<template> | |||
<n-space align="center" style="display: flex; align-items: center"> | |||
<span style="display: flex; align-items: center"> | |||
<n-icon size="20" style="margin-right: 8px"> | |||
<Power /> | |||
</n-icon> | |||
<span>{{ $t("开机自启动") }}</span> | |||
</span> | |||
<n-switch v-model:value="app_store.config.auto_start" /> | |||
</n-space> | |||
</template> |
@ -0,0 +1,77 @@ | |||
<template> | |||
<div class="circle-progress"> | |||
<svg :width="size" :height="size" viewBox="0 0 100 100"> | |||
<!-- 背景圆环 --> | |||
<circle | |||
cx="50" | |||
cy="50" | |||
:r="radius" | |||
fill="none" | |||
:stroke="backgroundColor" | |||
:stroke-width="strokeWidth" | |||
/> | |||
<!-- 进度圆环 --> | |||
<circle | |||
cx="50" | |||
cy="50" | |||
:r="radius" | |||
fill="none" | |||
:stroke="color" | |||
:stroke-width="strokeWidth" | |||
:stroke-dasharray="circumference" | |||
:stroke-dashoffset="dashOffset" | |||
class="progress" | |||
/> | |||
<!-- 中心文本 --> | |||
<slot> | |||
<text x="50" y="50" text-anchor="middle" dominant-baseline="middle" class="progress-text"> | |||
{{ text }} | |||
{{ percentage }}% | |||
</text> | |||
</slot> | |||
</svg> | |||
</div> | |||
</template> | |||
<script setup lang="ts"> | |||
import { computed } from 'vue'; | |||
interface Props { | |||
percentage: number; | |||
size?: number; | |||
strokeWidth?: number; | |||
color?: string; | |||
backgroundColor?: string; | |||
text?: string; | |||
} | |||
const props = withDefaults(defineProps<Props>(), { | |||
size: 100, | |||
strokeWidth: 6, | |||
color: '#409eff', | |||
backgroundColor: '#e5e9f2', | |||
text: '' | |||
}); | |||
const radius = computed(() => 50 - props.strokeWidth / 2); | |||
const circumference = computed(() => 2 * Math.PI * radius.value); | |||
const dashOffset = computed(() => | |||
circumference.value * (1 - props.percentage / 100) | |||
); | |||
</script> | |||
<style lang="scss" scoped> | |||
.circle-progress { | |||
display: inline-block; | |||
.progress { | |||
transform: rotate(-90deg); | |||
transform-origin: center; | |||
} | |||
.progress-text { | |||
font-size: 14px; | |||
fill: #606266; | |||
} | |||
} | |||
</style> |
@ -0,0 +1,82 @@ | |||
<template> | |||
<n-card class="gesture-card" :bordered="false"> | |||
<n-space align="center" class="gesture-content"> | |||
<div class="gesture-icon" :class="{ 'double-hand': isDoubleHand }"> | |||
<slot name="icon"></slot> | |||
</div> | |||
<div class="gesture-info"> | |||
<h3>{{ title }}</h3> | |||
<p>{{ description }}</p> | |||
<slot name="extra"></slot> | |||
</div> | |||
</n-space> | |||
</n-card> | |||
</template> | |||
<script setup lang="ts"> | |||
defineProps<{ | |||
title: string; | |||
description: string; | |||
isDoubleHand?: boolean; | |||
}>(); | |||
</script> | |||
<style scoped lang="scss"> | |||
.gesture-card { | |||
transition: all 0.3s ease; | |||
background: linear-gradient(145deg, #f8faff, #ffffff); | |||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); | |||
border: 1px solid #e5e9f2; | |||
border-radius: 12px; | |||
&:hover { | |||
transform: translateY(-2px); | |||
box-shadow: 0 8px 24px rgba(64, 152, 252, 0.15); | |||
border-color: #4098fc; | |||
} | |||
} | |||
.gesture-content { | |||
padding: 12px; | |||
} | |||
.gesture-icon { | |||
background: rgba(64, 152, 252, 0.1); | |||
padding: 16px; | |||
border-radius: 12px; | |||
display: flex; | |||
align-items: center; | |||
justify-content: center; | |||
border: 1px solid rgba(64, 152, 252, 0.2); | |||
} | |||
.gesture-info { | |||
flex: 1; | |||
h3 { | |||
margin: 0 0 4px 0; | |||
font-size: 1.1rem; | |||
color: #2c3e50; | |||
} | |||
p { | |||
margin: 0; | |||
color: #666; | |||
font-size: 0.9rem; | |||
} | |||
} | |||
.double-hand { | |||
display: flex; | |||
gap: 8px; | |||
:deep(svg) { | |||
width: 32px; | |||
height: 32px; | |||
} | |||
} | |||
.flipped { | |||
transform: scaleX(-1); | |||
} | |||
</style> |
@ -0,0 +1,23 @@ | |||
<template> | |||
<component | |||
:is="icon" | |||
theme="outline" | |||
size="40" | |||
fill="#4098fc" | |||
:stroke-width="3" | |||
:class="{ flipped }" | |||
/> | |||
</template> | |||
<script setup lang="ts"> | |||
defineProps<{ | |||
icon: any; | |||
flipped?: boolean; | |||
}>(); | |||
</script> | |||
<style scoped> | |||
.flipped { | |||
transform: scaleX(-1); | |||
} | |||
</style> |
@ -0,0 +1,41 @@ | |||
<script setup lang="ts"> | |||
import type { MenuOption } from "naive-ui"; | |||
import { NMenu } from "naive-ui"; | |||
import { ref } from "vue"; | |||
import { useRouter } from "vue-router"; | |||
const router = useRouter(); | |||
const menuOptions: MenuOption[] = [ | |||
{ | |||
label: "首页", | |||
key: "/", | |||
}, | |||
{ | |||
label: "操作指南", | |||
key: "/guide", | |||
}, | |||
// { | |||
// label: "开发", | |||
// key: "/update", | |||
// } | |||
]; | |||
const activeKey = ref("/"); | |||
const handleUpdateValue = (key: string) => { | |||
activeKey.value = key; | |||
router.push(key); | |||
}; | |||
</script> | |||
<template> | |||
<n-menu | |||
:options="menuOptions" | |||
v-model:value="activeKey" | |||
mode="vertical" | |||
@update:value="handleUpdateValue" | |||
/> | |||
</template> | |||
<style scoped></style> |
@ -0,0 +1,49 @@ | |||
export default { | |||
"手势识别控制": "Gesture Recognition Control", | |||
"运行中": "Running", | |||
"已停止": "Stopped", | |||
"开机自启动": "Auto Start", | |||
"显示识别窗口": "Show Recognition Window", | |||
"摄像头选择": "Camera Selection", | |||
"手势操作指南": "Gesture Guide", | |||
"光标控制": "Cursor Control", | |||
"竖起食指滑动控制光标位置": "Slide with index finger to control cursor position", | |||
"单击操作": "Click Operation", | |||
"双指举起执行鼠标单击": "Raise two fingers to perform mouse click", | |||
"Rock手势执行鼠标单击": "Rock gesture to perform mouse click", | |||
"滚动控制": "Scroll Control", | |||
"三指上下滑动控制页面滚动": "Slide three fingers up/down to control page scrolling", | |||
"(okay手势)食指和拇指捏合滚动页面": "(okay gesture)Pinch with index and thumb to scroll page", | |||
"食指和拇指距离小于": "Index and thumb distance less than", | |||
"触发捏合": "Trigger pinch", | |||
"默认值0.02": "Default value 0.02", | |||
"可以通过右键->检查->控制台->捏合手势->查看当前距离": "Can check current distance by right-click -> inspect -> console -> pinch gesture", | |||
"全屏控制": "Full Screen Control", | |||
"四指并拢发送按键": "Four fingers together to send key", | |||
"点击设置快捷键": "Click to set shortcut", | |||
"请按下按键...": "Please press keys...", | |||
"点击设置": "Click to set", | |||
"退格": "Backspace", | |||
"发送退格键": "Send backspace key", | |||
"开始语音识别": "Start Voice Recognition", | |||
"六指手势开始语音识别": "Six fingers gesture to start voice recognition", | |||
"结束语音识别": "End Voice Recognition", | |||
"拳头手势结束语音识别": "Fist gesture to end voice recognition", | |||
"暂停/继续": "Pause/Resume", | |||
"单手张开1.5秒 暂停/继续 手势识别": "Open one hand for 1.5 seconds to pause/resume gesture recognition", | |||
"识别框x": "Recognition box x", | |||
"识别框y": "Recognition box y", | |||
"识别框宽": "Recognition box width", | |||
"识别框高": "Recognition box height", | |||
// 通知 | |||
"Lazyeat": "Lazyeat", | |||
"提示": "Tip", | |||
"停止语音识别": "Stop Voice Recognition", | |||
"手势识别": "Gesture Recognition", | |||
"继续手势识别": "Continue Gesture Recognition", | |||
"暂停手势识别": "Pause Gesture Recognition", | |||
}; |
@ -0,0 +1,18 @@ | |||
import { createI18n } from "vue-i18n"; | |||
// 导入语言包 | |||
import en from "./en"; | |||
import zh from "./zh"; | |||
// 创建 i18n 实例 | |||
const i18n = createI18n({ | |||
locale: navigator.language.split("-")[0], // 使用系统语言作为默认语言 | |||
// locale: "en", // 使用系统语言作为默认语言 | |||
fallbackLocale: "zh", // 回退语言 | |||
messages: { | |||
en, | |||
zh, | |||
}, | |||
}); | |||
export default i18n; |
@ -0,0 +1,47 @@ | |||
export default { | |||
"手势识别控制": "手势识别控制", | |||
"运行中": "运行中", | |||
"已停止": "已停止", | |||
"显示识别窗口": "显示识别窗口", | |||
"摄像头选择": "摄像头选择", | |||
"手势操作指南": "手势操作指南", | |||
"开机自启动": "开机自启动", | |||
"光标控制": "光标控制", | |||
"竖起食指滑动控制光标位置": "竖起食指滑动控制光标位置", | |||
"单击操作": "单击操作", | |||
"双指举起执行鼠标单击": "双指举起执行鼠标单击", | |||
"Rock手势执行鼠标单击": "Rock手势执行鼠标单击", | |||
"滚动控制": "滚动控制", | |||
"三指上下滑动控制页面滚动": "三指上下滑动控制页面滚动", | |||
"(okay手势)食指和拇指捏合滚动页面": "(okay手势)食指和拇指捏合滚动页面", | |||
"食指和拇指距离小于": "食指和拇指距离小于", | |||
"触发捏合": "触发捏合", | |||
"全屏控制": "全屏控制", | |||
"四指并拢发送按键": "四指并拢发送按键", | |||
"点击设置快捷键": "点击设置快捷键", | |||
"请按下按键...": "请按下按键...", | |||
"点击设置": "点击设置", | |||
"退格": "退格", | |||
"发送退格键": "发送退格键", | |||
"开始语音识别": "开始语音识别", | |||
"六指手势开始语音识别": "六指手势开始语音识别", | |||
"结束语音识别": "结束语音识别", | |||
"拳头手势结束语音识别": "拳头手势结束语音识别", | |||
"暂停/继续": "暂停/继续", | |||
"单手张开1.5秒 暂停/继续 手势识别": "单手张开1.5秒 暂停/继续 手势识别", | |||
"识别框x": "识别框x", | |||
"识别框y": "识别框y", | |||
"识别框宽": "识别框宽", | |||
"识别框高": "识别框高", | |||
"默认值0.02": "默认值0.02", | |||
"可以通过右键->检查->控制台->捏合手势->查看当前距离": "可以通过右键->检查->控制台->捏合手势->查看当前距离", | |||
// 通知 | |||
"Lazyeat": "Lazyeat", | |||
"提示": "提示", | |||
"停止语音识别": "停止语音识别", | |||
"手势识别": "手势识别", | |||
"继续手势识别": "继续手势识别", | |||
"暂停手势识别": "暂停手势识别", | |||
}; |
@ -0,0 +1,30 @@ | |||
const port = 62334; | |||
const base_url = `http://localhost:${port}`; | |||
class PyApi { | |||
async ready(): Promise<boolean> { | |||
try { | |||
await fetch(`${base_url}/`, { | |||
signal: AbortSignal.timeout(1000), | |||
}); | |||
return true; | |||
} catch (error) { | |||
return false; | |||
} | |||
} | |||
async shutdown() { | |||
try { | |||
await fetch(`${base_url}/shutdown`, { | |||
method: "GET", | |||
signal: AbortSignal.timeout(500), | |||
}); | |||
} catch (error) { | |||
console.error("关闭服务失败:", error); | |||
} | |||
} | |||
} | |||
const pyApi = new PyApi(); | |||
export default pyApi; |
@ -0,0 +1,38 @@ | |||
import MainWindow from "@/view/mainWindow/MainWindow.vue"; | |||
import SubWindow from "@/view/subWindow/SubWindow.vue"; | |||
import Home from "@/view/mainWindow/Home.vue"; | |||
import Guide from "@/view/mainWindow/Guide.vue"; | |||
import { createRouter, createWebHistory } from "vue-router"; | |||
const routes = [ | |||
{ | |||
path: "/", | |||
name: "mainWindow", | |||
component: MainWindow, | |||
children: [ | |||
{ | |||
path: "", | |||
name: "home", | |||
component: Home, | |||
}, | |||
{ | |||
path: "guide", | |||
name: "guide", | |||
component: Guide, | |||
} | |||
] | |||
}, | |||
{ | |||
path: "/sub-window", | |||
name: "subWindow", | |||
component: SubWindow, | |||
}, | |||
]; | |||
const router = createRouter({ | |||
// 设置成 html5 模式,subWindow 才能正常工作 | |||
history: createWebHistory(import.meta.env.BASE_URL), | |||
routes, | |||
}); | |||
export default router; |
@ -0,0 +1,46 @@ | |||
import { | |||
WebviewWindow, | |||
getAllWebviewWindows, | |||
} from "@tauri-apps/api/webviewWindow"; | |||
export const SUB_WINDOW_WIDTH = 130; | |||
export const SUB_WINDOW_HEIGHT = 130; | |||
export async function createSubWindow(url: string, title: string) { | |||
let message = ""; | |||
let success = true; | |||
try { | |||
const allWindows = await getAllWebviewWindows(); | |||
const windownsLen = allWindows.length; | |||
const label = `NewWindow_${windownsLen + 1}`; | |||
const openUrl = url || "index.html"; | |||
const newTitle = title || "新窗口"; | |||
const openTitle = `${newTitle}-${windownsLen + 1}`; | |||
const webview_window = new WebviewWindow(label, { | |||
url: openUrl, | |||
title: openTitle, | |||
parent: "main", | |||
zoomHotkeysEnabled: false, | |||
width: SUB_WINDOW_WIDTH, | |||
height: SUB_WINDOW_HEIGHT, | |||
minWidth: SUB_WINDOW_WIDTH, | |||
minHeight: SUB_WINDOW_HEIGHT, | |||
alwaysOnTop: true, | |||
decorations: false, // 隐藏窗口边框 | |||
visible: false, | |||
resizable: false, | |||
}); | |||
webview_window.once("tauri://created", async () => { | |||
message = "打开成功"; | |||
}); | |||
webview_window.once("tauri://error", function (e) { | |||
message = `打开${openTitle}报错: ${e}`; | |||
success = false; | |||
}); | |||
return { success: success, message: message, webview: webview_window }; | |||
} catch (error) { | |||
return { success: false, message: error }; | |||
} | |||
} |
@ -0,0 +1,348 @@ | |||
<template> | |||
<div class="guide-container"> | |||
<div class="header-bar"> | |||
<div class="title-container"> | |||
<h1 class="view-title">{{ $t('手势操作指南') }}</h1> | |||
<div class="view-description">{{ $t('学习使用各种手势控制您的设备') }}</div> | |||
</div> | |||
</div> | |||
<div class="main-content"> | |||
<n-card class="guide-card"> | |||
<div class="gesture-grid"> | |||
<!-- 卡片1: 光标控制 --> | |||
<div class="card"> | |||
<div class="card-icon"> | |||
<div class="icon-circle"> | |||
<span class="icon-symbol">👆</span> | |||
</div> | |||
</div> | |||
<div class="card-content"> | |||
<h3 class="card-title">{{ $t('光标控制') }}</h3> | |||
<p class="card-description">{{ $t('竖起食指滑动控制光标位置') }}</p> | |||
</div> | |||
</div> | |||
<!-- 卡片2: 单击操作(双指) --> | |||
<div class="card"> | |||
<div class="card-icon"> | |||
<div class="icon-circle"> | |||
<span class="icon-symbol">✌️</span> | |||
</div> | |||
</div> | |||
<div class="card-content"> | |||
<h3 class="card-title">{{ $t('单击操作') }}</h3> | |||
<p class="card-description">{{ $t('双指举起执行鼠标单击') }}</p> | |||
</div> | |||
</div> | |||
<!-- 卡片3: 单击操作(Rock手势) --> | |||
<div class="card"> | |||
<div class="card-icon"> | |||
<div class="icon-circle"> | |||
<span class="icon-symbol">🤘</span> | |||
</div> | |||
</div> | |||
<div class="card-content"> | |||
<h3 class="card-title">{{ $t('单击操作') }}</h3> | |||
<p class="card-description">{{ $t('Rock手势执行鼠标单击') }}</p> | |||
<div class="card-extra"> | |||
</div> | |||
</div> | |||
</div> | |||
<!-- 卡片4: 滚动控制 --> | |||
<div class="card"> | |||
<div class="card-icon"> | |||
<div class="icon-circle"> | |||
<span class="icon-symbol">👌</span> | |||
</div> | |||
</div> | |||
<div class="card-content"> | |||
<h3 class="card-title">{{ $t('滚动控制') }}</h3> | |||
<p class="card-description">{{ $t('(okay手势)食指和拇指捏合滚动页面') }}</p> | |||
<div class="card-extra"> | |||
<div class="setting-control"> | |||
<span>{{ $t("食指和拇指距离小于") }}</span> | |||
<n-input-number | |||
v-model:value="app_store.config.scroll_gesture_2_thumb_and_index_threshold" | |||
:min="0" | |||
:step="0.01" | |||
size="small" | |||
/> | |||
<span>{{ $t("触发捏合") }}</span> | |||
</div> | |||
<div class="hint-tags"> | |||
<n-tag size="small" type="info">{{ $t("默认值0.02") }}</n-tag> | |||
<n-tag size="small" type="info">{{ $t("右键->检查->控制台->查看当前距离") }}</n-tag> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
<!-- 卡片5: 全屏控制 --> | |||
<div class="card"> | |||
<div class="card-icon"> | |||
<div class="icon-circle"> | |||
<span class="icon-symbol">🖐️</span> | |||
</div> | |||
</div> | |||
<div class="card-content"> | |||
<h3 class="card-title">{{ $t('全屏控制') }}</h3> | |||
<p class="card-description">{{ $t('四指并拢发送按键') }}</p> | |||
<div class="card-extra"> | |||
<div class="keyboard-input"> | |||
<n-input | |||
:value="app_store.config.four_fingers_up_send || 'f'" | |||
readonly | |||
:placeholder="$t('点击设置快捷键')" | |||
@click="listenForKey" | |||
:status="isListening ? 'warning' : undefined" | |||
size="small" | |||
> | |||
<template #suffix> | |||
<span>{{ isListening ? $t("请按下按键...") : $t("点击设置") }}</span> | |||
</template> | |||
</n-input> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
<!-- 卡片6: 退格 --> | |||
<div class="card"> | |||
<div class="card-icon"> | |||
<div class="icon-circle"> | |||
<span class="icon-symbol">🔙</span> | |||
</div> | |||
</div> | |||
<div class="card-content"> | |||
<h3 class="card-title">{{ $t('退格') }}</h3> | |||
<p class="card-description">{{ $t('发送退格键') }}</p> | |||
</div> | |||
</div> | |||
<!-- 卡片7: 开始语音识别 --> | |||
<div class="card"> | |||
<div class="card-icon"> | |||
<div class="icon-circle"> | |||
<span class="icon-symbol">🎤</span> | |||
</div> | |||
</div> | |||
<div class="card-content"> | |||
<h3 class="card-title">{{ $t('开始语音识别') }}</h3> | |||
<p class="card-description">{{ $t('六指手势开始语音识别') }}</p> | |||
</div> | |||
</div> | |||
<!-- 卡片8: 结束语音识别 --> | |||
<div class="card"> | |||
<div class="card-icon"> | |||
<div class="icon-circle"> | |||
<span class="icon-symbol">👊</span> | |||
</div> | |||
</div> | |||
<div class="card-content"> | |||
<h3 class="card-title">{{ $t('结束语音识别') }}</h3> | |||
<p class="card-description">{{ $t('拳头手势结束语音识别') }}</p> | |||
</div> | |||
</div> | |||
<!-- 卡片9: 暂停/继续 --> | |||
<div class="card"> | |||
<div class="card-icon"> | |||
<div class="icon-circle"> | |||
<span class="icon-symbol">✋</span> | |||
</div> | |||
</div> | |||
<div class="card-content"> | |||
<h3 class="card-title">{{ $t('暂停/继续') }}</h3> | |||
<p class="card-description">{{ $t('单手张开1.5秒 暂停/继续 手势识别') }}</p> | |||
</div> | |||
</div> | |||
</div> | |||
</n-card> | |||
</div> | |||
</div> | |||
</template> | |||
<script setup lang="ts"> | |||
import { use_app_store } from "@/store/app"; | |||
import { ref } from "vue"; | |||
const app_store = use_app_store(); | |||
const isListening = ref(false); | |||
const listenForKey = () => { | |||
isListening.value = true; | |||
// ...(原有监听键盘的代码) | |||
}; | |||
</script> | |||
<style scoped lang="scss"> | |||
.guide-container { | |||
padding: 16px; | |||
box-sizing: border-box; | |||
} | |||
.header-bar { | |||
margin-bottom: 24px; | |||
} | |||
.title-container { | |||
padding: 0 16px; | |||
} | |||
.view-title { | |||
font-size: 24px; | |||
font-weight: 700; | |||
margin: 0; | |||
color: #333; | |||
} | |||
.view-description { | |||
font-size: 14px; | |||
color: #666; | |||
margin-top: 8px; | |||
} | |||
.main-content { | |||
display: flex; | |||
flex-direction: column; | |||
} | |||
.guide-card { | |||
background-color: #fff; | |||
border-radius: 8px; | |||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | |||
overflow: hidden; | |||
} | |||
.gesture-grid { | |||
display: grid; | |||
grid-template-columns: repeat(3, 1fr); | |||
gap: 16px; | |||
padding: 16px; | |||
} | |||
.card { | |||
display: flex; | |||
flex-direction: column; | |||
background: #fff; | |||
border: 1px solid #f0f0f0; | |||
border-radius: 8px; | |||
overflow: hidden; | |||
transition: all 0.3s ease; | |||
&:hover { | |||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); | |||
transform: translateY(-2px); | |||
} | |||
} | |||
.card-icon { | |||
padding: 16px; | |||
display: flex; | |||
justify-content: center; | |||
} | |||
.icon-circle { | |||
width: 64px; | |||
height: 64px; | |||
border-radius: 50%; | |||
background: #e6f7ff; | |||
display: flex; | |||
align-items: center; | |||
justify-content: center; | |||
} | |||
.icon-symbol { | |||
font-size: 32px; | |||
color: #1890ff; | |||
} | |||
.card-content { | |||
padding: 0 16px 16px; | |||
} | |||
.card-title { | |||
font-size: 16px; | |||
font-weight: 600; | |||
color: #333; | |||
margin: 8px 0 4px; | |||
} | |||
.card-description { | |||
font-size: 14px; | |||
color: #666; | |||
margin: 4px 0 8px; | |||
line-height: 1.4; | |||
} | |||
.card-extra { | |||
border-top: 1px solid #f0f0f0; | |||
padding-top: 12px; | |||
margin-top: 12px; | |||
a { | |||
color: #1890ff; | |||
text-decoration: none; | |||
margin-right: 12px; | |||
&:hover { | |||
text-decoration: underline; | |||
} | |||
} | |||
} | |||
.setting-control { | |||
display: flex; | |||
align-items: center; | |||
flex-wrap: wrap; | |||
gap: 8px; | |||
margin-bottom: 8px; | |||
.n-input-number { | |||
width: 100px; | |||
} | |||
} | |||
.hint-tags { | |||
display: flex; | |||
flex-wrap: wrap; | |||
gap: 8px; | |||
margin-top: 8px; | |||
.n-tag { | |||
padding: 4px 8px; | |||
background: #f0f9ff; | |||
border-color: #91d5ff; | |||
color: #1890ff; | |||
} | |||
} | |||
.keyboard-input { | |||
width: 100%; | |||
.n-input { | |||
width: 100%; | |||
} | |||
} | |||
@media (max-width: 992px) { | |||
.gesture-grid { | |||
grid-template-columns: repeat(2, 1fr); | |||
} | |||
} | |||
@media (max-width: 576px) { | |||
.gesture-grid { | |||
grid-template-columns: 1fr; | |||
} | |||
.setting-control { | |||
flex-direction: column; | |||
align-items: flex-start; | |||
} | |||
} | |||
</style> |
@ -0,0 +1,367 @@ | |||
<template> | |||
<div class="home-container"> | |||
<!-- 顶部统计数据卡片 --> | |||
<div class="stats-row"> | |||
<div class="stat-card"> | |||
<div class="stat-icon">👋</div> | |||
<div class="stat-data"> | |||
<div class="value">92%</div> | |||
<div class="label">识别准确率</div> | |||
</div> | |||
</div> | |||
<div class="stat-card"> | |||
<div class="stat-icon">⚡</div> | |||
<div class="stat-data"> | |||
<div class="value">38ms</div> | |||
<div class="label">平均响应时间</div> | |||
</div> | |||
</div> | |||
<div class="stat-card"> | |||
<div class="stat-icon">🔍</div> | |||
<div class="stat-data"> | |||
<div class="value">312</div> | |||
<div class="label">今日识别次数</div> | |||
</div> | |||
</div> | |||
</div> | |||
<!-- 主内容区域 --> | |||
<div class="main-content"> | |||
<!-- 左侧摄像头预览区域 --> | |||
<div class="preview-card"> | |||
<div class="preview-header"> | |||
<h3>手势识别预览</h3> | |||
<!-- 运行开关移到预览区域顶部 --> | |||
<n-switch | |||
v-model:value="app_store.mission_running" | |||
size="large" | |||
:rail-style="railStyle" | |||
> | |||
<template #checked>{{ $t("运行中") }}</template> | |||
<template #unchecked>{{ $t("已停止") }}</template> | |||
</n-switch> | |||
</div> | |||
<!-- 摄像头预览组件 --> | |||
<VideoDetector /> | |||
</div> | |||
<!-- 右侧控制面板 --> | |||
<div class="control-panel"> | |||
<!-- 系统设置 --> | |||
<div class="settings-section"> | |||
<h3 class="section-title">手势识别控制</h3> | |||
<div class="setting-item"> | |||
<div class="setting-label"> | |||
<n-icon size="20"> | |||
<Browser /> | |||
</n-icon> | |||
<span>{{ $t("显示识别窗口") }}</span> | |||
</div> | |||
<n-switch v-model:value="app_store.config.show_window" /> | |||
</div> | |||
<div class="setting-item"> | |||
<div class="setting-label"> | |||
<AutoStart /> | |||
</div> | |||
</div> | |||
<div class="setting-item"> | |||
<div class="setting-label"> | |||
<n-icon size="20"> | |||
<Camera /> | |||
</n-icon> | |||
<span>{{ $t("摄像头选择") }}</span> | |||
</div> | |||
<n-select | |||
v-model:value="app_store.config.selected_camera_id" | |||
:options="camera_options" | |||
:disabled="app_store.mission_running" | |||
style="width: 100%" | |||
/> | |||
</div> | |||
</div> | |||
<!-- 手势库展示 --> | |||
<div class="gesture-gallery"> | |||
<div class="gallery-header"> | |||
<div>手势库</div> | |||
<button class="add-btn">+ 添加手势</button> | |||
</div> | |||
<div class="gesture-grid"> | |||
<div class="gesture-item" v-for="i in 6" :key="i"> | |||
<div class="gesture-icon">👌</div> | |||
<div class="gesture-name">OK手势</div> | |||
<div class="gesture-action">暂停媒体</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</template> | |||
<script setup lang="ts"> | |||
import AutoStart from "@/components/AutoStart.vue"; | |||
import VideoDetector from "@/hand_landmark/VideoDetector.vue"; | |||
import { use_app_store } from "@/store/app"; | |||
import { Browser, Camera } from "@icon-park/vue-next"; | |||
import { computed, onMounted } from "vue"; | |||
const is_dev = computed(() => import.meta.env.DEV); | |||
const app_store = use_app_store(); | |||
// 计算属性:摄像头选项 | |||
const camera_options = computed(() => { | |||
return app_store.cameras.map((camera) => ({ | |||
label: camera.label || `摄像头 ${camera.deviceId.slice(0, 4)}`, | |||
value: camera.deviceId, | |||
})); | |||
}); | |||
const getCameras = async () => { | |||
try { | |||
// 申请获取摄像头权限 | |||
const stream = await navigator.mediaDevices.getUserMedia({ | |||
video: true, | |||
}); | |||
stream.getTracks().forEach((track) => track.stop()); | |||
const devices = await navigator.mediaDevices.enumerateDevices(); | |||
app_store.cameras = devices.filter( | |||
(device) => device.kind === "videoinput" | |||
); | |||
} catch (error) { | |||
console.error("获取摄像头列表失败:", error); | |||
} | |||
}; | |||
onMounted(async () => { | |||
await getCameras(); | |||
}); | |||
</script> | |||
<style scoped lang="scss"> | |||
.home-container { | |||
display: flex; | |||
flex-direction: column; | |||
gap: 24px; | |||
height: 100%; | |||
} | |||
.stats-row { | |||
display: grid; | |||
grid-template-columns: repeat(3, 1fr); | |||
gap: 16px; | |||
} | |||
.stat-card { | |||
background: var(--card-bg); | |||
backdrop-filter: blur(10px); | |||
border-radius: 16px; | |||
padding: 20px; | |||
display: flex; | |||
align-items: center; | |||
gap: 16px; | |||
border: 1px solid var(--border-color); | |||
transition: transform 0.2s, box-shadow 0.2s; | |||
&:hover { | |||
transform: translateY(-5px); | |||
box-shadow: 0 10px 20px var(--shadow-color); | |||
} | |||
} | |||
.stat-icon { | |||
width: 56px; | |||
height: 56px; | |||
background: rgba(14, 165, 233, 0.1); | |||
border-radius: 50%; | |||
display: flex; | |||
align-items: center; | |||
justify-content: center; | |||
font-size: 24px; | |||
color: var(--accent-primary); | |||
} | |||
.stat-data { | |||
flex: 1; | |||
} | |||
.value { | |||
font-size: 24px; | |||
font-weight: 700; | |||
color: var(--text-primary); | |||
} | |||
.label { | |||
font-size: 14px; | |||
color: var(--text-secondary); | |||
margin-top: 4px; | |||
} | |||
.main-content { | |||
display: grid; | |||
grid-template-columns: 1.5fr 1fr; | |||
gap: 24px; | |||
height: calc(100% - 120px); | |||
} | |||
.preview-card { | |||
background: var(--card-bg); | |||
backdrop-filter: blur(10px); | |||
border-radius: 16px; | |||
padding: 24px; | |||
border: 1px solid var(--border-color); | |||
display: flex; | |||
flex-direction: column; | |||
gap: 16px; | |||
} | |||
.preview-header { | |||
display: flex; | |||
justify-content: space-between; | |||
align-items: center; | |||
h3 { | |||
font-size: 18px; | |||
font-weight: 600; | |||
color: var(--text-primary); | |||
margin: 0; | |||
} | |||
} | |||
.control-panel { | |||
display: flex; | |||
flex-direction: column; | |||
gap: 24px; | |||
} | |||
.settings-section { | |||
background: var(--card-bg); | |||
backdrop-filter: blur(10px); | |||
border-radius: 16px; | |||
padding: 24px; | |||
border: 1px solid var(--border-color); | |||
} | |||
.section-title { | |||
font-size: 18px; | |||
font-weight: 600; | |||
color: var(--text-primary); | |||
margin-top: 0; | |||
margin-bottom: 20px; | |||
} | |||
.setting-item { | |||
display: flex; | |||
justify-content: space-between; | |||
align-items: center; | |||
padding: 12px 0; | |||
border-bottom: 1px solid var(--border-color); | |||
&:last-child { | |||
border-bottom: none; | |||
} | |||
} | |||
.setting-label { | |||
display: flex; | |||
align-items: center; | |||
gap: 8px; | |||
font-size: 14px; | |||
color: var(--text-primary); | |||
} | |||
.gesture-gallery { | |||
background: var(--card-bg); | |||
backdrop-filter: blur(10px); | |||
border-radius: 16px; | |||
padding: 24px; | |||
border: 1px solid var(--border-color); | |||
} | |||
.gallery-header { | |||
display: flex; | |||
justify-content: space-between; | |||
align-items: center; | |||
margin-bottom: 20px; | |||
div { | |||
font-size: 18px; | |||
font-weight: 600; | |||
color: var(--text-primary); | |||
} | |||
} | |||
.add-btn { | |||
background: rgba(14, 165, 233, 0.15); | |||
border: none; | |||
border-radius: 8px; | |||
padding: 8px 15px; | |||
color: var(--accent-primary); | |||
font-weight: 500; | |||
cursor: pointer; | |||
transition: all 0.2s; | |||
&:hover { | |||
background: rgba(14, 165, 233, 0.25); | |||
} | |||
} | |||
.gesture-grid { | |||
display: grid; | |||
grid-template-columns: repeat(2, 1fr); | |||
gap: 15px; | |||
margin-top: 10px; | |||
} | |||
.gesture-item { | |||
background: var(--bg-tertiary); | |||
border-radius: 12px; | |||
padding: 15px; | |||
display: flex; | |||
flex-direction: column; | |||
align-items: center; | |||
transition: all 0.2s; | |||
&:hover { | |||
transform: translateY(-3px); | |||
box-shadow: 0 4px 10px var(--shadow-color); | |||
} | |||
} | |||
.gesture-icon { | |||
font-size: 32px; | |||
margin-bottom: 10px; | |||
} | |||
.gesture-name { | |||
font-weight: 500; | |||
color: var(--text-primary); | |||
} | |||
.gesture-action { | |||
font-size: 13px; | |||
color: var(--text-secondary); | |||
text-align: center; | |||
margin-top: 5px; | |||
} | |||
.ad-container { | |||
height: 200px; | |||
background: var(--card-bg); | |||
backdrop-filter: blur(10px); | |||
border-radius: 16px; | |||
overflow: hidden; | |||
iframe { | |||
border: none; | |||
background-color: var(--bg-secondary); | |||
border-radius: 16px; | |||
} | |||
} | |||
</style> |
@ -0,0 +1,606 @@ | |||
<script setup lang="ts"> | |||
import pyApi from "@/py_api"; | |||
import use_app_store from "@/store/app"; | |||
import { getVersion } from "@tauri-apps/api/app"; | |||
import { | |||
getCurrentWindow, | |||
LogicalPosition, | |||
LogicalSize, | |||
} from "@tauri-apps/api/window"; | |||
import { LazyStore } from "@tauri-apps/plugin-store"; | |||
import { onMounted, ref, watch, computed } from "vue"; | |||
import { useRoute, useRouter } from "vue-router"; | |||
const route = useRoute(); | |||
const router = useRouter(); | |||
const is_dev = import.meta.env.DEV; | |||
const appVersion = ref(""); | |||
const app_store = use_app_store(); | |||
const ready = ref(false); | |||
const darkTheme = ref(false); | |||
const randomIconPath = ref(""); | |||
// 新UI中需要的数据 | |||
const settings = ref({ | |||
autostart: true, | |||
showWindow: true | |||
}); | |||
// 当前视图计算 | |||
const currentView = computed(() => route.name || 'home'); | |||
// 视图标题计算 | |||
const viewTitle = computed(() => { | |||
switch (currentView.value) { | |||
case 'home': return '控制面板'; | |||
case 'gestures': return '手势管理'; | |||
case 'settings': return '设置中心'; | |||
case 'help': return '帮助文档'; | |||
default: return 'WaveControl'; | |||
} | |||
}); | |||
// 在组件挂载时生成随机图标 | |||
onMounted(() => { | |||
const randomIndex = Math.floor(Math.random() * 9) + 1; | |||
randomIconPath.value = `/wavecontrol${randomIndex}.jpg`; | |||
}); | |||
// 初始化逻辑 | |||
onMounted(async () => { | |||
ready.value = await pyApi.ready(); | |||
const timer = setInterval(async () => { | |||
ready.value = await pyApi.ready(); | |||
if (ready.value) { | |||
clearInterval(timer); | |||
} | |||
}, 5000); | |||
await getCurrentWindow().onCloseRequested(async () => { | |||
const factor = await getCurrentWindow().scaleFactor(); | |||
const position = (await getCurrentWindow().innerPosition()).toLogical(factor); | |||
const size = (await getCurrentWindow().innerSize()).toLogical(factor); | |||
await window_store_json.set("window_state", { | |||
x: position.x, | |||
y: position.y, | |||
width: size.width, | |||
height: size.height, | |||
}); | |||
if (!is_dev) { | |||
await pyApi.shutdown(); | |||
} | |||
}); | |||
}); | |||
// 窗口恢复上一次位置 | |||
const window_store_json = new LazyStore("window_state.json"); | |||
onMounted(async () => { | |||
appVersion.value = await getVersion(); | |||
const window_state = await window_store_json.get("window_state"); | |||
if (window_state) { | |||
let new_x = window_state.x; | |||
let new_y = window_state.y; | |||
const screen_width = window.screen.width; | |||
const screen_height = window.screen.height; | |||
// 如果窗口位置超出屏幕,则将窗口位置设置为100,100 | |||
if (new_x <= 0) new_x = 100; | |||
if (new_x >= screen_width) new_x = 100 | |||
if (new_y <= 0) new_y = 100; | |||
if (new_y >= screen_height) new_y = 100; | |||
getCurrentWindow().setPosition(new LogicalPosition(new_x, new_y)); | |||
getCurrentWindow().setSize( | |||
new LogicalSize(window_state.width, window_state.height) | |||
); | |||
} | |||
}); | |||
// app_store 数据加载 | |||
const app_store_json = new LazyStore("settings.json"); | |||
onMounted(async () => { | |||
const config_data = await app_store_json.get("config"); | |||
console.log("config_data", config_data); | |||
if (config_data) { | |||
Object.assign(app_store.config, JSON.parse(JSON.stringify(config_data))); | |||
} | |||
}); | |||
watch( | |||
() => app_store.config, | |||
async (value) => { | |||
await app_store_json.set("config", value); | |||
app_store_json.save(); | |||
}, | |||
{ deep: true } | |||
); | |||
// 通知 | |||
import { | |||
isPermissionGranted, | |||
requestPermission, | |||
} from "@tauri-apps/plugin-notification"; | |||
onMounted(async () => { | |||
let permissionGranted = await isPermissionGranted(); | |||
if (!permissionGranted) { | |||
const permission = await requestPermission(); | |||
permissionGranted = permission === "granted"; | |||
} | |||
}); | |||
// 使用默认浏览器打开 iframe 中的 <a> 标签 | |||
import { openUrl } from "@tauri-apps/plugin-opener"; | |||
window.addEventListener("message", async function (e) { | |||
const url = e.data; | |||
if (url) { | |||
await openUrl(url); | |||
} | |||
}); | |||
// 创建子窗口 | |||
import { createSubWindow } from "@/utils/subWindow"; | |||
const subWindow = ref(null); | |||
onMounted(async () => { | |||
if (!subWindow.value) { | |||
subWindow.value = await createSubWindow("/sub-window", "subWindow"); | |||
} | |||
}); | |||
// 操作方法 | |||
const toggleTheme = () => { | |||
darkTheme.value = !darkTheme.value; | |||
}; | |||
const switchView = (viewName: string) => { | |||
router.push({ name: viewName }); | |||
}; | |||
const toggleSetting = (setting: string) => { | |||
settings.value[setting] = !settings.value[setting]; | |||
}; | |||
// 状态计算属性 | |||
const statusClass = computed(() => ({ | |||
'status-active': ready.value, | |||
'status-inactive': !ready.value | |||
})); | |||
const statusText = computed(() => { | |||
return ready.value ? '运行中' : '已停止'; | |||
}); | |||
</script> | |||
<template> | |||
<div class="app-container" :class="{'dark-theme': darkTheme}"> | |||
<!-- 加载状态 --> | |||
<div v-if="!ready" class="loading-overlay"> | |||
<div class="loader-container"> | |||
<div class="spinner"></div> | |||
<div class="loading-text">手势识别模块加载中...</div> | |||
</div> | |||
</div> | |||
<!-- 主界面 --> | |||
<div v-else class="main-layout"> | |||
<!-- 左侧导航 --> | |||
<div class="sidebar"> | |||
<div class="branding"> | |||
<!-- 使用随机图标路径 --> | |||
<img :src="randomIconPath" alt="WaveControl Logo" class="logo" /> | |||
<div class="app-info"> | |||
<div class="app-name">WaveControl</div> | |||
</div> | |||
</div> | |||
<div class="nav-items"> | |||
<div class="nav-item" :class="{active: currentView === 'home'}" @click="switchView('home')"> | |||
<i class="icon">🏠</i> | |||
<span>主控台</span> | |||
</div> | |||
<div class="nav-item" :class="{active: currentView === 'guide'}" @click="switchView('guide')"> | |||
<i class="icon">👋</i> | |||
<span>手势管理</span> | |||
</div> | |||
<div class="nav-item" :class="{active: currentView === 'settings'}" @click="switchView('settings')"> | |||
<i class="icon">⚙️</i> | |||
<span>设置</span> | |||
</div> | |||
<div class="nav-item" :class="{active: currentView === 'help'}" @click="switchView('help')"> | |||
<i class="icon">❓</i> | |||
<span>帮助</span> | |||
</div> | |||
</div> | |||
<div class="status-card"> | |||
<div class="status-indicator" :class="statusClass"> | |||
{{ statusText }} | |||
</div> | |||
<div class="fps-counter"> | |||
<span>FPS</span> | |||
<span class="value">60</span> | |||
</div> | |||
</div> | |||
<div class="contributors"> | |||
<div v-if="app_store.is_macos()"> | |||
<a | |||
class="contributor-link" | |||
href="https://gitee.com/wydhhh/software-engineering" | |||
target="_blank" | |||
> | |||
@backpack</a | |||
> | |||
</div> | |||
<div v-else class="contributor">@ninjakelly</div> | |||
</div> | |||
</div> | |||
<!-- 主内容区 --> | |||
<div class="content-area"> | |||
<div class="header-bar"> | |||
<div class="title-container"> | |||
<h1 class="view-title">{{ viewTitle }}</h1> | |||
<div class="view-description">隔空手势控制系统</div> | |||
</div> | |||
<div class="controls"> | |||
<button class="theme-toggle" @click="toggleTheme"> | |||
<i class="icon">{{ darkTheme ? '☀️' : '🌙' }}</i> | |||
</button> | |||
</div> | |||
</div> | |||
<div class="main-content"> | |||
<router-view /> | |||
</div> | |||
</div> | |||
</div> | |||
<DevTool v-if="is_dev" /> | |||
</div> | |||
</template> | |||
<style scoped lang="scss"> | |||
.app-container { | |||
font-family: 'Segoe UI', system-ui, sans-serif; | |||
height: 100vh; | |||
width: 100vw; | |||
overflow: hidden; | |||
transition: background-color 0.3s, color 0.3s; | |||
&.dark-theme { | |||
--bg-primary: #0f172a; | |||
--bg-secondary: #1e293b; | |||
--bg-tertiary: #334155; | |||
--text-primary: #f1f5f9; | |||
--text-secondary: #94a3b8; | |||
--accent-primary: #0ea5e9; | |||
--accent-secondary: #7dd3fc; | |||
--hover-bg: rgba(148, 163, 184, 0.12); | |||
--border-color: #334155; | |||
--card-bg: rgba(30, 41, 59, 0.7); | |||
--shadow-color: rgba(0, 0, 0, 0.4); | |||
--status-active: #0ea5e9; | |||
--status-inactive: #475569; | |||
} | |||
&:not(.dark-theme) { | |||
--bg-primary: #f8fafc; | |||
--bg-secondary: #f1f5f9; | |||
--bg-tertiary: #e2e8f0; | |||
--text-primary: #0f172a; | |||
--text-secondary: #475569; | |||
--accent-primary: #0284c7; | |||
--accent-secondary: #0ea5e9; | |||
--hover-bg: rgba(15, 23, 42, 0.05); | |||
--border-color: #cbd5e1; | |||
--card-bg: rgba(241, 245, 249, 0.7); | |||
--shadow-color: rgba(0, 0, 0, 0.08); | |||
--status-active: #0284c7; | |||
--status-inactive: #94a3b8; | |||
} | |||
background-color: var(--bg-primary); | |||
color: var(--text-primary); | |||
} | |||
.loading-overlay { | |||
position: absolute; | |||
top: 0; | |||
left: 0; | |||
width: 100%; | |||
height: 100%; | |||
display: flex; | |||
align-items: center; | |||
justify-content: center; | |||
background-color: var(--bg-primary); | |||
z-index: 100; | |||
} | |||
.loader-container { | |||
display: flex; | |||
flex-direction: column; | |||
align-items: center; | |||
gap: 20px; | |||
} | |||
.spinner { | |||
width: 50px; | |||
height: 50px; | |||
border: 4px solid rgba(14, 165, 233, 0.2); | |||
border-radius: 50%; | |||
border-top: 4px solid var(--accent-primary); | |||
animation: spin 1s linear infinite; | |||
} | |||
@keyframes spin { | |||
0% { transform: rotate(0deg); } | |||
100% { transform: rotate(360deg); } | |||
} | |||
.loading-text { | |||
font-size: 18px; | |||
font-weight: 500; | |||
color: var(--text-primary); | |||
} | |||
.main-layout { | |||
display: flex; | |||
height: 100vh; | |||
} | |||
/* 左侧导航样式 */ | |||
.sidebar { | |||
width: 260px; | |||
background-color: var(--bg-secondary); | |||
display: flex; | |||
flex-direction: column; | |||
padding: 25px 0; | |||
border-right: 1px solid var(--border-color); | |||
z-index: 10; | |||
} | |||
.branding { | |||
display: flex; | |||
align-items: center; | |||
padding: 0 20px 20px; | |||
} | |||
.logo { | |||
width: 42px; | |||
height: 42px; | |||
border-radius: 10px; | |||
margin-right: 12px; | |||
background: linear-gradient(135deg, var(--accent-primary), #7dd3fc); | |||
padding: 5px; | |||
} | |||
.app-info { | |||
display: flex; | |||
flex-direction: column; | |||
} | |||
.app-name { | |||
font-size: 18px; | |||
font-weight: 700; | |||
letter-spacing: 0.5px; | |||
color: var(--text-primary); | |||
} | |||
.app-version { | |||
font-size: 13px; | |||
color: var(--text-secondary); | |||
font-weight: 500; | |||
opacity: 0.8; | |||
} | |||
.nav-items { | |||
display: flex; | |||
flex-direction: column; | |||
gap: 8px; | |||
padding: 0 15px; | |||
flex: 1; | |||
} | |||
.nav-item { | |||
display: flex; | |||
align-items: center; | |||
padding: 12px 20px; | |||
border-radius: 10px; | |||
cursor: pointer; | |||
transition: all 0.2s; | |||
gap: 14px; | |||
font-size: 16px; | |||
color: var(--text-secondary); | |||
&:hover { | |||
background-color: var(--hover-bg); | |||
color: var(--text-primary); | |||
} | |||
&.active { | |||
background: rgba(14, 165, 233, 0.15); | |||
color: var(--accent-primary); | |||
font-weight: 500; | |||
.icon { | |||
color: var(--accent-primary); | |||
} | |||
} | |||
} | |||
.icon { | |||
font-size: 18px; | |||
} | |||
.status-card { | |||
background: var(--card-bg); | |||
border-radius: 12px; | |||
padding: 20px; | |||
margin: 20px; | |||
backdrop-filter: blur(10px); | |||
border: 1px solid var(--border-color); | |||
box-shadow: 0 4px 6px var(--shadow-color); | |||
} | |||
.status-indicator { | |||
display: inline-flex; | |||
align-items: center; | |||
font-size: 14px; | |||
font-weight: 500; | |||
padding: 6px 12px; | |||
border-radius: 20px; | |||
&::before { | |||
content: ''; | |||
display: inline-block; | |||
width: 8px; | |||
height: 8px; | |||
border-radius: 50%; | |||
margin-right: 8px; | |||
} | |||
&.status-active { | |||
background: rgba(14, 165, 233, 0.2); | |||
color: var(--accent-primary); | |||
&::before { | |||
background-color: var(--accent-primary); | |||
} | |||
} | |||
&.status-inactive { | |||
background: rgba(148, 163, 184, 0.2); | |||
color: var(--text-secondary); | |||
&::before { | |||
background-color: var(--text-secondary); | |||
} | |||
} | |||
} | |||
.fps-counter { | |||
margin-top: 15px; | |||
display: flex; | |||
align-items: baseline; | |||
gap: 8px; | |||
span { | |||
font-size: 14px; | |||
color: var(--text-secondary); | |||
} | |||
.value { | |||
font-size: 24px; | |||
font-weight: 600; | |||
color: var(--accent-primary); | |||
} | |||
} | |||
.contributors { | |||
padding: 20px; | |||
color: var(--text-secondary); | |||
font-size: 14px; | |||
display: flex; | |||
flex-direction: column; | |||
gap: 15px; | |||
.contributor { | |||
padding: 5px 12px; | |||
border-radius: 6px; | |||
background-color: var(--bg-tertiary); | |||
} | |||
.contributor-link { | |||
color: var(--accent-primary); | |||
text-decoration: none; | |||
&:hover { | |||
text-decoration: underline; | |||
} | |||
} | |||
} | |||
/* 主内容区域样式 */ | |||
.content-area { | |||
flex: 1; | |||
display: flex; | |||
flex-direction: column; | |||
overflow: hidden; | |||
} | |||
.header-bar { | |||
display: flex; | |||
justify-content: space-between; | |||
align-items: center; | |||
padding: 25px 30px 15px; | |||
border-bottom: 1px solid var(--border-color); | |||
} | |||
.title-container { | |||
display: flex; | |||
flex-direction: column; | |||
} | |||
.view-title { | |||
font-size: 28px; | |||
font-weight: 700; | |||
margin: 0; | |||
color: var(--text-primary); | |||
line-height: 1.2; | |||
} | |||
.view-description { | |||
font-size: 15px; | |||
color: var(--text-secondary); | |||
margin-top: 6px; | |||
} | |||
.controls { | |||
display: flex; | |||
gap: 15px; | |||
} | |||
.theme-toggle { | |||
width: 40px; | |||
height: 40px; | |||
border-radius: 50%; | |||
background: var(--bg-secondary); | |||
border: none; | |||
display: flex; | |||
align-items: center; | |||
justify-content: center; | |||
cursor: pointer; | |||
font-size: 18px; | |||
color: var(--text-primary); | |||
transition: all 0.2s; | |||
&:hover { | |||
background: var(--hover-bg); | |||
transform: scale(1.05); | |||
} | |||
} | |||
.main-content { | |||
flex: 1; | |||
overflow: auto; | |||
padding: 30px; | |||
} | |||
/* 添加路由视图过渡效果 */ | |||
.router-view-container { | |||
transition: opacity 0.3s ease; | |||
> * { | |||
animation: fadeIn 0.5s ease; | |||
} | |||
} | |||
@keyframes fadeIn { | |||
from { opacity: 0; transform: translateY(10px); } | |||
to { opacity: 1; transform: translateY(0); } | |||
} | |||
</style> |
@ -0,0 +1,93 @@ | |||
<template> | |||
<div class="container-sub-window"> | |||
<CircleProgress | |||
:percentage="app_store.sub_windows.progress" | |||
:size="100" | |||
:text="app_store.flag_detecting ? '暂停检测' : '继续检测'" | |||
:color="app_store.flag_detecting ? '#F56C6C' : '#67C23A'" | |||
/> | |||
<div style="height: 30px"> | |||
<span>{{ app_store.sub_windows.notification }}</span> | |||
</div> | |||
</div> | |||
</template> | |||
<script setup lang="ts"> | |||
import CircleProgress from "@/components/CircleProgress.vue"; | |||
import use_app_store from "@/store/app"; | |||
import { getCurrentWindow, LogicalPosition } from "@tauri-apps/api/window"; | |||
import { computed, ref, watch } from "vue"; | |||
const app_store = use_app_store(); | |||
const display_progress = ref(false); | |||
const display_notification = computed(() => { | |||
return !display_progress.value; | |||
}); | |||
let hideTimer: number | null = null; | |||
async function show_window() { | |||
await getCurrentWindow().show(); | |||
} | |||
async function hide_window() { | |||
await getCurrentWindow().hide(); | |||
} | |||
watch( | |||
() => app_store.sub_windows.x, | |||
(newVal) => { | |||
getCurrentWindow().setPosition( | |||
new LogicalPosition(newVal, app_store.sub_windows.y) | |||
); | |||
} | |||
); | |||
// 显示 sub-window | |||
watch( | |||
() => app_store.sub_windows.progress, | |||
(newVal) => { | |||
if (newVal) { | |||
display_progress.value = true; | |||
show_window(); | |||
// 清除之前的定时器 | |||
if (hideTimer) { | |||
clearTimeout(hideTimer); | |||
} | |||
// 设置新的定时器 | |||
hideTimer = setTimeout(() => { | |||
hide_window(); | |||
}, 300); | |||
} | |||
} | |||
); | |||
watch( | |||
() => app_store.sub_windows.notification, | |||
(newVal) => { | |||
if (newVal) { | |||
display_progress.value = false; | |||
show_window(); | |||
// 清除之前的定时器 | |||
if (hideTimer) { | |||
clearTimeout(hideTimer); | |||
} | |||
// 设置新的定时器 | |||
hideTimer = setTimeout(() => { | |||
hide_window(); | |||
app_store.sub_windows.notification = ""; | |||
}, 1000); | |||
} | |||
} | |||
); | |||
</script> | |||
<style lang="scss" scoped> | |||
.container-sub-window { | |||
width: 100%; | |||
height: 100%; | |||
display: flex; | |||
justify-content: center; | |||
align-items: center; | |||
flex-direction: column; | |||
} | |||
</style> |