From 818527593771b238cfcd0848390151527d012729 Mon Sep 17 00:00:00 2001 From: Yuhang Wu Date: Wed, 14 Oct 2026 14:54:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=89=98=E7=9B=98=E5=8A=9F=E8=83=BD=E4=B8=8E=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=AF=B9=E8=AF=9D=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增系统托盘图标及菜单,支持显示窗口、启动/停止穿透、退出应用 - 点击窗口关闭按钮或使用系统关闭快捷键时,弹出确认对话框,可选择最小化到托盘或直接退出 - 托盘菜单随语言切换同步更新,且根据穿透运行状态自动启用/禁用对应选项 - 升级应用版本至v1.1.0,添加Tauri tray-icon特性及相关权限配置 --- docs/CHANGELOG.md | 26 +++++ docs/Frpilot开发提示词.md | 12 +++ package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/default.json | 3 +- src/App.vue | 6 ++ src/components/CloseConfirmModal.vue | 100 +++++++++++++++++++ src/components/MacTitleBar.vue | 6 +- src/components/TitleBarActions.vue | 6 +- src/composables/useTray.ts | 144 +++++++++++++++++++++++++++ src/i18n/locales/en-US.json | 13 +++ src/i18n/locales/zh-CN.json | 13 +++ src/layout/Layout.vue | 4 + src/stores/frpc.ts | 38 ++++++- src/stores/window.ts | 97 ++++++++++++++++++ src/types/components.d.ts | 3 + src/utils/window.ts | 22 ++++ 17 files changed, 489 insertions(+), 8 deletions(-) create mode 100644 src/components/CloseConfirmModal.vue create mode 100644 src/composables/useTray.ts create mode 100644 src/stores/window.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7864017..94f5569 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,32 @@ --- +## v1.1.0(2026-10-14) + +### ✨ 新增功能 + +- **系统托盘**:新增常驻系统托盘图标与菜单(**显示窗口 / 启动穿透 / 停止穿透 / 退出**)。 + - 左键单击托盘图标直接显示并聚焦主窗口,右键单击弹出菜单; + - 「启动穿透」「停止穿透」与仪表盘的一键启动 / 停止共用同一套逻辑,并随运行状态联动:未运行时禁用「停止穿透」;启动成功后自动禁用「启动穿透」、启用「停止穿透」,停止后恢复; + - 「退出」会在停止正在运行的穿透后再退出整个软件; + - 托盘菜单文案随语言切换同步更新。 +- **关闭确认对话框**:点击标题栏关闭按钮(Windows 关闭按钮 / macOS 红绿灯)不再直接退出,改为先弹出对话框,提供两个选项: + - **最小化到系统托盘**:隐藏窗口、保持程序后台运行,可从托盘随时恢复; + - **直接退出**:停止穿透并完全退出软件; + - Windows 与 macOS 行为一致,同时拦截 `Alt+F4` 等系统原生关闭,一并走确认流程。 + +### 🎨 优化 + +- **更新提示**:顶部栏「检查更新」按钮新增「有新版本」圆点徽标;关闭更新弹窗后保留已检测到的版本,再次点击即可重新打开,避免重复联网检查。 +- **标题栏**:以应用图标替换原先的文字 / 渐变方块 Logo。 +- **macOS 窗口**:适配无边框窗口的圆角与透明背景,消除首帧方角闪烁;窗口失焦时红绿灯自动置灰。 + +### 🧱 工程 + +- 应用版本升级至 **v1.1.0**;`tauri` 启用 `tray-icon` 特性,capabilities 新增 `core:app:allow-default-window-icon` 权限。 +- 更新端点增加 GitHub 直连作为镜像备用;修正应用名称与标识符大小写(统一为 `Frpilot`)。 +- 重写 README,并补充仪表盘 / 服务器 / 隧道 / 下载 / 设置等页面的界面截图。 + ## v1.0.0(2026-10-14) ### 🎉 首个正式版本发布! diff --git a/docs/Frpilot开发提示词.md b/docs/Frpilot开发提示词.md index 8bd8660..c001f20 100644 --- a/docs/Frpilot开发提示词.md +++ b/docs/Frpilot开发提示词.md @@ -127,3 +127,15 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下: 开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发 +#### 3.7 系统托盘功能开发 + +- 这是Tauri2文档里面关于系统托盘的指示:https://tauri.app/zh-cn/learn/system-tray/ +- 使用文档中前端的方式开发系统托盘+托盘图标 +- 托盘菜单内容暂定四项,显示窗口、启动穿透、停止穿透、退出。 +- 点击显示窗口,窗口显示 +- 穿透未启动时,停止穿透禁止操作,点击启动穿透,同步仪表盘页面的一键启动操作,启动成功后,启动穿透禁止操作,停止穿透允许操作 +- 穿透启动时,启动穿透禁止操作,停止穿透允许操作,点击停止穿透,同步仪表盘页面的停止操作,停止后,启动穿透允许操作,暂停穿透禁止操作 +- 点击退出,整个程序退出,如果穿透正在启动运行,一并停止并退出软件 +- 点击Header栏里面的关闭按钮,目前是直接退出整个程序,现在改成先出现一个对话框,对话框内有两个单选项分别是最小化到系统托盘、直接退出,如果选择最小化到系统托盘,则保持程序运行状态,隐藏到系统托盘,如果是直接退出,则停止穿透并退出软件(Mac和Windows同步) + +开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发 \ No newline at end of file diff --git a/package.json b/package.json index 54dce3e..beb5c14 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "frpilot", "private": true, - "version": "0.9.0", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a91a4a6..a91c0dd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,7 +22,7 @@ tauri-build = { version = "2", features = [] } tauri-helper = "0.2.1" [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8a40cb6..ed0013a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -18,6 +18,7 @@ "core:window:allow-close", "core:window:allow-hide", "core:window:allow-show", - "core:window:allow-set-focus" + "core:window:allow-set-focus", + "core:app:allow-default-window-icon" ] } diff --git a/src/App.vue b/src/App.vue index 135f8cf..4ef9a93 100644 --- a/src/App.vue +++ b/src/App.vue @@ -8,9 +8,12 @@ import { theme as antdTheme } from "ant-design-vue" import { useAppStore } from "@/stores/app" import { useFrpcStore } from "@/stores/frpc" +import { useWindowStore } from "@/stores/window" +import { initTray } from "@/composables/useTray" const appStore = useAppStore() const frpcStore = useFrpcStore() +const windowStore = useWindowStore() /** 跟随明暗主题切换 Ant Design Vue 的算法 */ const themeConfig = computed(() => ({ @@ -24,6 +27,9 @@ const themeConfig = computed(() => ({ // 应用根组件初始化一次:订阅 frpc 日志/退出事件,离开仪表盘后仍持续收集 onMounted(() => { frpcStore.init() + // 系统托盘(前端 API 方式)与窗口关闭拦截 + initTray() + windowStore.init() }) diff --git a/src/components/CloseConfirmModal.vue b/src/components/CloseConfirmModal.vue new file mode 100644 index 0000000..800d517 --- /dev/null +++ b/src/components/CloseConfirmModal.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/src/components/MacTitleBar.vue b/src/components/MacTitleBar.vue index 18a603e..c14129c 100644 --- a/src/components/MacTitleBar.vue +++ b/src/components/MacTitleBar.vue @@ -10,7 +10,7 @@ class="traffic-light bg-[#ff5f57]" :class="{ 'traffic-light--idle': !focused }" :title="t('titlebar.close')" - @click="closeWindow()" + @click="windowStore.requestClose()" > @@ -45,11 +45,13 @@ diff --git a/src/components/TitleBarActions.vue b/src/components/TitleBarActions.vue index 20ff203..718969d 100644 --- a/src/components/TitleBarActions.vue +++ b/src/components/TitleBarActions.vue @@ -57,7 +57,7 @@ - + @@ -80,8 +80,9 @@ import { useI18n } from "vue-i18n" import type { MenuProps } from "ant-design-vue" import { useAppStore } from "@/stores/app" import { useUpdateStore } from "@/stores/update" +import { useWindowStore } from "@/stores/window" import { languageOptions, type Language } from "@/i18n" -import { closeWindow, minimizeWindow } from "@/utils/window" +import { minimizeWindow } from "@/utils/window" import { openProjectRepo } from "@/api/updater" import { usePlatform } from "@/composables/usePlatform" @@ -90,6 +91,7 @@ const emit = defineEmits<{ settings: [] }>() const { t } = useI18n() const appStore = useAppStore() const updateStore = useUpdateStore() +const windowStore = useWindowStore() const { isMac } = usePlatform() const onLanguageClick: MenuProps["onClick"] = ({ key }) => { diff --git a/src/composables/useTray.ts b/src/composables/useTray.ts new file mode 100644 index 0000000..1acc516 --- /dev/null +++ b/src/composables/useTray.ts @@ -0,0 +1,144 @@ +import { message } from "ant-design-vue" +import { translate } from "@/i18n" +import { useAppStore } from "@/stores/app" +import { useFrpcStore } from "@/stores/frpc" +import { useWindowStore } from "@/stores/window" +import { isTauri } from "@/utils/window" + +/** 是否已初始化,避免重复创建托盘 */ +let initialized = false + +function errMsg(e: unknown) { + return e instanceof Error ? e.message : String(e) +} + +/** + * 初始化系统托盘(Tauri 前端 API 方式,参见 Tauri 文档「系统托盘」)。 + * + * 在应用根组件挂载时调用一次即可。托盘菜单共四项: + * 显示窗口 / 启动穿透 / 停止穿透 / 退出。 + * + * - 「启动穿透」「停止穿透」与仪表盘的一键启动 / 停止共用 store 逻辑, + * 并随 Frpc 运行状态联动禁用:未运行时禁用「停止穿透」,运行中禁用「启动穿透」; + * - 「退出」会先停止正在运行的穿透,再退出整个软件。 + */ +export function initTray() { + if (initialized || !isTauri()) return + initialized = true + void createTray().catch((e) => { + initialized = false + console.error("系统托盘初始化失败:", e) + }) +} + +async function createTray() { + const appStore = useAppStore() + const frpcStore = useFrpcStore() + const windowStore = useWindowStore() + + const [{ TrayIcon }, { Menu, MenuItem, PredefinedMenuItem }, { defaultWindowIcon }] = + await Promise.all([ + import("@tauri-apps/api/tray"), + import("@tauri-apps/api/menu"), + import("@tauri-apps/api/app"), + ]) + + // 显示窗口(始终可用) + const showItem = await MenuItem.new({ + id: "tray-show", + text: translate("tray.show"), + action: () => windowStore.showWindow(), + }) + + // 启动穿透(未运行时可用) + const startItem = await MenuItem.new({ + id: "tray-start", + text: translate("tray.start"), + enabled: frpcStore.canStart, + action: () => void startFromTray(frpcStore), + }) + + // 停止穿透(运行中才可用) + const stopItem = await MenuItem.new({ + id: "tray-stop", + text: translate("tray.stop"), + enabled: frpcStore.canStop, + action: () => void stopFromTray(frpcStore), + }) + + // 退出(先停止穿透,再退出软件) + const quitItem = await MenuItem.new({ + id: "tray-quit", + text: translate("tray.quit"), + action: () => windowStore.quitApp(), + }) + + const [separatorTop, separatorBottom] = await Promise.all([ + PredefinedMenuItem.new({ item: "Separator" }), + PredefinedMenuItem.new({ item: "Separator" }), + ]) + + const menu = await Menu.new({ + items: [showItem, separatorTop, startItem, stopItem, separatorBottom, quitItem], + }) + + // 托盘图标复用应用自身的窗口图标 + const icon = await defaultWindowIcon().catch(() => null) + + await TrayIcon.new({ + id: "frpilot-tray", + icon: icon ?? undefined, + tooltip: "Frpilot", + menu, + // 左键点击直接显示窗口,右键才弹出菜单 + showMenuOnLeftClick: false, + action: (event) => { + // 仅在左键按下时响应,避免 Down / Up 触发两次 + if (event.type === "Click" && event.button === "Left" && event.buttonState === "Down") { + void windowStore.showWindow() + } + }, + }) + + // Frpc 运行状态变化时同步菜单可用状态 + watch( + [() => frpcStore.canStart, () => frpcStore.canStop], + ([canStart, canStop]) => { + void startItem.setEnabled(canStart) + void stopItem.setEnabled(canStop) + }, + ) + + // 语言切换时同步菜单文案 + watch( + () => appStore.language, + () => { + void showItem.setText(translate("tray.show")) + void startItem.setText(translate("tray.start")) + void stopItem.setText(translate("tray.stop")) + void quitItem.setText(translate("tray.quit")) + }, + ) +} + +/** 托盘「启动穿透」:复用仪表盘的一键启动逻辑 */ +async function startFromTray(frpcStore: ReturnType) { + if (!frpcStore.canStart) return + try { + await frpcStore.startSelected() + } catch (e) { + useAppStore().runStatus = "error" + frpcStore.pushLog("ERROR", translate("dashboard.logStartFailed", { message: errMsg(e) })) + message.error(errMsg(e)) + } +} + +/** 托盘「停止穿透」:复用仪表盘的停止逻辑 */ +async function stopFromTray(frpcStore: ReturnType) { + if (!frpcStore.canStop) return + try { + await frpcStore.stop() + } catch (e) { + message.error(errMsg(e)) + } +} diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 0bd08c5..8b3e5c5 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -28,6 +28,19 @@ "download": "Download frpc", "setting": "Settings" }, + "tray": { + "show": "Show window", + "start": "Start tunnel", + "stop": "Stop tunnel", + "quit": "Quit" + }, + "closeDialog": { + "title": "Close Frpilot", + "tray": "Minimize to system tray", + "trayDesc": "Keep Frpilot running in the background; reopen it from the system tray", + "exit": "Quit directly", + "exitDesc": "Stop the tunnel and exit Frpilot completely" + }, "system": { "cpu": "CPU usage", "memory": "Memory usage", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index d295542..16895eb 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -28,6 +28,19 @@ "download": "Frpc 下载", "setting": "设置" }, + "tray": { + "show": "显示窗口", + "start": "启动穿透", + "stop": "停止穿透", + "quit": "退出" + }, + "closeDialog": { + "title": "关闭 Frpilot", + "tray": "最小化到系统托盘", + "trayDesc": "保持 Frpilot 在后台运行,可从系统托盘随时打开", + "exit": "直接退出", + "exitDesc": "停止穿透并完全退出 Frpilot" + }, "system": { "cpu": "CPU 占用", "memory": "内存占用", diff --git a/src/layout/Layout.vue b/src/layout/Layout.vue index 6fe9cfb..83a11da 100644 --- a/src/layout/Layout.vue +++ b/src/layout/Layout.vue @@ -15,6 +15,9 @@ + + + @@ -22,6 +25,7 @@ import Header from "./header/Header.vue" import Aside from "./aside/Aside.vue" import UpdateModal from "@/components/UpdateModal.vue" +import CloseConfirmModal from "@/components/CloseConfirmModal.vue" import { useUpdateStore } from "@/stores/update" const router = useRouter() diff --git a/src/stores/frpc.ts b/src/stores/frpc.ts index 0b893f7..2cc87b4 100644 --- a/src/stores/frpc.ts +++ b/src/stores/frpc.ts @@ -1,7 +1,7 @@ import { defineStore } from "pinia" import dayjs from "dayjs" import { listen } from "@tauri-apps/api/event" -import { getFrpcStatus, startFrpc, stopFrpc } from "@/api/frp" +import { getFrpcStatus, getFrpDownloads, startFrpc, stopFrpc } from "@/api/frp" import { translate } from "@/i18n" import { useAppStore } from "@/stores/app" import type { FrpcExitEvent, FrpcLogEvent, FrpcStatus } from "@/types/frp" @@ -42,6 +42,12 @@ export const useFrpcStore = defineStore("frpc", () => { /** 是否正在运行 */ const isRunning = computed(() => appStore.runStatus === "running") + /** 是否可启动(未运行且不在启动中,供托盘菜单联动) */ + const canStart = computed(() => !isRunning.value && !starting.value) + + /** 是否可停止(运行中且不在停止中,供托盘菜单联动) */ + const canStop = computed(() => isRunning.value && !stopping.value) + /** 追加一行日志(最多保留 MAX_LOGS 行) */ function pushLog(level: LogLevel, message: string, time?: string) { logs.value.push({ time: time || dayjs().format("HH:mm:ss"), level, message }) @@ -136,6 +142,32 @@ export const useFrpcStore = defineStore("frpc", () => { } } + /** + * 确保已选中一个可用的 Frpc 版本。 + * + * 托盘可能在未打开仪表盘时触发启动,此时下拉框尚未加载数据、selectedId 为空, + * 这里自行拉取并回退到第一个带 frpc 可执行文件的版本。 + */ + async function ensureSelectedVersion() { + if (selectedId.value) return + try { + const downloads = (await getFrpDownloads()) ?? [] + const first = downloads.find((item) => item.frpc_path) + if (first) selectedId.value = String(first.id) + } catch { + // 读取失败时保持为空,由调用方提示 + } + } + + /** 使用当前选中版本一键启动(供托盘菜单复用仪表盘的启动逻辑) */ + async function startSelected(): Promise { + await ensureSelectedVersion() + if (!selectedId.value) { + throw new Error(translate("dashboard.frpcVersionEmpty")) + } + return start(Number(selectedId.value)) + } + return { logs, selectedId, @@ -143,10 +175,14 @@ export const useFrpcStore = defineStore("frpc", () => { starting, stopping, isRunning, + canStart, + canStop, pushLog, clearLogs, init, start, stop, + ensureSelectedVersion, + startSelected, } }) diff --git a/src/stores/window.ts b/src/stores/window.ts new file mode 100644 index 0000000..11b2fc9 --- /dev/null +++ b/src/stores/window.ts @@ -0,0 +1,97 @@ +import { defineStore } from "pinia" +import { exitApp, hideWindow, isTauri, revealWindow } from "@/utils/window" +import { useFrpcStore } from "@/stores/frpc" + +/** 关闭窗口时用户的选择:最小化到系统托盘 / 直接退出 */ +export type CloseChoice = "tray" | "exit" + +/** + * 窗口生命周期:关闭确认、最小化到系统托盘、退出软件。 + * + * - 顶部栏关闭按钮与系统原生关闭(如 Alt+F4)都会先弹出确认对话框; + * - 选择「最小化到系统托盘」时隐藏窗口,程序保持后台运行; + * - 选择「直接退出」时若穿透正在运行会先停止,再退出整个软件。 + */ +export const useWindowStore = defineStore("window", () => { + const frpcStore = useFrpcStore() + + /** 关闭确认对话框是否打开 */ + const closeConfirmOpen = ref(false) + /** 当前选中的关闭方式 */ + const closeChoice = ref("tray") + /** 是否正在退出(停止穿透 + 退出软件) */ + const quitting = ref(false) + + let initialized = false + + /** 应用启动时调用一次:拦截系统原生关闭,改为弹出确认对话框 */ + async function init() { + if (initialized || !isTauri()) return + initialized = true + + const { getCurrentWindow } = await import("@tauri-apps/api/window") + await getCurrentWindow().onCloseRequested((event) => { + event.preventDefault() + requestClose() + }) + } + + /** 请求关闭窗口:弹出确认对话框(默认选中最小化到系统托盘) */ + function requestClose() { + closeChoice.value = "tray" + closeConfirmOpen.value = true + } + + /** 取消关闭 */ + function cancelClose() { + closeConfirmOpen.value = false + } + + /** 确认关闭:按所选方式处理 */ + async function confirmClose() { + const choice = closeChoice.value + closeConfirmOpen.value = false + if (choice === "tray") { + await minimizeToTray() + } else { + await quitApp() + } + } + + /** 最小化到系统托盘(隐藏窗口,保持程序运行) */ + async function minimizeToTray() { + await hideWindow() + } + + /** 显示并聚焦主窗口(从系统托盘恢复) */ + async function showWindow() { + await revealWindow() + } + + /** 退出软件:穿透正在运行则先停止,再退出 */ + async function quitApp() { + if (quitting.value) return + quitting.value = true + try { + if (frpcStore.isRunning) { + await frpcStore.stop() + } + } catch { + // 停止失败不阻塞退出,后端在应用退出时会兜底结束 frpc 子进程 + } + await exitApp() + } + + return { + closeConfirmOpen, + closeChoice, + quitting, + init, + requestClose, + cancelClose, + confirmClose, + minimizeToTray, + showWindow, + quitApp, + } +}) diff --git a/src/types/components.d.ts b/src/types/components.d.ts index fdae036..fe06106 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -31,6 +31,8 @@ declare module 'vue' { AModal: typeof import('ant-design-vue/es')['Modal'] APopconfirm: typeof import('ant-design-vue/es')['Popconfirm'] AProgress: typeof import('ant-design-vue/es')['Progress'] + ARadio: typeof import('ant-design-vue/es')['Radio'] + ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup'] ARow: typeof import('ant-design-vue/es')['Row'] ASegmented: typeof import('ant-design-vue/es')['Segmented'] ASelect: typeof import('ant-design-vue/es')['Select'] @@ -42,6 +44,7 @@ declare module 'vue' { ATag: typeof import('ant-design-vue/es')['Tag'] ATextarea: typeof import('ant-design-vue/es')['Textarea'] ATooltip: typeof import('ant-design-vue/es')['Tooltip'] + CloseConfirmModal: typeof import('./../components/CloseConfirmModal.vue')['default'] FrpCard: typeof import('./../components/frp/FrpCard.vue')['default'] MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default'] MarkdownBlock: typeof import('./../components/MarkdownBlock.vue')['default'] diff --git a/src/utils/window.ts b/src/utils/window.ts index d589bd5..6bf5bba 100644 --- a/src/utils/window.ts +++ b/src/utils/window.ts @@ -31,6 +31,28 @@ export async function closeWindow() { await win?.close() } +/** 隐藏窗口(最小化到系统托盘,保持程序在后台运行) */ +export async function hideWindow() { + const win = await currentWindow() + await win?.hide() +} + +/** 显示并聚焦窗口(从系统托盘恢复) */ +export async function revealWindow() { + const win = await currentWindow() + if (!win) return + await win.show() + await win.unminimize() + await win.setFocus() +} + +/** 退出整个应用(退出前由调用方负责停止穿透等收尾工作) */ +export async function exitApp() { + if (!isTauri()) return + const { exit } = await import("@tauri-apps/plugin-process") + await exit(0) +} + /** 开始拖拽窗口(配合 data-tauri-drag-region 使用) */ export async function startDragging() { const win = await currentWindow()