feat: 新增系统托盘功能与关闭确认对话框

- 新增系统托盘图标及菜单,支持显示窗口、启动/停止穿透、退出应用
- 点击窗口关闭按钮或使用系统关闭快捷键时,弹出确认对话框,可选择最小化到托盘或直接退出
- 托盘菜单随语言切换同步更新,且根据穿透运行状态自动启用/禁用对应选项
- 升级应用版本至v1.1.0,添加Tauri tray-icon特性及相关权限配置
This commit is contained in:
Yuhang Wu
2026-10-14 14:54:07 +08:00
parent 827075bdef
commit 8185275937
17 changed files with 489 additions and 8 deletions
+26
View File
@@ -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) ## v1.0.0(2026-10-14)
### 🎉 首个正式版本发布! ### 🎉 首个正式版本发布!
+12
View File
@@ -127,3 +127,15 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发 开发过程中使用到的组件尽可能全部使用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,看一下有哪些依赖可以简化代码开发
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frpilot", "name": "frpilot",
"private": true, "private": true,
"version": "0.9.0", "version": "1.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -22,7 +22,7 @@ tauri-build = { version = "2", features = [] }
tauri-helper = "0.2.1" tauri-helper = "0.2.1"
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+2 -1
View File
@@ -18,6 +18,7 @@
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-hide", "core:window:allow-hide",
"core:window:allow-show", "core:window:allow-show",
"core:window:allow-set-focus" "core:window:allow-set-focus",
"core:app:allow-default-window-icon"
] ]
} }
+6
View File
@@ -8,9 +8,12 @@
import { theme as antdTheme } from "ant-design-vue" import { theme as antdTheme } from "ant-design-vue"
import { useAppStore } from "@/stores/app" import { useAppStore } from "@/stores/app"
import { useFrpcStore } from "@/stores/frpc" import { useFrpcStore } from "@/stores/frpc"
import { useWindowStore } from "@/stores/window"
import { initTray } from "@/composables/useTray"
const appStore = useAppStore() const appStore = useAppStore()
const frpcStore = useFrpcStore() const frpcStore = useFrpcStore()
const windowStore = useWindowStore()
/** 跟随明暗主题切换 Ant Design Vue 的算法 */ /** 跟随明暗主题切换 Ant Design Vue 的算法 */
const themeConfig = computed(() => ({ const themeConfig = computed(() => ({
@@ -24,6 +27,9 @@ const themeConfig = computed(() => ({
// 应用根组件初始化一次:订阅 frpc 日志/退出事件,离开仪表盘后仍持续收集 // 应用根组件初始化一次:订阅 frpc 日志/退出事件,离开仪表盘后仍持续收集
onMounted(() => { onMounted(() => {
frpcStore.init() frpcStore.init()
// 系统托盘(前端 API 方式)与窗口关闭拦截
initTray()
windowStore.init()
}) })
</script> </script>
+100
View File
@@ -0,0 +1,100 @@
<template>
<a-modal
:open="store.closeConfirmOpen"
:title="t('closeDialog.title')"
:width="380"
centered
:mask-closable="false"
:closable="!store.quitting"
:keyboard="!store.quitting"
@cancel="store.cancelClose()"
>
<a-radio-group v-model:value="store.closeChoice" class="close-choice">
<a-radio value="tray" class="close-option">
<span class="close-option-title">{{ t("closeDialog.tray") }}</span>
<span class="close-option-desc">{{ t("closeDialog.trayDesc") }}</span>
</a-radio>
<a-radio value="exit" class="close-option">
<span class="close-option-title">{{ t("closeDialog.exit") }}</span>
<span class="close-option-desc">{{ t("closeDialog.exitDesc") }}</span>
</a-radio>
</a-radio-group>
<template #footer>
<a-button :disabled="store.quitting" @click="store.cancelClose()">
{{ t("common.cancel") }}
</a-button>
<a-button type="primary" :loading="store.quitting" @click="store.confirmClose()">
{{ t("common.confirm") }}
</a-button>
</template>
</a-modal>
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n"
import { useWindowStore } from "@/stores/window"
const { t } = useI18n()
const store = useWindowStore()
</script>
<style scoped lang="scss">
.close-choice {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.close-option {
display: flex;
align-items: flex-start;
width: 100%;
margin-inline-end: 0;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.1);
transition: border-color 0.2s ease, background-color 0.2s ease;
&:hover {
border-color: rgba(99, 102, 241, 0.5);
}
// 选中态高亮
&.ant-radio-wrapper-checked {
border-color: #6366f1;
background-color: rgba(99, 102, 241, 0.08);
}
:deep(.ant-radio) {
margin-top: 2px;
}
:deep(> span:last-child) {
display: flex;
flex-direction: column;
gap: 2px;
padding-inline-end: 0;
}
}
.dark .close-option {
border-color: rgba(255, 255, 255, 0.12);
&.ant-radio-wrapper-checked {
border-color: #6366f1;
background-color: rgba(99, 102, 241, 0.16);
}
}
.close-option-title {
font-weight: 500;
}
.close-option-desc {
font-size: 12px;
line-height: 1.4;
opacity: 0.6;
}
</style>
+4 -2
View File
@@ -10,7 +10,7 @@
class="traffic-light bg-[#ff5f57]" class="traffic-light bg-[#ff5f57]"
:class="{ 'traffic-light--idle': !focused }" :class="{ 'traffic-light--idle': !focused }"
:title="t('titlebar.close')" :title="t('titlebar.close')"
@click="closeWindow()" @click="windowStore.requestClose()"
> >
<svg viewBox="0 0 12 12" class="light-icon opacity-0 group-hover/lights:opacity-100"> <svg viewBox="0 0 12 12" class="light-icon opacity-0 group-hover/lights:opacity-100">
<path d="M3.5 3.5l5 5M8.5 3.5l-5 5" /> <path d="M3.5 3.5l5 5M8.5 3.5l-5 5" />
@@ -45,11 +45,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from "vue-i18n" import { useI18n } from "vue-i18n"
import TitleBarActions from "./TitleBarActions.vue" import TitleBarActions from "./TitleBarActions.vue"
import { closeWindow, minimizeWindow } from "@/utils/window" import { useWindowStore } from "@/stores/window"
import { minimizeWindow } from "@/utils/window"
const emit = defineEmits<{ settings: [] }>() const emit = defineEmits<{ settings: [] }>()
const { t } = useI18n() const { t } = useI18n()
const windowStore = useWindowStore()
const focused = useWindowFocus() const focused = useWindowFocus()
</script> </script>
+4 -2
View File
@@ -57,7 +57,7 @@
</a-button> </a-button>
<!-- 关闭(macOS 由左侧红绿灯负责,此处隐藏) --> <!-- 关闭(macOS 由左侧红绿灯负责,此处隐藏) -->
<a-button v-if="!isMac" type="text" class="tb-btn tb-btn-close" :title="t('titlebar.close')" @click="closeWindow()"> <a-button v-if="!isMac" type="text" class="tb-btn tb-btn-close" :title="t('titlebar.close')" @click="windowStore.requestClose()">
<template #icon> <template #icon>
<CloseOutlined /> <CloseOutlined />
</template> </template>
@@ -80,8 +80,9 @@ import { useI18n } from "vue-i18n"
import type { MenuProps } from "ant-design-vue" import type { MenuProps } from "ant-design-vue"
import { useAppStore } from "@/stores/app" import { useAppStore } from "@/stores/app"
import { useUpdateStore } from "@/stores/update" import { useUpdateStore } from "@/stores/update"
import { useWindowStore } from "@/stores/window"
import { languageOptions, type Language } from "@/i18n" import { languageOptions, type Language } from "@/i18n"
import { closeWindow, minimizeWindow } from "@/utils/window" import { minimizeWindow } from "@/utils/window"
import { openProjectRepo } from "@/api/updater" import { openProjectRepo } from "@/api/updater"
import { usePlatform } from "@/composables/usePlatform" import { usePlatform } from "@/composables/usePlatform"
@@ -90,6 +91,7 @@ const emit = defineEmits<{ settings: [] }>()
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const updateStore = useUpdateStore() const updateStore = useUpdateStore()
const windowStore = useWindowStore()
const { isMac } = usePlatform() const { isMac } = usePlatform()
const onLanguageClick: MenuProps["onClick"] = ({ key }) => { const onLanguageClick: MenuProps["onClick"] = ({ key }) => {
+144
View File
@@ -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<typeof useFrpcStore>) {
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<typeof useFrpcStore>) {
if (!frpcStore.canStop) return
try {
await frpcStore.stop()
} catch (e) {
message.error(errMsg(e))
}
}
+13
View File
@@ -28,6 +28,19 @@
"download": "Download frpc", "download": "Download frpc",
"setting": "Settings" "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": { "system": {
"cpu": "CPU usage", "cpu": "CPU usage",
"memory": "Memory usage", "memory": "Memory usage",
+13
View File
@@ -28,6 +28,19 @@
"download": "Frpc 下载", "download": "Frpc 下载",
"setting": "设置" "setting": "设置"
}, },
"tray": {
"show": "显示窗口",
"start": "启动穿透",
"stop": "停止穿透",
"quit": "退出"
},
"closeDialog": {
"title": "关闭 Frpilot",
"tray": "最小化到系统托盘",
"trayDesc": "保持 Frpilot 在后台运行,可从系统托盘随时打开",
"exit": "直接退出",
"exitDesc": "停止穿透并完全退出 Frpilot"
},
"system": { "system": {
"cpu": "CPU 占用", "cpu": "CPU 占用",
"memory": "内存占用", "memory": "内存占用",
+4
View File
@@ -15,6 +15,9 @@
<!-- 全局更新弹窗 --> <!-- 全局更新弹窗 -->
<UpdateModal /> <UpdateModal />
<!-- 关闭确认对话框(最小化到系统托盘 / 直接退出) -->
<CloseConfirmModal />
</div> </div>
</template> </template>
@@ -22,6 +25,7 @@
import Header from "./header/Header.vue" import Header from "./header/Header.vue"
import Aside from "./aside/Aside.vue" import Aside from "./aside/Aside.vue"
import UpdateModal from "@/components/UpdateModal.vue" import UpdateModal from "@/components/UpdateModal.vue"
import CloseConfirmModal from "@/components/CloseConfirmModal.vue"
import { useUpdateStore } from "@/stores/update" import { useUpdateStore } from "@/stores/update"
const router = useRouter() const router = useRouter()
+37 -1
View File
@@ -1,7 +1,7 @@
import { defineStore } from "pinia" import { defineStore } from "pinia"
import dayjs from "dayjs" import dayjs from "dayjs"
import { listen } from "@tauri-apps/api/event" 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 { translate } from "@/i18n"
import { useAppStore } from "@/stores/app" import { useAppStore } from "@/stores/app"
import type { FrpcExitEvent, FrpcLogEvent, FrpcStatus } from "@/types/frp" import type { FrpcExitEvent, FrpcLogEvent, FrpcStatus } from "@/types/frp"
@@ -42,6 +42,12 @@ export const useFrpcStore = defineStore("frpc", () => {
/** 是否正在运行 */ /** 是否正在运行 */
const isRunning = computed(() => appStore.runStatus === "running") const isRunning = computed(() => appStore.runStatus === "running")
/** 是否可启动(未运行且不在启动中,供托盘菜单联动) */
const canStart = computed(() => !isRunning.value && !starting.value)
/** 是否可停止(运行中且不在停止中,供托盘菜单联动) */
const canStop = computed(() => isRunning.value && !stopping.value)
/** 追加一行日志(最多保留 MAX_LOGS 行) */ /** 追加一行日志(最多保留 MAX_LOGS 行) */
function pushLog(level: LogLevel, message: string, time?: string) { function pushLog(level: LogLevel, message: string, time?: string) {
logs.value.push({ time: time || dayjs().format("HH:mm:ss"), level, message }) 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<FrpcStatus> {
await ensureSelectedVersion()
if (!selectedId.value) {
throw new Error(translate("dashboard.frpcVersionEmpty"))
}
return start(Number(selectedId.value))
}
return { return {
logs, logs,
selectedId, selectedId,
@@ -143,10 +175,14 @@ export const useFrpcStore = defineStore("frpc", () => {
starting, starting,
stopping, stopping,
isRunning, isRunning,
canStart,
canStop,
pushLog, pushLog,
clearLogs, clearLogs,
init, init,
start, start,
stop, stop,
ensureSelectedVersion,
startSelected,
} }
}) })
+97
View File
@@ -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<CloseChoice>("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,
}
})
+3
View File
@@ -31,6 +31,8 @@ declare module 'vue' {
AModal: typeof import('ant-design-vue/es')['Modal'] AModal: typeof import('ant-design-vue/es')['Modal']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm'] APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
AProgress: typeof import('ant-design-vue/es')['Progress'] 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'] ARow: typeof import('ant-design-vue/es')['Row']
ASegmented: typeof import('ant-design-vue/es')['Segmented'] ASegmented: typeof import('ant-design-vue/es')['Segmented']
ASelect: typeof import('ant-design-vue/es')['Select'] ASelect: typeof import('ant-design-vue/es')['Select']
@@ -42,6 +44,7 @@ declare module 'vue' {
ATag: typeof import('ant-design-vue/es')['Tag'] ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea'] ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATooltip: typeof import('ant-design-vue/es')['Tooltip'] ATooltip: typeof import('ant-design-vue/es')['Tooltip']
CloseConfirmModal: typeof import('./../components/CloseConfirmModal.vue')['default']
FrpCard: typeof import('./../components/frp/FrpCard.vue')['default'] FrpCard: typeof import('./../components/frp/FrpCard.vue')['default']
MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default'] MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default']
MarkdownBlock: typeof import('./../components/MarkdownBlock.vue')['default'] MarkdownBlock: typeof import('./../components/MarkdownBlock.vue')['default']
+22
View File
@@ -31,6 +31,28 @@ export async function closeWindow() {
await win?.close() 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 使用) */ /** 开始拖拽窗口(配合 data-tauri-drag-region 使用) */
export async function startDragging() { export async function startDragging() {
const win = await currentWindow() const win = await currentWindow()