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
+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))
}
}