Compare commits

..
4 Commits
Author SHA1 Message Date
Yuhang Wu 8185275937 feat: 新增系统托盘功能与关闭确认对话框
- 新增系统托盘图标及菜单,支持显示窗口、启动/停止穿透、退出应用
- 点击窗口关闭按钮或使用系统关闭快捷键时,弹出确认对话框,可选择最小化到托盘或直接退出
- 托盘菜单随语言切换同步更新,且根据穿透运行状态自动启用/禁用对应选项
- 升级应用版本至v1.1.0,添加Tauri tray-icon特性及相关权限配置
2026-10-14 14:54:07 +08:00
Yuhang Wu 827075bdef feat: 添加应用图标并实现更新提示徽标
- 新增应用图标资源
- 调整玻璃面板背景色优化视觉效果
- 重构标题栏布局,替换品牌Logo为应用图标
- 为检查更新按钮添加徽标,有更新时显示提示点
- 修改更新逻辑,保留已检测更新状态避免重复检查
- 简化模板代码格式,提升可读性
- 版本号从1.0.0更新为0.9.0
2026-10-14 14:09:41 +08:00
Yuhang Wu 4adde51bef feat: 为 macOS 适配无边框窗口圆角效果
新增 macOS 平台配置文件,修改平台检测逻辑,在 macOS 时给 html 加标记,调整 CSS 让 #app 绘制背景并裁切 8px 圆角,避免首帧方角闪烁,同时适配弹窗遮罩圆角。
2026-10-14 13:38:35 +08:00
Yuhang Wu cbd5096595 docs: 更新项目说明文档、添加界面截图与镜像加速配置
- 更新 README.md,完善功能特性、技术栈与使用说明
- 添加界面截图到 docs/snipastes 目录
- 配置 Tauri 更新镜像与 GitHub Actions 脚本
- 修正应用名称大小写与更新端点配置
2026-10-14 11:42:07 +08:00
35 changed files with 844 additions and 236 deletions
+47
View File
@@ -9,6 +9,11 @@ on:
permissions:
contents: write # 需要写入权限来创建 Release
env:
# 更新产物镜像前缀:latest.json 里的 GitHub 下载直链会被改写为 <前缀> + 原始直链。
# 更换镜像时,请同步修改 src-tauri/tauri.conf.json 中 updater.endpoints 的镜像地址。
MIRROR_PREFIX: "https://ghproxy.net/"
jobs:
publish-tauri:
strategy:
@@ -132,3 +137,45 @@ jobs:
releaseDraft: true # 先创建草稿,手动确认后再发布
prerelease: false
args: ${{ matrix.args }}
# 所有平台构建完成后,把 latest.json 里的下载直链改写为镜像前缀,
# 让「下载安装包」这一步也走镜像加速(此前只有检查更新走镜像)。
mirror-updater-manifest:
needs: publish-tauri
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Rewrite latest.json download URLs to mirror
run: |
set -euo pipefail
TAG="v$(node -p "require('./package.json').version")"
echo "Release tag: $TAG"
mkdir -p updater
# 从草稿 Release 中取回 tauri-action 生成的更新清单
gh release download "$TAG" --pattern latest.json --dir updater --clobber
node <<'NODE'
const fs = require("fs");
const file = "updater/latest.json";
const prefix = process.env.MIRROR_PREFIX;
const data = JSON.parse(fs.readFileSync(file, "utf8"));
let count = 0;
for (const info of Object.values(data.platforms || {})) {
if (info && typeof info.url === "string" && info.url.startsWith("https://github.com/")) {
info.url = prefix + info.url;
count += 1;
}
}
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
console.log(`Rewrote ${count} download URL(s) with mirror prefix: ${prefix}`);
NODE
# 覆盖上传(签名针对安装包字节校验,与 URL 无关,改地址不影响更新校验)
gh release upload "$TAG" updater/latest.json --clobber
+150 -107
View File
@@ -1,56 +1,107 @@
# Frpilot
> 一个基于 **Tauri 2 + Vue 3 + TypeScript** 的 [frp](https://github.com/fatedier/frp) 可视化客户端,帮助用户快速配置 frpc 版本、服务器与隧道。
> 一个基于 **Tauri 2 + Vue 3 + TypeScript** 的 [frp](https://github.com/fatedier/frp) 可视化客户端,帮你用图形界面管理 frpc 版本、服务器与隧道,从此告别手写 `frpc.toml`。
Frpilot 目前处于早期开发阶段,已完成应用骨架(自定义标题栏、多语言、明暗主题、磨砂玻璃布局)。
Frpilot 已从「应用骨架」成长为可用的一体化客户端:下载 frpc、配置服务器、编排隧道、一键启动并实时查看日志,全部在同一个窗口里完成,并内置应用内自动更新。
---
## ✨ 功能特性
## ✨ 已实现功能
### 已实现
### Frpc 版本管理
- **i18n 国际化**:内置简体中文 / English,语言选择持久化,Ant Design Vue 组件库语言包联动
- **自定义标题栏**:按操作系统自动切换
- Windows:左侧 `Frpilot` 标题,右侧「语言 / 明暗 / 设置 / 最小化 / 关闭」
- macOS:左侧红绿灯(关闭 / 最小化 / 最大化),右侧同上一组按钮
- **明暗主题**:基于 `@vueuse/core` 的 `useDark`,Tailwind `dark:` 变体 + Ant Design Vue 暗色算法联动,选择持久化
- **悬浮磨砂玻璃布局**:侧边栏(Aside)与内容区(Content)均为圆角 + `backdrop-filter` 毛玻璃面板
- **侧边栏菜单**:仪表盘 / 服务器 / 隧道列表 / Frpc 下载
- **底部系统信息**:CPU 占用、内存占用(百分比 + 进度条)、运行状态指示点
- 🟢 运行正常 · 🔴 运行异常 · 🟡 未启动
- 在线拉取 Frp 官方版本列表,支持「所有架构 frpc / frps」开关,按平台与架构筛选
- 一键下载并自动解压,实时显示下载 / 解压进度与状态
- 导入本地已有的 `frpc` / `frps`
- 删除已下载版本、复制下载直链
### 规划中
### 服务器管理
- frpc 版本管理与下载
- 服务器(frps)连接配置管理
- 隧道配置的增删改查与启停
- 配置文件的导入 / 导出与校验
- frps 服务器的新增 / 编辑 / 删除
- 服务器地址、端口、鉴权 Token、传输协议、TLS 开关、备注
- 置顶与排序;启用开关(**同一时间仅允许启用一台**服务器)
### 隧道管理
- 覆盖 **TCP / UDP / HTTP / HTTPS / STCP / XTCP / SUDP / TCPMUX** 等代理类型
- 本地 IP / 端口、远程端口、自定义域名、子域名、URL 前缀
- 高级配置:密钥、加密、压缩、带宽限制、Proxy Protocol、健康检查、负载均衡
- 启用 / 置顶 / 排序
### 一键启动与实时日志
- 仪表盘选择 frpc 版本后「一键启动 / 停止」
- 自动依据所选版本、已启用的服务器与隧道生成 `frpc.toml` 并拉起独立进程
- 通过 `frpc-log` / `frpc-exit` 事件实时回显运行日志与进程退出信息
### 仪表盘
- 系统信息:主机名 / 系统 / 架构 / IP 地址
- 服务器与隧道数量统计
- 侧边栏常驻 CPU 占用、内存占用与运行状态指示点(🟢 运行正常 · 🔴 运行异常 · 🟡 未启动)
### 应用内自动更新
- 启动时静默检查,支持手动检查更新
- 更新弹窗以 **Markdown** 渲染更新说明,展示下载进度
- 支持「忽略该版本」、一键更新,安装完成后自动重启
### 关于页面
- 项目介绍、技术栈标签、使用流程指引
- 开源地址一键跳转、当前版本信息与「检查更新」入口
### 界面与体验
- **自定义标题栏**:Windows / macOS 双套方案,窗口可拖拽移动;顶部栏集成语言 / 主题 / 检查更新 / GitHub / 设置与窗口控制按钮
- **macOS 特化**:还原原生红绿灯,隐藏冗余的最小化 / 关闭按钮,禁用 macOS 下不需要的最大化
- **明暗主题**:跟随切换,Tailwind 暗色变体与 Ant Design Vue 暗色算法同步,选择持久化
- **悬浮磨砂玻璃**:侧边栏、内容区与卡片统一为圆角 + `backdrop-filter` 半透明面板
- **国际化**:内置简体中文 / English,语言选择持久化,Ant Design Vue 组件语言包联动
---
## 🖼️ 界面预览
| 仪表盘 | 服务器 |
| --- | --- |
| ![仪表盘](docs/snipastes/仪表盘.webp) | ![服务器](docs/snipastes/服务器.webp) |
| 隧道列表 | Frpc 下载 |
| --- | --- |
| ![隧道列表](docs/snipastes/隧道列表.webp) | ![Frpc 下载](docs/snipastes/frpc下载.webp) |
| 设置 · 外观 | 设置 · 关于 |
| --- | --- |
| ![外观设置](docs/snipastes/设置.webp) | ![关于](docs/snipastes/关于.webp) |
---
## 🧱 技术栈
| 分类 | 选型 |
| ---------- | ---------------------------------------------------------------- |
| 桌面框架 | [Tauri 2](https://v2.tauri.app/)(Rust 后端) |
| 前端框架 | Vue 3(`<script setup>`)+ TypeScript |
| 构建工具 | Vite 8 |
| UI 组件库 | Ant Design Vue 4 + `@ant-design/icons-vue` |
| 样式 | Tailwind CSS 4(原子化)+ SCSS |
| 状态管理 | Pinia |
| 路由 | Vue Router |
| 国际化 | Vue I18n 11 |
| 工具库 | `@vueuse/core` |
| 分类 | 选型 |
| ---------- | --------------------------------------------------------------------- |
| 桌面框架 | [Tauri 2](https://v2.tauri.app/)(Rust 后端) |
| 前端框架 | Vue 3(`<script setup>`)+ TypeScript |
| 构建工具 | Vite 8 |
| UI 组件库 | Ant Design Vue 4 + `@ant-design/icons-vue` |
| 样式 | Tailwind CSS 4(原子化)+ SCSS |
| 状态管理 | Pinia |
| 路由 | Vue Router |
| 国际化 | Vue I18n 11 |
| Markdown | `markdown-it`(更新日志渲染) |
| 工具库 | `@vueuse/core` |
| 后端 | Rust:`sqlx` + SQLite、`reqwest`(下载)、`sysinfo`(系统采集) |
| 插件 | `updater` / `process` / `dialog` / `opener` / `store` / `prevent-default` |
| 自动化 | `unplugin-auto-import`、`unplugin-vue-components`(Antd 按需自动导入) |
---
## 📦 环境要求
- **Node.js** ≥ 18(推荐 20 / 22 LTS)
- **Node.js** ≥ 20(推荐 20 / 22 LTS)
- **pnpm** ≥ 9(项目使用 pnpm,含 `pnpm-lock.yaml`)
- **Rust** 稳定版工具链(`rustup`)
- **Rust** 稳定版工具链(`rustup`,建议 ≥ 1.90)
- **系统依赖**:请参考 [Tauri 前置依赖](https://v2.tauri.app/start/prerequisites/)(Windows 需 WebView2 与 MSVC 构建工具;macOS 需 Xcode Command Line Tools;Linux 需 `webkit2gtk` 等)
---
@@ -61,7 +112,7 @@ Frpilot 目前处于早期开发阶段,已完成应用骨架(自定义标题
# 1. 安装依赖
pnpm install
# 2. 仅启动前端(浏览器调试,窗口控制类操作会安全空转)
# 2. 仅启动前端(浏览器调试,窗口 / 命令类操作会安全空转)
pnpm dev
# 3. 启动完整桌面应用(推荐,含 Rust 后端)
@@ -70,12 +121,12 @@ pnpm tauri dev
### 常用脚本
| 命令 | 说明 |
| ---------------- | -------------------------------------------------------- |
| `pnpm dev` | 启动 Vite 开发服务器(端口 `1420`) |
| `pnpm tauri dev` | 启动 Tauri 桌面开发环境(自动拉起前端) |
| `pnpm build` | 类型检查(`vue-tsc --noEmit`)+ 构建前端产物到 `dist/` |
| `pnpm preview` | 预览前端构建产物 |
| 命令 | 说明 |
| ------------------ | ------------------------------------------------------ |
| `pnpm dev` | 启动 Vite 开发服务器(端口 `1420`) |
| `pnpm tauri dev` | 启动 Tauri 桌面开发环境(自动拉起前端) |
| `pnpm build` | 类型检查(`vue-tsc --noEmit`)+ 构建前端产物到 `dist/` |
| `pnpm preview` | 预览前端构建产物 |
| `pnpm tauri build` | 打包生成各平台安装包 |
> ⚠️ `pnpm dev` 的端口固定为 `1420` 且 `strictPort: true`,占用时会直接报错退出。
@@ -86,40 +137,39 @@ pnpm tauri dev
```
Frpilot/
├── src/
│ ├── api/ # 接口封装(预留)
│ ├── assets/
│ │ └── main.css # Tailwind 入口 + 全局样式(.glass-panel / dark 变体)
│ ├── components/ # 通用组件
│ │ ├── TitleBarActions.vue # 标题栏右侧按钮组(语言/主题/设置/最小化/关闭)
│ │ ├── WindowsTitleBar.vue # Windows 自定义标题栏
│ │ └── MacTitleBar.vue # macOS 自定义标题栏(红绿灯)
│ ├── composables/
│ │ ├── usePlatform.ts # 操作系统类型判断
│ │ └── useSystemStats.ts # CPU / 内存占用采集
│ ├── i18n/
│ │ ├── index.ts # createI18n 初始化
│ │ └── locales/
│ │ ├── zh-CN.json # 中文语言包
│ │ └── en-US.json # 英文语言包
│ ├── layout/
│ │ ├── Layout.vue # 整体布局(背景 + Header + Aside + Content)
│ │ ├── aside/Aside.vue # 侧边栏(菜单 + 系统信息)
│ │ └── header/Header.vue # 顶部栏(按系统加载 TitleBar)
│ ├── router/router.ts # 路由表
│ ├── stores/app.ts # 全局状态:语言 / 主题 / 运行状态
│ ├── types/ # 自动生成的类型声明
│ ├── utils/window.ts # Tauri 窗口控制封装
│ ├── views/ # 页面(仪表盘 / 服务器 / 隧道 / 下载)
│ ├── App.vue # ConfigProvider(语言 + 主题)
│ └── main.ts # 应用入口
├── src-tauri/ # Rust 后端
│ ├── src/lib.rs # 命令注册入口
│ ├── capabilities/default.json# 权限声明(窗口控制等)
│ └── tauri.conf.json # Tauri 配置(无边框窗口 1100×720)
├── src/ # 前端(Vue 3 + TypeScript)
│ ├── api/ # Tauri 命令封装(frp / server / tunnel / system / updater)
│ ├── assets/main.css # Tailwind 入口 + 全局样式(磨砂玻璃 / Markdown / 弹窗)
│ ├── components/ # 通用组件
│ │ ├── frp/FrpCard.vue
│ │ ├── server/ServerCard.vue · ServerFormModal.vue
│ │ ├── tunnel/TunnelCard.vue · TunnelFormModal.vue
│ │ ├── WindowsTitleBar.vue · MacTitleBar.vue · TitleBarActions.vue
│ │ ├── UpdateModal.vue # 更新弹窗(Markdown 更新日志 + 进度)
│ │ └── MarkdownBlock.vue # Markdown 渲染
│ ├── composables/ # usePlatform · useSystemStats
│ ├── config/backgrounds.ts # 内置窗口背景图
│ ├── i18n/ # 国际化(zh-CN / en-US)
│ ├── layout/ # 主布局 + 标题栏 + 侧边栏
│ ├── router/router.ts # 路由表
│ ├── stores/ # app · frpc · update(Pinia)
│ ├── types/ # 自动生成的类型声明与领域类型
│ ├── utils/ # window(窗口控制)· markdown(渲染)
│ └── views/ # 页面(仪表盘 / 服务器 / 隧道 / 下载 / 设置)
├── src-tauri/ # 后端(Rust)
│ ├── src/
│ │ ├── commands/ # Tauri 命令(frp / runner / server / system / tunnel)
│ │ ├── services/ # 数据库 / frpc.toml 生成 / 进程管理 / 系统采集
│ │ ├── models.rs # ApiResponse / PageResult 等通用模型
│ │ └── lib.rs # 应用入口与命令注册
│ ├── capabilities/default.json # 权限声明(窗口控制 / updater / process 等)
│ └── tauri.conf.json # Tauri 配置(无边框窗口 1100×720)
├── docs/
│ ├── CHANGELOG.md # 版本更新日志(发布流程会读取)
│ └── snipastes/ # 界面截图
├── .github/workflows/release.yml # 多平台发布工作流
├── index.html
├── vite.config.ts # 别名 / 自动导入 / Tailwind 插件
├── vite.config.ts # 别名 / 自动导入 / Tailwind 插件
└── package.json
```
@@ -158,7 +208,7 @@ const { t } = useI18n()
### 新增侧边栏菜单项与页面
1. 在 `src/views/` 新建页面组件。
2. 在 `src/router/router.ts` 的 `Layout` 子路由中注册(路径需以 `/` 开头,与菜单 `key` 一致)。
2. 在 `src/router/router.ts` 的 `Layout` 子路由中注册(路径与菜单 `key` 一致)。
3. 在 `src/layout/aside/Aside.vue` 的 `menuItems` 中追加一项(`key` = 路由路径)。
4. 在语言包中补充对应 `menu.*` 文案。
@@ -168,48 +218,41 @@ const { t } = useI18n()
- Tailwind 使用 class 策略:在 `src/assets/main.css` 中已声明 `@custom-variant dark`。
- Ant Design Vue 主题在 `src/App.vue` 中通过 `a-config-provider` 的 `theme.algorithm` 联动。
### 玻璃拟态样式
### 磨砂玻璃样式
`src/assets/main.css` 中的 `.glass-panel` 为通用毛玻璃面板,直接给元素加 `class="glass-panel"` 即可(已含暗色适配)。
`src/assets/main.css` 中的 `.glass-panel`(面板)与 `.glass-card`(卡片)为通用毛玻璃样式,直接加类名即可(已含暗色适配)。
### 接入真实的 CPU / 内存占用
### Markdown 渲染
`src/composables/useSystemStats.ts` 会优先调用 Tauri 命令 `get_system_info`,在命令不存在时回退到**模拟数据**。接入真实数据只需在 Rust 端实现该命令:
```rust
// src-tauri/src/lib.rs
#[tauri::command]
fn get_system_info() -> serde_json::Value {
// 返回 { "cpu": f64, "memory": f64 },单位为百分比
serde_json::json!({ "cpu": 12.0, "memory": 45.0 })
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![get_system_info])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
```
前端会自动切换为真实数据(`isMock` 会变为 `false`)。
### 运行状态指示点
运行状态由 `src/stores/app.ts` 的 `runStatus` 控制,取值:
```ts
type RunStatus = 'running' | 'error' | 'stopped' // 🟢 | 🔴 | 🟡
```
后续接入 frpc 进程状态时,更新该字段即可。
更新日志、说明类富文本统一使用 `src/components/MarkdownBlock.vue`(内部调用 `src/utils/markdown.ts` 的 `renderMarkdown`)。渲染关闭了原始 HTML,并把链接点击接管到系统浏览器打开。
### 窗口控制与权限
- 前端封装:`src/utils/window.ts`(`minimizeWindow` / `toggleMaximizeWindow` / `closeWindow` / `startDragging`),在非 Tauri 环境(浏览器)下自动空转。
- 前端封装:`src/utils/window.ts`(`minimizeWindow` / `toggleMaximizeWindow` / `closeWindow` / `startDragging` 等),在非 Tauri 环境(浏览器)下自动空转。
- 自定义标题栏依赖无边框窗口(`src-tauri/tauri.conf.json` 中 `decorations: false`),拖拽区域通过 `data-tauri-drag-region` 实现。
- 相关权限在 `src-tauri/capabilities/default.json` 中声明(`core:window:allow-minimize`、`allow-close`、`allow-toggle-maximize`、`allow-start-dragging` 等)。**新增窗口 API 调用时需同步补充对应权限**,否则会报 `not allowed` 错误。
- **窗口圆角(8px)**:Windows 11 由系统 DWM 自动裁切;macOS 的无边框窗口系统不会裁圆角,因此:
- `src-tauri/tauri.macos.conf.json` 中把窗口设为 `transparent: true`(**Tauri 2.12.1+ 无需再开启 `macOSPrivateApi`**);
- `src/assets/main.css` 中在 `html.is-macos` 下把背景从 `<html>` 移到 `#app`,由 `#app` 裁出 8px 圆角,并让 `html`/`body` 透明以透出桌面;
- `src/main.ts` 在挂载前根据 UA 给 `<html>` 打上 `is-macos` 标记。
- ⚠️ 平台配置文件按 **JSON Merge Patch** 合并,且**数组整体替换**,因此 `tauri.macos.conf.json` 里必须完整重复 `app.windows` 的字段;**修改基础配置的窗口参数时,记得同步这份文件**。
- 相关权限在 `src-tauri/capabilities/default.json` 中声明(`core:window:*`、`updater:default`、`process:default` 等)。**新增窗口 / 插件 API 调用时需同步补充对应权限**,否则会报 `not allowed` 错误。
### 发布与自动更新
- 版本号取自 `package.json` 的 `version`(`src-tauri/tauri.conf.json` 引用之),`src-tauri/Cargo.toml` 保持一致。
- 推送 `v*` tag 会触发 `.github/workflows/release.yml`:构建 **Windows x64 / macOS Apple Silicon / Linux x64** 产物,签名生成更新产物与 `latest.json`,并自动从 `docs/CHANGELOG.md` 提取对应版本说明作为 Release 正文。
- 发布前请在 `docs/CHANGELOG.md` 中补上 `## v<版本号>` 段落。
---
## 🗺️ 路线图
- frpc 配置文件的导入 / 导出与校验
- frps 服务端信息的集中管理与状态检测
- 日志持久化与导出
- 系统托盘 / 开机自启 / 关闭到托盘
- 主题跟随系统
- 更多语言与自定义背景
---
@@ -227,4 +270,4 @@ type RunStatus = 'running' | 'error' | 'stopped' // 🟢 | 🔴 | 🟡
## 📄 License
License 待定(待补充)。
本项目基于 [Apache License 2.0](LICENSE) 开源。
+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)
### 🎉 首个正式版本发布!
+12
View File
@@ -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,看一下有哪些依赖可以简化代码开发
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frpilot",
"private": true,
"version": "1.0.0",
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -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"
+2 -1
View File
@@ -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"
]
}
+6 -3
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "frpilot",
"productName": "Frpilot",
"version": "../package.json",
"identifier": "frpilot",
"identifier": "Frpilot",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
@@ -41,7 +41,10 @@
"updater": {
"active": true,
"dialog": false,
"endpoints": ["https://ghproxy.net/https://github.com/SparksFly91/Frpilot/releases/latest/download/latest.json"],
"endpoints": [
"https://ghproxy.net/https://github.com/SparksFly91/Frpilot/releases/latest/download/latest.json",
"https://github.com/SparksFly91/Frpilot/releases/latest/download/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0REVGMTREODFFOTI2MUEKUldRYUp1bUJUZkhlNU1qRmlPQm5xSmdQd24waFltVmNtTjlUallYVmdva1VGNkp5bTVGcVlsZUgK"
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://schema.tauri.app/config/2",
"app": {
"windows": [
{
"title": "Frpilot",
"width": 1100,
"height": 720,
"minWidth": 900,
"minHeight": 600,
"decorations": false,
"transparent": true
}
]
}
}
+6
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+55 -15
View File
@@ -11,28 +11,35 @@ body,
padding: 0;
}
/*
* 背景统一绘制在 <html> 上:
* 根元素的背景会被绘制到整个画布(canvas)上,铺满窗口的每一个像素,
* 不会出现“元素高度(100vh) 与实际窗口差几个像素”导致的上下留白。
*/
html {
background-color: #eef2ff;
background-image:
/* 窗口背景色与渐变(供 html / #app 复用,避免重复定义) */
:root {
--app-bg-color: #eef2ff;
--app-bg-gradient:
radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.28), transparent 60%),
radial-gradient(1000px 500px at 110% 10%, rgba(34, 211, 238, 0.28), transparent 55%),
linear-gradient(160deg, #eef2ff 0%, #f8fafc 45%, #eef7ff 100%);
background-repeat: no-repeat;
}
html.dark {
background-color: #0b1220;
background-image:
--app-bg-color: #0b1220;
--app-bg-gradient:
radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.35), transparent 60%),
radial-gradient(1000px 500px at 110% 10%, rgba(14, 165, 233, 0.28), transparent 55%),
linear-gradient(160deg, #0b1220 0%, #0f172a 45%, #111827 100%);
}
/*
* 默认(Windows / Linux):背景绘制在 <html> 上。
* 根元素的背景会被绘制到整个画布(canvas)上,铺满窗口的每一个像素,
* 不会出现“元素高度(100vh) 与实际窗口差几个像素”导致的上下留白;
* 窗口圆角交给系统裁切(Windows 11 的 DWM 默认就是 8px 圆角)。
*/
html {
background-color: var(--app-bg-color);
background-image: var(--app-bg-gradient);
background-repeat: no-repeat;
}
/* 选中自定义背景图时替换为图片:等比缩放铺满,不拉伸变形 */
html.has-bg-image {
background-image: var(--app-bg-image, none);
@@ -41,11 +48,44 @@ html.has-bg-image {
background-repeat: no-repeat;
}
/*
* macOS:无边框窗口系统不会自动裁圆角(不像 Windows 11 的 DWM),
* 因此通过 tauri.macos.conf.json 开启 transparent 窗口,
* 再把背景从 <html> 移到 #app 上并由 #app 裁出 8px 圆角,
* html/body 保持完全透明,让圆角之外的区域透出桌面壁纸。
*/
html.is-macos,
html.is-macos body {
background: transparent !important;
}
html.is-macos #app {
position: fixed;
inset: 0;
border-radius: 8px;
overflow: hidden;
background-color: var(--app-bg-color);
background-image: var(--app-bg-gradient);
background-repeat: no-repeat;
}
html.is-macos.has-bg-image #app {
background-image: var(--app-bg-image, none);
background-size: cover;
background-position: center center;
background-repeat: no-repeat;
}
/* 全屏遮罩跟随窗口圆角,避免弹窗打开时遮罩把圆角「填方」 */
html.is-macos .ant-modal-mask {
border-radius: 8px;
}
body {
overflow: hidden;
font-family: 'Inter', 'HarmonyOS Sans SC', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
/* 背景交由 <html> 绘制,body 保持透明避免覆盖画布背景 */
/* 背景交由 <html>(或 macOS 下的 #app)绘制,body 保持透明避免覆盖画布背景 */
background-color: transparent;
}
@@ -72,7 +112,7 @@ body {
.glass-panel {
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.45);
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
box-shadow: 0 8px 32px rgba(15, 23, 42, 0.12);
@@ -80,7 +120,7 @@ body {
.dark .glass-panel {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(15, 23, 42, 0.55);
background: rgba(15, 23, 42, 0.45);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
@@ -91,7 +131,7 @@ body {
*/
.glass-card.ant-card {
border: 1px solid rgba(255, 255, 255, 0.5) !important;
background: rgba(255, 255, 255, 0.5) !important;
background: rgba(255, 255, 255, 0.45) !important;
backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%);
box-shadow: 0 6px 22px rgba(15, 23, 42, 0.08) !important;
+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--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>
+36 -61
View File
@@ -2,22 +2,14 @@
<div class="flex items-center gap-1">
<!-- 语言切换 -->
<a-dropdown placement="bottomRight" trigger="click">
<a-button
type="text"
class="tb-btn"
:title="t('titlebar.language')"
>
<template #icon><TranslationOutlined /></template>
<a-button type="text" class="tb-btn" :title="t('titlebar.language')">
<template #icon>
<TranslationOutlined />
</template>
</a-button>
<template #overlay>
<a-menu
:selected-keys="[appStore.language]"
@click="onLanguageClick"
>
<a-menu-item
v-for="item in languageOptions"
:key="item.key"
>
<a-menu :selected-keys="[appStore.language]" @click="onLanguageClick">
<a-menu-item v-for="item in languageOptions" :key="item.key">
{{ item.label }}
</a-menu-item>
</a-menu>
@@ -25,69 +17,50 @@
</a-dropdown>
<!-- 明暗切换 -->
<a-button
type="text"
class="tb-btn"
:title="appStore.isDark ? t('titlebar.lightMode') : t('titlebar.darkMode')"
@click="appStore.toggleDark()"
>
<a-button type="text" class="tb-btn" :title="appStore.isDark ? t('titlebar.lightMode') : t('titlebar.darkMode')"
@click="appStore.toggleDark()">
<template #icon>
<BulbFilled v-if="appStore.isDark" />
<BulbOutlined v-else />
</template>
</a-button>
<!-- 检查更新 -->
<a-button
type="text"
class="tb-btn"
:title="t('titlebar.checkUpdate')"
:loading="updateStore.checking"
@click="onCheckUpdate"
>
<template #icon><CloudSyncOutlined /></template>
</a-button>
<!-- 检查更新(有新版本时显示圆点徽标) -->
<a-badge :dot="updateStore.hasUpdate">
<a-button type="text" class="tb-btn" :title="t('titlebar.checkUpdate')" :loading="updateStore.checking"
@click="onCheckUpdate">
<template #icon>
<CloudSyncOutlined />
</template>
</a-button>
</a-badge>
<!-- GitHub -->
<a-button
type="text"
class="tb-btn"
:title="t('titlebar.github')"
@click="onOpenGithub"
>
<template #icon><GithubOutlined /></template>
<a-button type="text" class="tb-btn" :title="t('titlebar.github')" @click="onOpenGithub">
<template #icon>
<GithubOutlined />
</template>
</a-button>
<!-- 设置 -->
<a-button
type="text"
class="tb-btn"
:title="t('titlebar.settings')"
@click="emit('settings')"
>
<template #icon><SettingOutlined /></template>
<a-button type="text" class="tb-btn" :title="t('titlebar.settings')" @click="emit('settings')">
<template #icon>
<SettingOutlined />
</template>
</a-button>
<!-- 最小化(macOS 由左侧红绿灯负责,此处隐藏) -->
<a-button
v-if="!isMac"
type="text"
class="tb-btn"
:title="t('titlebar.minimize')"
@click="minimizeWindow()"
>
<template #icon><MinusOutlined /></template>
<a-button v-if="!isMac" type="text" class="tb-btn" :title="t('titlebar.minimize')" @click="minimizeWindow()">
<template #icon>
<MinusOutlined />
</template>
</a-button>
<!-- 关闭(macOS 由左侧红绿灯负责,此处隐藏) -->
<a-button
v-if="!isMac"
type="text"
class="tb-btn tb-btn-close"
:title="t('titlebar.close')"
@click="closeWindow()"
>
<template #icon><CloseOutlined /></template>
<a-button v-if="!isMac" type="text" class="tb-btn tb-btn-close" :title="t('titlebar.close')" @click="windowStore.requestClose()">
<template #icon>
<CloseOutlined />
</template>
</a-button>
</div>
</template>
@@ -107,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"
@@ -117,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 }) => {
+2 -11
View File
@@ -1,15 +1,6 @@
<template>
<a-modal
:open="store.modalOpen"
:title="modalTitle"
:closable="!store.installing"
:mask-closable="false"
:keyboard="!store.installing"
:width="520"
centered
wrap-class-name="update-modal-wrap"
@cancel="onCancel"
>
<a-modal :open="store.modalOpen" :title="modalTitle" :closable="!store.installing" :mask-closable="false"
:keyboard="!store.installing" :width="520" centered wrap-class-name="update-modal-wrap" @cancel="onCancel">
<!-- 俏皮的问候 + 版本变化 -->
<div class="update-hero">
<span class="update-hero-emoji">🎉</span>
+9 -21
View File
@@ -1,12 +1,14 @@
<template>
<!-- Windows 自定义标题栏 -->
<div
class="titlebar flex h-12 shrink-0 items-center justify-between pl-4 pr-2 select-none"
data-tauri-drag-region
>
<!-- 左侧:标题 -->
<div class="titlebar flex h-12 shrink-0 items-center justify-between pl-4 pr-2 select-none" data-tauri-drag-region>
<!-- 左侧:图标 + 标题 -->
<div class="flex items-center gap-2" data-tauri-drag-region>
<span class="brand-logo">F</span>
<img
class="h-[22px] w-[22px] shrink-0 object-contain"
:src="appIcon"
alt=""
draggable="false"
/>
<span class="text-sm font-semibold tracking-wide">Frpilot</span>
</div>
@@ -17,21 +19,7 @@
<script setup lang="ts">
import TitleBarActions from "./TitleBarActions.vue"
import appIcon from "@/assets/images/app-icon.webp"
const emit = defineEmits<{ settings: [] }>()
</script>
<style scoped lang="scss">
.brand-logo {
display: inline-flex;
height: 22px;
width: 22px;
align-items: center;
justify-content: center;
border-radius: 6px;
background: linear-gradient(135deg, #6366f1, #22d3ee);
color: #fff;
font-size: 12px;
font-weight: 700;
}
</style>
+9 -7
View File
@@ -5,15 +5,17 @@ export type Platform = "windows" | "macos" | "linux" | "unknown"
* 判断当前运行的操作系统类型。
* 通过 navigator.userAgent 推断,避免额外引入 @tauri-apps/plugin-os。
*/
export function usePlatform() {
export function detectPlatform(): Platform {
const ua = (navigator.userAgent || "").toLowerCase()
if (ua.includes("mac") || ua.includes("darwin")) return "macos"
if (ua.includes("win")) return "windows"
if (ua.includes("linux") || ua.includes("x11")) return "linux"
return "unknown"
}
const platform = computed<Platform>(() => {
if (ua.includes("mac") || ua.includes("darwin")) return "macos"
if (ua.includes("win")) return "windows"
if (ua.includes("linux") || ua.includes("x11")) return "linux"
return "unknown"
})
/** 在组件中响应式地获取当前平台 */
export function usePlatform() {
const platform = ref<Platform>(detectPlatform())
const isMac = computed(() => platform.value === "macos")
const isWindows = computed(() => platform.value === "windows")
+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",
"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",
+13
View File
@@ -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": "内存占用",
+4
View File
@@ -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()
+10
View File
@@ -9,6 +9,16 @@ import "ant-design-vue/dist/reset.css"
import router from "@/router/router"
// 引入国际化
import i18n from "@/i18n"
import { detectPlatform } from "@/composables/usePlatform"
/*
* macOS 的无边框窗口系统不会自动裁圆角,需要配合 transparent 窗口,
* 由 #app 自身裁出 8px 圆角(见 assets/main.css)。这里在挂载前打上平台标记,
* 避免首帧出现方角闪烁。
*/
if (detectPlatform() === "macos") {
document.documentElement.classList.add("is-macos")
}
const app = createApp(App)
app.use(createPinia())
+37 -1
View File
@@ -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,
}
})
+13 -2
View File
@@ -60,11 +60,19 @@ export const useUpdateStore = defineStore("update", () => {
return
}
// 已检测到可用更新(例如用户点过「取消」关掉了弹窗):直接重新打开,
// 避免重复联网检查、重复创建后端资源;徽标也因此能持续提示。
if (pending) {
modalOpen.value = true
return
}
checking.value = true
try {
const update = await checkForAppUpdate()
if (!update) {
info.value = null
if (!silent) message.success(translate("update.upToDate"))
return
}
@@ -107,9 +115,12 @@ export const useUpdateStore = defineStore("update", () => {
}
}
/** 取消(本次不更新,下次启动仍会提示) */
/**
* 取消:仅关闭弹窗,保留已检测到的可用更新。
* 顶部栏 / 关于页的圆点徽标会继续提示,再次点击「检查更新」可重新打开弹窗。
*/
function cancel() {
void discard()
modalOpen.value = false
}
/** 忽略该版本(持久化,静默检查不再提示) */
+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,
}
})
+4
View File
@@ -11,6 +11,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card']
ACol: typeof import('ant-design-vue/es')['Col']
@@ -30,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']
@@ -41,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']
+22
View File
@@ -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()
+5 -3
View File
@@ -48,9 +48,11 @@
<div class="flex flex-wrap items-center gap-3">
<span class="text-xs opacity-60">{{ t("about.currentVersion") }}</span>
<a-tag color="purple" class="!me-0">v{{ version }}</a-tag>
<a-button size="small" :loading="updateStore.checking" @click="onCheckUpdate">
{{ t("about.checkUpdate") }}
</a-button>
<a-badge :dot="updateStore.hasUpdate">
<a-button size="small" :loading="updateStore.checking" @click="onCheckUpdate">
{{ t("about.checkUpdate") }}
</a-button>
</a-badge>
<span v-if="updateStore.hasUpdate" class="text-xs text-[#6366f1]">
{{ t("about.newVersion", { version: updateStore.info?.version }) }}
</span>