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)
|
## v1.0.0(2026-10-14)
|
||||||
|
|
||||||
### 🎉 首个正式版本发布!
|
### 🎉 首个正式版本发布!
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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",
|
"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",
|
||||||
|
|||||||
@@ -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": "内存占用",
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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']
|
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']
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user