feat: 新增系统托盘功能与关闭确认对话框
- 新增系统托盘图标及菜单,支持显示窗口、启动/停止穿透、退出应用 - 点击窗口关闭按钮或使用系统关闭快捷键时,弹出确认对话框,可选择最小化到托盘或直接退出 - 托盘菜单随语言切换同步更新,且根据穿透运行状态自动启用/禁用对应选项 - 升级应用版本至v1.1.0,添加Tauri tray-icon特性及相关权限配置
This commit is contained in:
@@ -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)
|
||||
|
||||
### 🎉 首个正式版本发布!
|
||||
|
||||
@@ -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,看一下有哪些依赖可以简化代码开发
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frpilot",
|
||||
"private": true,
|
||||
"version": "0.9.0",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -10,7 +10,7 @@
|
||||
class="traffic-light bg-[#ff5f57]"
|
||||
:class="{ 'traffic-light--idle': !focused }"
|
||||
: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">
|
||||
<path d="M3.5 3.5l5 5M8.5 3.5l-5 5" />
|
||||
@@ -45,11 +45,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n"
|
||||
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 { t } = useI18n()
|
||||
const windowStore = useWindowStore()
|
||||
const focused = useWindowFocus()
|
||||
</script>
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</a-button>
|
||||
|
||||
<!-- 关闭(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>
|
||||
<CloseOutlined />
|
||||
</template>
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "内存占用",
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
|
||||
<!-- 全局更新弹窗 -->
|
||||
<UpdateModal />
|
||||
|
||||
<!-- 关闭确认对话框(最小化到系统托盘 / 直接退出) -->
|
||||
<CloseConfirmModal />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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()
|
||||
|
||||
+37
-1
@@ -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<FrpcStatus> {
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
Vendored
+3
@@ -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']
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user