Compare commits

...
15 Commits
Author SHA1 Message Date
Yuhang Wu 74f7b7d30f feat: 新增自定义窗口背景功能 2026-10-28 11:17:28 +08:00
Yuhang Wu 135e465b16 feat: 新增系统主题跟随、窗口遮罩和运行日志持久化
- 新增系统主题模式,支持跟随操作系统明暗主题
- 添加窗口背景遮罩组件,可调节透明度改善对比度
- 实现 Frpc 运行日志按日期持久化到 logs 目录
- 优化侧边栏收起时的系统资源显示样式
- 新增中英文界面的相关翻译文本
- 实现系统资源统计的共享复用,避免重复请求
2026-10-28 10:55:01 +08:00
Yuhang Wu 538b502c52 build: 升级版本至1.4.1并更新变更日志 2026-10-26 15:25:41 +08:00
Yuhang Wu 8082c69d22 refactor(UpdateModal): 调整下载进度和操作按钮的布局位置 2026-10-26 15:15:49 +08:00
Yuhang Wu 04f6d3f851 style(aside): 优化侧边栏切换按钮的布局适配
调整侧边栏展开/折叠状态下切换按钮的对齐方式和内边距,使其与菜单项排版保持一致
2026-10-26 15:01:49 +08:00
Yuhang Wu dad406b4cd docs: 更新 CHANGELOG 至 v1.4.0 版本说明 2026-10-26 14:23:12 +08:00
Yuhang Wu ec644e5434 feat: 按安装目录隔离存储应用数据,实现多实例独立运行
新增 StoragePaths 模块,将数据库、设置、frp 版本等业务数据统一保存在应用实际安装位置,避免不同实例或版本间数据混淆。新增 Preferences 模块管理安装目录内的持久化设置,重构数据库初始化逻辑,修改 Frpc 运行目录、frp 版本存储路径,新增数据目录锁机制防止多进程冲突,同时更新文档说明数据目录规则与平台差异。
2026-10-26 14:16:18 +08:00
Yuhang Wu 93b15ccb16 feat: 添加新手引导功能和侧边栏折叠功能 2026-10-26 11:29:43 +08:00
Yuhang Wu fafc87f4c6 feat: 调整Frp版本筛选逻辑为平台下拉选择
将原有的两个架构开关替换为平台下拉筛选器,统一Frp相关文本表述,版本号升级至1.4.0
2026-10-26 09:56:55 +08:00
Yuhang Wu ffd0ef7935 feat: 添加开机自启支持与开发版本提示 2026-10-22 16:36:11 +08:00
Yuhang Wu fcab06d28f docs: 更新 CHANGELOG 至 v1.3.0 版本内容 2026-10-20 10:29:57 +08:00
Yuhang Wu e66562d230 build: 升级版本号至1.3.0 2026-10-20 10:23:42 +08:00
Yuhang Wu 8eac325b75 feat(about): add version info and contact section to about page 2026-10-20 10:22:23 +08:00
Yuhang Wu bba146fd1a feat: 添加微信二维码及相关国际化配置
新增微信图片资源,补充联系方式相关的中英双语文案,新增复制功能的国际化文本,同步更新 README 格式优化
2026-10-20 10:22:14 +08:00
Yuhang Wu 7cbc80176d feat: 新增开机自启功能 2026-10-20 10:03:36 +08:00
58 changed files with 2619 additions and 345 deletions
+70 -54
View File
@@ -2,22 +2,24 @@
# FrpcPilot
**frp 可视化桌面客户端 —— 告别手写 `frpc.toml`**
**frp 可视化桌面客户端 —— 告别手写** **`frpc.toml`**
下载 frpc、配置服务器、编排隧道、一键启动并实时查看日志,全部在同一个窗口里完成。
[![Release](https://img.shields.io/github/v/release/SparksFly91/FrpcPilot?style=flat-square&color=6366f1)](https://github.com/SparksFly91/FrpcPilot/releases)
[![License](https://img.shields.io/github/license/SparksFly91/FrpcPilot?style=flat-square&color=6366f1)](./LICENSE)
[![Release](https://img.shields.io/github/v/release/SparksFly91/FrpcPilot?style=flat-square\&color=6366f1)](https://github.com/SparksFly91/FrpcPilot/releases)
[![License](https://img.shields.io/github/license/SparksFly91/FrpcPilot?style=flat-square\&color=6366f1)](./LICENSE)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-6366f1?style=flat-square)](#-下载安装)
[![Tauri](https://img.shields.io/badge/Tauri-2-24C8DB?style=flat-square&logo=tauri&logoColor=white)](https://v2.tauri.app/)
[![Vue](https://img.shields.io/badge/Vue-3-42B883?style=flat-square&logo=vuedotjs&logoColor=white)](https://vuejs.org/)
[![Tauri](https://img.shields.io/badge/Tauri-2-24C8DB?style=flat-square\&logo=tauri\&logoColor=white)](https://v2.tauri.app/)
[![Vue](https://img.shields.io/badge/Vue-3-42B883?style=flat-square\&logo=vuedotjs\&logoColor=white)](https://vuejs.org/)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-6366f1?style=flat-square)](https://github.com/SparksFly91/FrpcPilot/pulls)
</div>
运行数据按安装位置分类保存,目录规则与平台差异见 [数据目录说明](docs/数据目录说明.md)。
> 📘 **新手必读**:第一次搭建内网穿透?请阅读 **[《Frp 内网穿透搭建指南》](docs/Frp搭建内网穿透指南.md)** —— 从公网服务端搭建到用 FrpcPilot 一键开启穿透,手把手带你跑通第一个隧道。
---
***
## 🏷️ 关于「FrpcPilot」这个名字
@@ -28,7 +30,7 @@
一句话:**让 frpc 起飞,你只管掌舵。** ✈️
---
***
## 📖 项目简介
@@ -38,68 +40,76 @@
> 当前版本 **v1.2.1**,已支持 Windows / macOS(Apple Silicon)/ Linux 三平台,并内置应用内自动更新。
---
***
## 🖼️ 界面预览
| 仪表盘 | 服务器 |
| --- | --- |
| 仪表盘 | 服务器 |
| ------------------------------- | ------------------------------- |
| ![仪表盘](docs/snipastes/仪表盘.webp) | ![服务器](docs/snipastes/服务器.webp) |
| 隧道列表 | Frpc 下载 |
| --- | --- |
| 隧道列表 | Frpc 下载 |
| --------------------------------- | -------------------------------------- |
| ![隧道列表](docs/snipastes/隧道列表.webp) | ![Frpc 下载](docs/snipastes/frpc下载.webp) |
| 设置 · 常规 | 设置 · 外观 |
| --- | --- |
| 设置 · 常规 | 设置 · 外观 |
| ---------------------------------- | ------------------------------- |
| ![常规设置](docs/snipastes/设置·常规.webp) | ![外观设置](docs/snipastes/设置.webp) |
<p align="center">
<img src="docs/snipastes/关于.webp" alt="关于" width="49%" />
</p>
---
***
## ✨ 功能特性
### 📊 仪表盘
- 一屏总览:服务器数量、隧道数量,以及主机名 / 系统 / 架构 / IP 地址等系统信息
- 选择 frpc 版本后「**一键启动 / 停止**」,运行状态实时联动
- **实时日志**:通过事件流回显 frpc 运行日志(保留最近 500 行,支持一键清空)
- 侧边栏常驻 **CPU / 内存占用** 与运行状态指示(🟢 运行正常 · 🔴 运行异常 · 🟡 未启动)
### 🖥️ 服务器管理
- frps 服务器的新增 / 编辑 / 删除
- 支持服务器地址、端口、鉴权 Token、传输协议、TLS 开关与备注
- 置顶与排序;启用开关(**同一时间仅允许启用一台**服务器)
### 🔀 隧道管理
- 覆盖 **TCP / UDP / HTTP / HTTPS / STCP / XTCP / SUDP / TCPMUX** 等代理类型
- 本地 IP / 端口、远程端口、自定义域名、子域名、URL 前缀
- 高级配置:密钥、加密、压缩、带宽限制、Proxy Protocol、健康检查、负载均衡
- 统一管理启用 / 置顶 / 排序
### 📦 Frpc 下载管理
- 在线拉取 frp 官方版本列表,按平台与架构筛选,支持「所有架构 frpc / frps」开关
- 一键下载并自动解压,实时显示下载 / 解压进度与状态(支持镜像加速)
- 导入本地已有的 `frpc` / `frps`,删除已下载版本、复制下载直链
- **页面级缓存**:应用运行期间仅首次进入时拉取版本列表,避免重复请求触发限流,需要更新时手动点击「刷新」
### 🚀 一键启动与进程管理
- 依据所选版本、已启用的服务器与隧道,自动生成 `frpc.toml` 并拉起独立进程
- 通过 `frpc-log` / `frpc-exit` 事件实时推送日志与进程退出信息
- 应用退出时自动结束 frpc 子进程,避免残留后台进程
### 🗂️ 系统托盘
- 常驻托盘菜单:**显示窗口 / 启动穿透 / 停止穿透 / 退出**
- 左键单击直达主窗口,右键展开菜单;菜单随运行状态与界面语言自动联动
- 「退出」会先停止正在运行的穿透,再退出整个应用
### 🔔 关闭行为
- 关闭窗口时可选「**最小化到系统托盘**」或「**直接退出**」
- 支持「每次询问」与「保持以上选择」两种策略,也可在「设置 - 常规」中随时修改
### 🎨 界面与体验
- **自定义标题栏**:Windows / macOS 双套方案,窗口可拖拽移动;macOS 还原原生红绿灯
- **明暗主题**:一键切换,Tailwind 暗色变体与 Ant Design Vue 暗色算法同步
- **磨砂玻璃视觉**:侧边栏、内容区与卡片统一为圆角 + `backdrop-filter` 半透明面板,透明度可调
@@ -107,52 +117,54 @@
- **国际化**:内置简体中文 / English,语言选择持久化并与组件库语言包联动
### 🔄 应用内自动更新
- 启动时静默检查更新,支持手动检查
- 更新弹窗以 **Markdown** 渲染更新说明并展示下载进度
- 支持「忽略该版本」、一键更新,安装完成后自动重启
### ⚙️ 设置中心
- **常规**:关闭行为、应用语言
- **外观**:主题模式、背景图片、磨砂玻璃透明度(卡片 / 弹窗,实时预览)
- **关于**:项目介绍、技术栈、使用流程、开源地址与版本信息
---
***
## 📥 下载安装
前往 [**Releases**](https://github.com/SparksFly91/FrpcPilot/releases/latest) 下载对应平台的安装包:
前往 **[Releases](https://github.com/SparksFly91/FrpcPilot/releases/latest)** 下载对应平台的安装包:
| 平台 | 安装包 |
| --- | --- |
| Windows | `.exe`(NSIS 安装程序) |
| macOS(Apple Silicon) | `.dmg` / `.app` |
| Linux | `.deb` / `.AppImage` |
| 平台 | 安装包 |
| -------------------- | -------------------- |
| Windows | `.exe`(NSIS 安装程序) |
| macOS(Apple Silicon) | `.dmg` / `.app` |
| Linux | `.deb` / `.AppImage` |
装好后打开应用即可,后续版本会在应用内自动检查并更新。
> 🧭 **首次使用?** 还不清楚怎么搭建 frps 服务端、怎么配置隧道?请参考 [《Frp 内网穿透搭建指南》](docs/Frp搭建内网穿透指南.md)。
---
***
## 🧱 技术栈
| 分类 | 选型 |
| --- | --- |
| 桌面框架 | [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` · `dayjs` |
| 后端 | Rust:`sqlx` + SQLite、`reqwest`(下载)、`sysinfo`(系统采集)、`tokio` |
| Tauri 插件 | `updater` / `process` / `dialog` / `opener` / `store` / `prevent-default` |
| 工程化 | `unplugin-auto-import`、`unplugin-vue-components`(按需自动导入)、`tauri-helper`(命令注册) |
| 分类 | 选型 |
| -------- | ----------------------------------------------------------------------------- |
| 桌面框架 | [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` · `dayjs` |
| 后端 | Rust:`sqlx` + SQLite、`reqwest`(下载)、`sysinfo`(系统采集)、`tokio` |
| Tauri 插件 | `updater` / `process` / `dialog` / `opener` / `store` / `prevent-default` |
| 工程化 | `unplugin-auto-import`、`unplugin-vue-components`(按需自动导入)、`tauri-helper`(命令注册) |
---
***
## 📦 环境要求
@@ -164,7 +176,7 @@
- macOS:Xcode Command Line Tools
- Linux:`webkit2gtk`、`libayatana-appindicator`、`librsvg` 等
---
***
## 🚀 快速开始
@@ -185,17 +197,21 @@ pnpm dev
### 常用脚本
| 命令 | 说明 |
| --- | --- |
| `pnpm dev` | 启动 Vite 开发服务器(端口 `1420`) |
| `pnpm tauri dev` | 启动 Tauri 桌面开发环境(自动拉起前端) |
| `pnpm build` | 类型检查(`vue-tsc --noEmit`)+ 构建前端产物到 `dist/` |
| `pnpm preview` | 预览前端构建产物 |
| `pnpm tauri build` | 打包生成各平台安装包 |
开机自启需要使用 `pnpm tauri build` 或 `pnpm tauri build --no-bundle` 生成的正式程序。正式程序内置前端资源,不依赖 Vite,Windows 下也不会弹出调试控制台。开发模式和 debug 构建不支持开启自启。
自启路径由插件动态获取当前运行的正式程序路径,不写死盘符、安装目录或项目构建目录。正式程序启动时,若已开启自启,会自动更新已有启动项的路径。因此重新安装或移动程序后,只需从新位置打开一次正式程序,即可校正旧路径;已关闭的自启不会被自动开启。
| 命令 | 说明 |
| ------------------ | ----------------------------------------- |
| `pnpm dev` | 启动 Vite 开发服务器(端口 `1420`) |
| `pnpm tauri dev` | 启动 Tauri 桌面开发环境(自动拉起前端) |
| `pnpm build` | 类型检查(`vue-tsc --noEmit`)+ 构建前端产物到 `dist/` |
| `pnpm preview` | 预览前端构建产物 |
| `pnpm tauri build` | 打包生成各平台安装包 |
> ⚠️ `pnpm dev` 端口固定为 `1420` 且 `strictPort: true`,被占用时会直接报错退出。
---
***
## 📁 项目结构
@@ -240,7 +256,7 @@ FrpcPilot/
└── package.json
```
---
***
## 🛠️ 开发指南
@@ -287,19 +303,19 @@ FrpcPilot/
- 推送 `v*` tag 会触发 `.github/workflows/release.yml`:构建 **Windows x64 / macOS Apple Silicon / Linux x64** 产物,签名生成更新产物与 `latest.json`,并自动从 `docs/CHANGELOG.md` 提取对应版本说明作为 Release 正文。
- 发布前请在 `docs/CHANGELOG.md` 中补上 `## v<版本号>` 段落。
---
***
## 🗺️ 路线图
- [x] 系统托盘与关闭到托盘
- [ ] 开机自启
- [x] 开机自启
- [ ] 主题跟随系统
- [ ] frpc 配置文件的导入 / 导出与校验
- [ ] frps 服务端信息的集中管理与状态检测
- [ ] 日志持久化与导出
- [ ] 更多语言与更多内置背景
---
***
## 🤝 参与贡献
@@ -311,7 +327,7 @@ FrpcPilot/
如果 FrpcPilot 对你有帮助,欢迎点一个 ⭐ **Star** 支持一下~
---
***
## 📚 参考链接
@@ -323,7 +339,7 @@ FrpcPilot/
- [VueUse](https://vueuse.org/)
- [frp 官方仓库](https://github.com/fatedier/frp)
---
***
## 📄 License
+118
View File
@@ -4,6 +4,124 @@
---
## v1.5.0(2026-10-28)
### ✨ 新增功能
- **跟随系统主题**:「设置 - 外观」新增「跟随系统」,可随操作系统的明暗主题自动切换;也可继续手动选择浅色或深色主题。
- **自定义窗口背景**:可在「设置 - 外观」中选择或更换自己的背景图片,支持 PNG、JPG 和 WebP,单张图片不超过 15 MB。图片会保存到应用数据目录,重新打开软件后仍可使用。
- **窗口背景遮罩**:在窗口背景上叠加随明暗主题变化的遮罩,并可在外观设置中单独调整透明度,改善背景与顶部栏操作图标之间的对比度。
### 🎨 优化
- **主题背景**:默认背景颜色改为适配明暗主题的纯色,并调整内置背景图片与明暗主题下的整体观感。
- **折叠侧栏资源占用**:菜单栏折叠后直接显示 CPU、内存百分比和运行状态,无需悬停查看。
- **系统资源数据复用**:仪表盘与侧栏共享 CPU、内存占用数据缓存,减少进入仪表盘后等待数据加载的时间。
### 📁 数据与日志
- **Frpc 运行日志持久化**:运行日志按日期追加保存到安装目录的 `logs/frpc-YYYY-MM-DD.log`,包含 Frpc 输出及启动、停止和退出等状态记录。
### 🧱 工程
- 应用版本升级至 **v1.5.0**。
## v1.4.1(2026-10-26)
### 🎨 优化
- **侧边栏折叠操作布局**:展开时与「设置」入口左侧对齐;折叠后图标保持居中,提升导航布局的一致性。
- **更新下载进度展示**:将下载进度条移至更新弹窗底部操作区,并固定显示在操作按钮上方;更新日志较长时,滚动正文也能随时查看进度和操作按钮。
### 🧱 工程
- 应用版本升级至 **v1.4.1**。
## v1.4.0(2026-10-26)
### ⚠️ 升级提示
- **数据保存位置变更**:本版本不自动迁移旧版数据。升级后需重新配置服务器与隧道,并重新下载或导入 frpc;原系统默认数据目录中的文件保留不动,原有界面设置也不会自动继承。
- **安装位置要求**:请将软件安装在当前用户可写的专用目录。目录不可写时会提示错误并停止启动,不回退到系统默认数据目录。移动安装位置后,如已保存的 frp 路径失效,需重新下载或导入。
### ✨ 新增功能
- **新手引导**:首次启动时,通过四步引导依次介绍新增服务器、新增隧道、下载 Frp 和一键启动穿透;支持跳过,关闭后不再自动弹出,可在「设置 - 常规 - 新手引导」中重新查看。同步支持简体中文与英文。
- **侧边栏折叠**:支持在完整菜单与图标模式之间切换,并记住折叠状态,为主内容区留出更多空间;折叠后仍可导航、进入设置,并通过悬浮提示查看运行状态与 CPU、内存信息。
- **按安装目录隔离数据**:不同安装目录分别保存各自的数据库与设置,可独立运行;同一数据目录通过文件锁防止多个进程同时使用。开发构建使用独立的 `FrpcPilot-dev` 目录,避免与正式版配置混用。
### 📁 数据存储
- **分类保存运行数据**:数据库与界面设置保存在 `data`,下载、解压和导入的 frp 保存在 `frp/versions`,启动穿透时生成的 `frpc.toml` 保存在 `run`;浏览器数据、缓存和临时文件分别使用 `webview`、`cache`、`tmp` 等目录。
- **适配不同平台的安装位置**:Windows 使用可执行文件所在目录,Linux AppImage 使用实际 AppImage 文件所在目录,普通 Linux 二进制使用可执行文件所在目录;macOS 使用 `.app` 同级的 `FrpcPilot-data`,避免影响应用签名与更新。macOS 系统管理的 WebView 缓存仍可能保存在用户 Library 中。
- **设置随安装目录保存**:语言、外观、关闭行为、侧边栏状态、新手引导状态及忽略的更新版本等统一保存在 `data/preferences.json`,启动时读取,并在退出、更新或重启前完成待保存的设置写入。
### 🎨 优化
- **Frp 平台筛选**:下载页更名为「Frp下载」,用平台与架构下拉框替代原有的 Frpc / Frps 架构开关;首次加载默认选择当前平台,也可查看全部平台,刷新时尽量保留当前选择。
- **版本列表排序与展示**:按版本号数值降序排列,避免字符串排序造成新旧版本顺序错误;查看全部平台时按版本分组,下载卡片统一标明包含 `frpc` 与 `frps`。
- **本地导入更独立**:导入的 frpc / frps 会复制到安装目录内统一管理,原文件保持不变,后续使用不再依赖原始文件的位置。
- **启动加载反馈**:新增应用启动加载动画与提示,等待页面就绪并完成首帧渲染后再显示主界面,减少启动白屏与闪烁;加载动画与侧边栏过渡适配减少动态效果的系统偏好。
- **更新提示时机**:启动时的静默更新检查延后至新手引导结束或关闭后执行,避免引导与更新弹窗同时出现。
### 📝 文档
- 新增[《数据目录说明》](./数据目录说明.md),说明目录分类、各平台保存位置、写入权限、备份及安装位置变更注意事项;README 补充文档入口。
### 🧱 工程
- Tauri 最低依赖版本调整为 `2.12.3`,支持应用数据目录覆盖;新增存储目录初始化与独占锁测试。
- 应用版本升级至 **v1.4.0**。
## v1.3.2(2026-10-22)
### 🐛 修复
- **开机自启路径动态校正**:自启项动态获取当前正式程序的 EXE 路径,不写死盘符、安装目录或项目构建目录。已开启自启时,正式程序启动后会自动更新已有启动项;重新安装或移动程序后,从新位置打开一次即可校正旧路径,已关闭的自启保持关闭。
- **阻止开发版本注册自启**:开发模式及 debug 构建不再注册自启插件,避免将依赖 Vite 本地服务的开发程序写入启动项,导致重启后主界面显示 `ERR_CONNECTION_REFUSED`,并弹出调试控制台窗口。
### 🎨 优化
- **自启设置提示**:开发版本的自启开关自动置灰,并明确提示需要在正式构建中开启;同步补充简体中文与英文文案。
### 📝 文档
- README 补充正式构建的自启使用说明,以及重新安装或移动程序后的路径校正方式。
## v1.3.0(2026-10-20)
### ✨ 新增功能
- **开机自启**:「设置 - 常规」新增「开机自启」开关,按 Tauri 官方 `autostart` 插件实现,直接读写系统启动项(Windows 注册表 Run 键 / macOS LaunchAgent / Linux XDG autostart)。
- 进入设置页时读取系统启动项的真实状态,切换后再次回读,界面状态始终与系统保持一致,不在本地另行持久化;
- 写入失败会回滚开关并提示失败原因,避免出现「界面已开启、系统未生效」的假象;
- 非桌面环境(如浏览器调试)无法访问启动项时,开关自动置灰并给出说明,而不是展示可疑状态。
- **关于页面 · 联系方式**:关于页新增「联系方式」区块,展示作者邮箱与微信二维码,方便反馈问题与交流。
- 邮箱点击可唤起系统默认邮件客户端,机器上没有可用客户端时自动退化为复制邮箱;
- 邮箱提供一键复制,复制失败会提示手动复制;
- 微信以二维码图片展示,使用 Ant Design Vue 的 `a-image` 组件,点击可放大预览,方便用手机扫码添加。
### 🎨 优化
- **关于页面信息层级调整**:把「版本信息 / 检查更新」从页面底部移到「项目介绍」之前,进入关于页即可先看到当前版本与更新入口;页面顺序调整为 版本信息 → 项目介绍 → 使用流程 → 开源地址 → 联系方式。
- **常规设置排版更紧凑**:「设置 - 常规」各组之间的间距与分隔线留白收紧,新增「开机自启」后整体仍保持整齐的纵向节奏。
- **卡片式单选组(`CardRadioGroup`)更紧凑**:缩小选项间距、内边距与圆角,统一下调标题与描述字号,关闭确认弹窗与常规设置页同步受益。
- **关闭确认弹窗更紧凑**:重新安排弹窗内边距、两组选项之间的分隔线与文案间距,减少纵向占用。
### 📝 文档
- 新增 **《Frp 内网穿透搭建指南》**:从公网服务端(frps)搭建到用 FrpcPilot 一键开启穿透,手把手跑通第一个隧道,并补充配套界面截图。
- README 新增「新手必读」与首次使用提示,引导新手先阅读上述指南;路线图中「开机自启」已标记完成。
- README 排版规范化(表格对齐、分隔线、徽章链接转义等),并补充下载安装与设置中心的说明。
- 修正《Frp 内网穿透搭建指南》中「frpc 下载」截图文件名大小写不一致的问题(Linux / macOS 等区分大小写的环境下图片会加载失败);同时把后端测试用例中的示例 IP 替换为通用地址。
### 🧱 工程
- 新增依赖:Rust 侧 `tauri-plugin-autostart`,前端侧 `@tauri-apps/plugin-autostart`;capabilities 增加 `autostart:default` 权限(可查询 / 开启 / 关闭开机自启)。
- 新增开机自启的插件封装(`src/api/autostart.ts`)与状态管理(`src/stores/autostart.ts`),并对非桌面环境做安全降级。
- 关于页引入微信二维码图片资源,`unplugin-vue-components` 自动注册 `AImage` 组件。
- 应用版本升级至 **v1.3.0**。
## v1.2.1(2026-10-16)
### 🔄 项目更名
+183
View File
@@ -71,6 +71,150 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
- 设置页面里面也有一些没有随主题改变颜色
- 整体全部检查并且修复一下
#### 2.7 侧边栏折叠和漫游式引导
- 侧边导航栏在设置下面增加一个折叠/展开的图标,点击用于控制侧边导航栏的折叠和展开
- 侧边导航栏折叠之后,需要注意折叠之后的图标居中显示,并且鼠标放上去之后的hover效果显示的时候,图标也要在hover效果里面居中
- 官方参考代码如下:
```html
<template>
<div style="width: 256px">
<a-button type="primary" style="margin-bottom: 16px" @click="toggleCollapsed">
<MenuUnfoldOutlined v-if="state.collapsed" />
<MenuFoldOutlined v-else />
</a-button>
<a-menu
v-model:openKeys="state.openKeys"
v-model:selectedKeys="state.selectedKeys"
mode="inline"
theme="dark"
:inline-collapsed="state.collapsed"
:items="items"
></a-menu>
</div>
</template>
<script lang="ts" setup>
import { reactive, watch, h } from 'vue';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
PieChartOutlined,
MailOutlined,
DesktopOutlined,
InboxOutlined,
AppstoreOutlined,
} from '@ant-design/icons-vue';
const state = reactive({
collapsed: false,
selectedKeys: ['1'],
openKeys: ['sub1'],
preOpenKeys: ['sub1'],
});
const items = reactive([
{
key: '1',
icon: () => h(PieChartOutlined),
label: 'Option 1',
title: 'Option 1',
},
{
key: '2',
icon: () => h(DesktopOutlined),
label: 'Option 2',
title: 'Option 2',
},
{
key: '3',
icon: () => h(InboxOutlined),
label: 'Option 3',
title: 'Option 3',
},
{
key: 'sub1',
icon: () => h(MailOutlined),
label: 'Navigation One',
title: 'Navigation One',
children: [
{
key: '5',
label: 'Option 5',
title: 'Option 5',
},
{
key: '6',
label: 'Option 6',
title: 'Option 6',
},
{
key: '7',
label: 'Option 7',
title: 'Option 7',
},
{
key: '8',
label: 'Option 8',
title: 'Option 8',
},
],
},
{
key: 'sub2',
icon: () => h(AppstoreOutlined),
label: 'Navigation Two',
title: 'Navigation Two',
children: [
{
key: '9',
label: 'Option 9',
title: 'Option 9',
},
{
key: '10',
label: 'Option 10',
title: 'Option 10',
},
{
key: 'sub3',
label: 'Submenu',
title: 'Submenu',
children: [
{
key: '11',
label: 'Option 11',
title: 'Option 11',
},
{
key: '12',
label: 'Option 12',
title: 'Option 12',
},
],
},
],
},
]);
watch(
() => state.openKeys,
(_val, oldVal) => {
state.preOpenKeys = oldVal;
},
);
const toggleCollapsed = () => {
state.collapsed = !state.collapsed;
state.openKeys = state.collapsed ? [] : state.preOpenKeys;
};
</script>
```
- 第一次进入应用时,使用Antdv的漫游式引导组件做一个引导操作
- 漫游式引导的目的是帮助用户快速上手FrpcPilot的功能使用流程
- 配置服务器、创建隧道、下载Frp、仪表盘一键启动这是主要的四步流程,你可以按照这个来做引导
- 打开应用时,当主界面未显示完整(还是白屏的时候),做一个启动加载动画,显示“FrpcPilot正在启动中”
- 加载动画可以是一圈正在转圈的圆点,圆点大小要有变化,颜色在加载的过程中也可以有变化,最好是做成svg动画的形式,或者你有更好的方案也可以采用
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
### 3. 功能开发
#### 3.1 开发服务器配置功能
@@ -180,6 +324,10 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
- 不需要做持久化缓存,只需要在应用运行时生效即可
- 应用运行时只有在第一次进入Frpc下载页面的时候采取请求GitHub,后面再切换回Frpc下载页面的时候,因为缓存了组件,所以不必再次获取GitHub,需要刷新的时候由用户手动点击刷新按钮
#### 3.11 自定义背景
外面的背景图片,增加一个自定义,由用户自己选择图片并设置为背景图片,自定义的图片需要保证下次打开软件仍然是这个背景,可以使用Antdv里面的上传组件,或者你有更好的方案可以替换也可以
### 4. 修改
#### 4.1 项目名称修改
@@ -189,3 +337,38 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
- 其他与项目名称有关的也修改一下
先扫描一下有哪些需要修改的,列一个清单出来,经过我的确认之后再开始修改
#### 4.2 Frpc下载功能修改
保持Frpc下载主要功能不变,现在需要对检索结果进行过滤方面的修改,具体如下:
- 去掉当前的两个开关选项“所有架构frpc”和“所有架构frps”
- 换成一个下拉列表,列表值为frp的所有不同架构分类,命名方式为:系统类型+下划线+架构,例如:windows_amd64、darwin_amd64、linux_amd64、linux_arm等等,默认值为当前运行环境操作系统类型和架构,最顶上有一个“全部”选项用来显示所有
- 如果是选中“全部”,排列顺序为按照frp版本,从高到低显示同一个版本不同系统和架构的frp,再显示低版本的
- 修改过程中保持主要的下载功能不变,包括加速方案
- 左侧菜单栏改成Frp下载
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
#### 4.3 程序运行数据目录
当前软件运行的数据在我这台电脑上都在:C:\Users\OrBit-0297\AppData\Roaming\FrpcPilot这个目录里面,我想要修改为数据保存在软件安装所在目录,例如我这台电脑安装在:D:\software\FrpcPilot,按照以下要求修改数据保存的目录:
- 数据库文件、下载的frp全部保存在软件安装所在目录里面,并做好分类,例如:db数据库文件放在data目录里面,下载的frp放在frp目录里面,运行frpc生成的toml文件放在run目录里面
- 程序运行时生成的其他文件等也放在软件安装目录下面
- 我这样做的目的是假如安装位置不在C盘,就尽量减少C盘的占用,如果本身就安装在C盘的某个位置,上面说的那些文件保存路径也是以软件安装目录为准,避免放在默认目录里面
- 顺便准备一份迁移脚本,在下一个版本升级的时候便于老版本用户无感迁移之前的数据,注意新安装用户不需要使用这个迁移脚本
- 我想到的方案是脚本,如果你有更好的方案的话,那就采用你的方案,如果没有,就按照我的要求来做,同时你需要考虑到我遗漏的地方
- 数据迁移之后,可能还需要处理数据库中保存的frp软件目录所在位置
- 迁移脚本需要考虑不同操作系统之间的差别
- 或许可以考虑直接把之前的数据目录直接剪切到安装所在目录里面
- 数据迁移之后需要保证功能和已配置的服务器、隧道可以正常启动
#### 4.4 页面UI修改
- 左侧导航栏里面的CPU和内存占用,在菜单栏折叠起来之后,要显示;目前是鼠标放上去之后才显示,需要改成在折叠之后的菜单栏里面显示,上面显示CPU占用,下面显示内存占用。具体表现为:CPU:换行显示百分比,换行,内存:换行显示百分比,换行,状态。也可以不要CPU:和内存:,只显示百分比,字体可以小一点,但是主要的是占用百分比
- CPU和内存占用增加一个缓存,现在存在一个问题是仪表盘页面每次进入都会重新去获取CPU和内存占用,会有1-2s的等待仪表盘页面才显示CPU和内存占用,现在可以增加一个缓存,保持跟左侧菜单栏里面同步
- 外观设置里面的背景图片,默认的背景颜色,使用ui-ux-pro-max重新设计一个,不要AI常用的渐变色,同时需要主题明暗主题下的不同
- 整体窗口增加一个适应明暗主题变化的一个遮罩,大小是整个窗口的大小,因为目前存在一点问题是有些背景图片的一部分区域的颜色跟顶部栏的操作图标过于接近,导致视觉效果不佳,我想增加一个遮罩来减少一部分影响,遮罩要随明暗主题有反差,确保顶部栏图标的视觉效果,而且遮罩的透明度需要在外观设置里面可以单独调节
- 明暗主题切换增加一个跟随系统
- 运行日志持久化写入运行目录所在的logs目录里面
+45
View File
@@ -0,0 +1,45 @@
# 数据目录说明
FrpcPilot 以实际安装位置保存业务数据。安装目录中没有数据库或设置文件时,直接创建空数据库并使用默认设置。旧版系统应用数据目录中的文件不会被读取、复制或删除,需要使用的服务器与隧道请在新版中重新配置。
## Windows 目录示例
安装在 `D:\software\FrpcPilot` 时:
```text
D:\software\FrpcPilot\
├── frpcpilot.exe
├── data\
│ ├── frpcpilot.db # 服务器、隧道、frp 记录
│ ├── preferences.json # 语言、外观、关闭行为等设置
│ └── storage.lock # 防止多个进程同时使用数据目录
├── frp\versions\ # 各版本压缩包、解压文件和导入的程序
├── run\frpc.toml # 启动穿透时根据数据库重新生成
├── webview\ # Windows / Linux 浏览器数据与缓存
├── cache\ # 应用缓存
├── logs\ # 应用日志目录
└── tmp\ # 应用与更新器使用的临时目录
```
SQLite 的 journal / WAL / SHM 文件随数据库保存在 `data` 中。frpc 的工作目录是 `run`,相对路径的运行产物留在这里;实时日志仍输出到界面,不额外保存日志文件。导入的 frpc / frps 会复制到安装目录,原文件不修改。
## 各平台位置
| 平台 | 数据根目录 |
| --- | --- |
| Windows | `frpcpilot.exe` 所在目录 |
| macOS `.app` | `.app` 同级的 `FrpcPilot-data` |
| Linux AppImage | `APPIMAGE` 指向的实际文件所在目录 |
| Linux 普通二进制 / deb | 实际可执行文件所在目录 |
macOS 使用应用包同级目录,避免破坏签名或在更新器替换应用包时丢失数据。AppImage 的临时只读挂载目录不会用于存储业务数据。开发构建使用可执行文件旁的 `FrpcPilot-dev`,与正式版配置隔离。
安装目录必须允许当前用户写入。Windows `Program Files`、macOS `/Applications`、Linux deb 默认的 `/usr/bin` 等位置可能不可写,此时应用会显示错误并停止,不回退到系统默认的应用数据目录。建议使用当前用户可写的专用安装文件夹;Linux 推荐将 AppImage 放在自己的应用目录。
macOS WKWebView 不支持任意指定持久化目录,其系统管理的浏览器缓存仍可能写入用户 Library;应用设置保存在 `data/preferences.json`。操作系统、WebView 运行时安装、崩溃报告以及系统安装器自身生成的文件,也不能保证完全避开系统盘。
## 备份与安装位置变更
备份当前数据时,先退出软件,再备份整个安装目录;macOS 需要备份同级的 `FrpcPilot-data`。数据库和 `run/frpc.toml` 含认证信息,请按私密文件保存。
程序不自动修正安装位置变更前保存的 frp 绝对路径。移动安装目录后,如原路径失效,请重新下载或导入 frpc。不同操作系统的二进制不兼容,需要使用目标平台的 frpc。
+74 -1
View File
@@ -1,13 +1,86 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FrpcPilot</title>
<!-- 首屏样式内联,脚本和样式资源尚未加载时也能显示启动状态。 -->
<style>
#startup-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
background: radial-gradient(ellipse at 50% 40%, #e0e7ff, #f8fafc 70%);
color: #334155;
font: 500 15px/1.6 'Segoe UI', 'Microsoft YaHei', sans-serif;
}
#startup-loader[data-theme="dark"] {
background: radial-gradient(ellipse at 50% 40%, #1e1b4b, #0f172a 70%);
color: #e2e8f0;
}
#startup-loader svg { width: 76px; height: 76px; overflow: visible; }
#startup-loader .startup-ring {
transform-origin: 40px 40px;
animation: startup-orbit 3.6s linear infinite;
}
#startup-loader circle {
fill: #6366f1;
transform-box: fill-box;
transform-origin: center;
animation: startup-dot 1.6s ease-in-out infinite;
animation-delay: var(--delay);
}
@keyframes startup-orbit { to { transform: rotate(360deg); } }
@keyframes startup-dot {
0%, 100% { transform: scale(.55); opacity: .35; fill: #6366f1; }
50% { transform: scale(1.1); opacity: 1; fill: #06b6d4; }
}
@media (prefers-color-scheme: dark) {
#startup-loader:not([data-theme="light"]) {
background: radial-gradient(ellipse at 50% 40%, #1e1b4b, #0f172a 70%);
color: #e2e8f0;
}
}
@media (prefers-reduced-motion: reduce) {
#startup-loader .startup-ring, #startup-loader circle { animation: none; }
}
</style>
</head>
<body>
<div id="startup-loader" role="status" aria-live="polite" aria-busy="true" data-tauri-drag-region>
<svg viewBox="0 0 80 80" aria-hidden="true">
<g class="startup-ring">
<circle cx="40" cy="12" r="5" style="--delay: -1.4s" />
<circle cx="60" cy="20" r="5" style="--delay: -1.2s" />
<circle cx="68" cy="40" r="5" style="--delay: -1s" />
<circle cx="60" cy="60" r="5" style="--delay: -.8s" />
<circle cx="40" cy="68" r="5" style="--delay: -.6s" />
<circle cx="20" cy="60" r="5" style="--delay: -.4s" />
<circle cx="12" cy="40" r="5" style="--delay: -.2s" />
<circle cx="20" cy="20" r="5" style="--delay: 0s" />
</g>
</svg>
<span id="startup-label">FrpcPilot正在启动中</span>
</div>
<script>
try {
const savedTheme = localStorage.getItem('frpcpilot:theme');
if (savedTheme === 'dark' || savedTheme === 'light') {
document.getElementById('startup-loader').dataset.theme = savedTheme;
}
if (localStorage.getItem('frpcpilot:language') === 'en-US') {
document.getElementById('startup-label').textContent = 'FrpcPilot is starting';
document.documentElement.lang = 'en';
}
} catch { /* 存储不可用时仍显示默认启动画面。 */ }
</script>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frpcpilot",
"private": true,
"version": "1.2.1",
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -12,6 +12,7 @@
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2.7.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.4.0",
"@tauri-apps/plugin-updater": "^2.13.3",
+10
View File
@@ -14,6 +14,9 @@ importers:
'@tauri-apps/api':
specifier: ^2
version: 2.12.3
'@tauri-apps/plugin-autostart':
specifier: ^2.7.0
version: 2.7.0
'@tauri-apps/plugin-opener':
specifier: ^2
version: 2.7.1
@@ -535,6 +538,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-autostart@2.7.0':
resolution: {integrity: sha512-4uRq+rqKSAh6MWAxgk6FnUPigc8VMG8eDVYTLk3A7OfKs9sLVwfeM8kbUvzjpabRQvyl4zd9tfzXBTrrtuprsA==}
'@tauri-apps/plugin-opener@2.7.1':
resolution: {integrity: sha512-XkXdt30rontti9TII/gBBxFRXWfj8DiprFW7RgBFRBj1OhdfjBNVcFqSEWkkZ4hXafHnuEnB7F1sFsmu6uyywQ==}
@@ -1578,6 +1584,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.12.2
'@tauri-apps/cli-win32-x64-msvc': 2.12.2
'@tauri-apps/plugin-autostart@2.7.0':
dependencies:
'@tauri-apps/api': 2.12.3
'@tauri-apps/plugin-opener@2.7.1':
dependencies:
'@tauri-apps/api': 2.12.3
+119 -5
View File
@@ -231,6 +231,20 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-launch"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17918dba7ecf78b9a14507ec9f984e7977d13fad87dc86d61038980a45d128cf"
dependencies = [
"dirs 6.0.0",
"os_info",
"smappservice-rs",
"thiserror 2.0.21",
"windows-registry",
"windows-result",
]
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -493,6 +507,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.2"
@@ -889,6 +909,15 @@ dependencies = [
"ctutils",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs"
version = "7.0.0"
@@ -1280,6 +1309,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-helper",
"tauri-plugin-autostart",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-prevent-default",
@@ -1813,6 +1843,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -2571,6 +2607,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "ntapi"
version = "0.4.3"
@@ -2802,6 +2850,30 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-security"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
dependencies = [
"bitflags 2.13.2",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-service-management"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8"
dependencies = [
"block2",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
"objc2-security",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
@@ -2923,6 +2995,21 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_info"
version = "3.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
dependencies = [
"android_system_properties",
"log",
"nix",
"objc2",
"objc2-foundation",
"objc2-ui-kit",
"windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
@@ -3988,6 +4075,18 @@ dependencies = [
"serde",
]
[[package]]
name = "smappservice-rs"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52703b97a53101cf5d4580e0737aaa634ce1fdcfc123c16b2a9658e358013488"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-service-management",
"thiserror 2.0.21",
]
[[package]]
name = "socket2"
version = "0.6.5"
@@ -4494,7 +4593,7 @@ dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs",
"dirs 7.0.0",
"dunce",
"embed_plist",
"getrandom 0.3.4",
@@ -4502,6 +4601,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"http-range",
"jni 0.21.1",
"libc",
"log",
@@ -4544,7 +4644,7 @@ checksum = "2eea0b6302f82c574b21af6fc6ae6e86183b9280853c22621d0abf0936911ffc"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"dirs 7.0.0",
"glob",
"heck 0.5.0",
"json-patch",
@@ -4629,6 +4729,20 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-autostart"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bce56ceb0a99b8ab5eebc977ba9ffc62391b862648cb9ab76fc8bc7231b8f7e2"
dependencies = [
"auto-launch",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.21",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.8.2"
@@ -4740,7 +4854,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489"
dependencies = [
"base64 0.22.1",
"dirs",
"dirs 7.0.0",
"flate2",
"futures-util",
"http",
@@ -5277,7 +5391,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72"
dependencies = [
"crossbeam-channel",
"dirs",
"dirs 7.0.0",
"libappindicator",
"muda",
"objc2",
@@ -6137,7 +6251,7 @@ dependencies = [
"block2",
"cookie",
"crossbeam-channel",
"dirs",
"dirs 7.0.0",
"dom_query",
"dpi",
"dunce",
+2 -1
View File
@@ -22,7 +22,7 @@ tauri-build = { version = "2", features = [] }
tauri-helper = "0.2.1"
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri = { version = "2.12.3", features = ["protocol-asset", "tray-icon"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -39,6 +39,7 @@ sysinfo = "0.39"
tauri-plugin-dialog = "2.7.2"
tauri-plugin-updater = "2.10.1"
tauri-plugin-process = "2.3.1"
tauri-plugin-autostart = "2"
tauri-plugin-prevent-default = "6.0.1"
reqwest = { version = "0.12", features = ["json", "stream"] }
futures-util = "0.3"
+1
View File
@@ -8,6 +8,7 @@
"opener:default",
"updater:default",
"process:default",
"autostart:default",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-unminimize",
+41
View File
@@ -0,0 +1,41 @@
use tauri::{AppHandle, State};
use tauri_helper::auto_collect_command;
use tauri_plugin_dialog::DialogExt;
use crate::models::ApiResponse;
use crate::services::background::{find_custom_background, import_custom_background};
use crate::services::storage::StoragePaths;
/// 打开原生文件选择器并将图片复制到应用数据目录。
#[tauri::command]
#[auto_collect_command]
pub async fn choose_custom_background(
app: AppHandle,
paths: State<'_, StoragePaths>,
) -> Result<ApiResponse<Option<String>>, String> {
let picked = app
.dialog()
.file()
.add_filter("图片", &["png", "jpg", "jpeg", "webp"])
.set_title("选择背景图片")
.blocking_pick_file();
let Some(file) = picked else {
return Ok(ApiResponse::success(None));
};
let source = file.into_path().map_err(|e| e.to_string())?;
let saved = import_custom_background(&paths, &source)?;
Ok(ApiResponse::success(Some(
saved.to_string_lossy().into_owned(),
)))
}
/// 获取已保存的自定义背景路径,图片内容通过受限的 asset 协议读取。
#[tauri::command]
#[auto_collect_command]
pub async fn get_custom_background(
paths: State<'_, StoragePaths>,
) -> Result<ApiResponse<Option<String>>, String> {
Ok(ApiResponse::success(
find_custom_background(&paths).map(|path| path.to_string_lossy().into_owned()),
))
}
+70 -19
View File
@@ -12,6 +12,7 @@ use tokio::io::AsyncWriteExt;
use crate::models::{
ApiResponse, FrpDownload, FrpDownloadPayload, FrpDownloadProgress, FrpRelease,
};
use crate::services::storage::StoragePaths;
/// GitHub 镜像加速前缀(国内访问 GitHub 不稳定,拉取版本与下载统一走镜像)
const GITHUB_MIRROR: &str = "https://gh-proxy.com/";
@@ -133,14 +134,31 @@ async fn fetch_releases(client: &reqwest::Client) -> Result<Vec<Value>, String>
Err(format!("获取 Frp 版本列表失败:{last_err}"))
}
/// 应用数据目录下的 frp 版本存储根目录
/// 安装目录下的 frp 版本存储根目录
fn versions_dir(app: &AppHandle) -> Result<PathBuf, String> {
Ok(app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("frp")
.join("versions"))
Ok(app.state::<StoragePaths>().versions())
}
fn version_directory(version: &str) -> String {
if version != "."
&& version != ".."
&& !version.is_empty()
&& version
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
{
version.to_string()
} else {
// 将任意 Unicode 文件名编码为十六进制,保证导入目录名称唯一。
format!(
"import-{}",
version
.as_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
)
}
}
/// 发送下载进度事件
@@ -153,7 +171,9 @@ fn emit_progress(
message: Option<&str>,
) {
let percent = if total > 0 {
((downloaded as f64 / total as f64) * 100.0).clamp(0.0, 100.0).round() as u8
((downloaded as f64 / total as f64) * 100.0)
.clamp(0.0, 100.0)
.round() as u8
} else {
0
};
@@ -174,8 +194,8 @@ fn emit_progress(
/// 获取 Frp 版本列表(返回所有架构的发行包,并合并本机已下载 / 已导入记录)
///
/// 前端根据「展示所有架构的 Frpc / Frps」开关在本地过滤,避免频繁请求
/// GitHub API 触发限流;默认仅展示当前运行平台由前端完成。
/// 前端根据平台下拉列表在本地过滤,避免频繁请求 GitHub API 触发限流;
/// 默认仅展示当前运行平台由前端完成。
#[tauri::command]
#[auto_collect_command]
pub async fn get_frp_releases(
@@ -219,7 +239,10 @@ pub async fn get_frp_releases(
};
for asset in assets {
let name = asset.get("name").and_then(Value::as_str).unwrap_or_default();
let name = asset
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let Some((_v, os, arch)) = parse_asset(name) else {
continue;
};
@@ -510,7 +533,9 @@ fn find_binary(dir: &Path, stem: &str) -> Option<PathBuf> {
stack.push(path);
continue;
}
let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_ascii_lowercase())
let Some(name) = path
.file_name()
.map(|s| s.to_string_lossy().to_ascii_lowercase())
else {
continue;
};
@@ -558,10 +583,12 @@ pub async fn delete_frp(
};
if let Ok(base) = versions_dir(&app) {
let ver_dir = base.join(&row.version);
let ver_dir = base.join(version_directory(&row.version));
let _ = tokio::fs::remove_dir_all(ver_dir.join(&row.platform)).await;
if let Some(archive) = &row.archive_path {
let _ = tokio::fs::remove_file(archive).await;
if Path::new(archive).starts_with(&base) {
let _ = tokio::fs::remove_file(archive).await;
}
}
// 版本目录已空则一并删除
if let Ok(mut entries) = tokio::fs::read_dir(&ver_dir).await {
@@ -570,7 +597,6 @@ pub async fn delete_frp(
}
}
}
match sqlx::query("DELETE FROM frp_downloads WHERE id = ?")
.bind(id)
.execute(&*state)
@@ -581,7 +607,7 @@ pub async fn delete_frp(
}
}
/// 导入本地已下载的 frpc / frps(记录可执行文件路径,不复制文件)
/// 导入本地 frpc / frps,将副本保存到安装目录,原始文件保持不变。
#[tauri::command]
#[auto_collect_command]
pub async fn import_frp(
@@ -612,11 +638,33 @@ pub async fn import_frp(
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let size = std::fs::metadata(&path).map(|m| m.len() as i64).unwrap_or(0);
let size = std::fs::metadata(&path)
.map(|m| m.len() as i64)
.unwrap_or(0);
let lower = file_name.to_ascii_lowercase();
let is_frps = lower.contains("frps");
let version = parse_version(&file_name).unwrap_or_else(|| file_name.clone());
let full = path.to_string_lossy().to_string();
let binary_name = if is_frps { "frps" } else { "frpc" };
// 对用户选择的文件名进行编码后再构造存储目录。
let import_dir = versions_dir(&app)?
.join(version_directory(&version))
.join(&platform)
.join("import");
std::fs::create_dir_all(&import_dir).map_err(|e| e.to_string())?;
let destination = import_dir.join(if cfg!(windows) {
format!("{binary_name}.exe")
} else {
binary_name.to_string()
});
let same_file = match (std::fs::canonicalize(&destination), std::fs::canonicalize(&path)) {
(Ok(destination), Ok(source)) => destination == source,
_ => false,
};
if !same_file {
std::fs::copy(&path, &destination).map_err(|e| format!("导入 Frp 失败:{e}"))?;
}
make_executable(&destination);
let full = destination.to_string_lossy().to_string();
let (frpc_path, frps_path) = if is_frps {
(None, Some(full))
} else {
@@ -683,7 +731,10 @@ mod tests {
#[test]
fn parse_version_extracts_semver() {
assert_eq!(parse_version("frpc_0.71.0_windows_amd64.exe"), Some("v0.71.0".into()));
assert_eq!(
parse_version("frpc_0.71.0_windows_amd64.exe"),
Some("v0.71.0".into())
);
assert_eq!(parse_version("frps-v0.60.0"), Some("v0.60.0".into()));
assert_eq!(parse_version("frpc.exe"), None);
assert_eq!(parse_version("frpc_0.71.exe"), None);
+2
View File
@@ -1,5 +1,7 @@
pub mod background;
pub mod frp;
pub mod runner;
pub mod server;
pub mod storage;
pub mod system;
pub mod tunnel;
+21 -9
View File
@@ -5,6 +5,7 @@
use std::path::PathBuf;
use chrono::Local;
use sqlx::SqlitePool;
use tauri::{AppHandle, Manager, State};
use tauri_helper::auto_collect_command;
@@ -12,6 +13,7 @@ use tauri_helper::auto_collect_command;
use crate::models::{ApiResponse, FrpDownload, FrpcStatus, Server, Tunnel};
use crate::services::config::generate_frpc_config;
use crate::services::runner::FrpcRunner;
use crate::services::storage::StoragePaths;
// 统一查询字段,使用 concat! 保证结果为 'static str(sqlx 0.9 要求)
macro_rules! server_fields {
@@ -32,14 +34,9 @@ macro_rules! download_fields {
};
}
/// 本次运行生成的 frpc.toml 路径(应用数据目录/frp/run/frpc.toml)
/// 本次运行生成的 frpc.toml 路径(安装目录/run/frpc.toml)
fn config_file_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("frp")
.join("run");
let dir = app.state::<StoragePaths>().run();
std::fs::create_dir_all(&dir).map_err(|e| format!("创建配置目录失败:{e}"))?;
Ok(dir.join("frpc.toml"))
}
@@ -77,7 +74,12 @@ pub async fn start_frpc(
.await
{
Ok(Some(row)) => row,
Ok(None) => return Ok(ApiResponse::error(404, "所选 Frpc 版本不存在,请刷新后重试")),
Ok(None) => {
return Ok(ApiResponse::error(
404,
"所选 Frpc 版本不存在,请刷新后重试",
));
}
Err(e) => return Ok(ApiResponse::error(500, &e.to_string())),
};
@@ -151,7 +153,17 @@ pub async fn start_frpc(
// 5. 启动 frpc 进程
let config_path_str = config_path.to_string_lossy().to_string();
match runner.spawn(&app, &frpc_path, &config_path_str, &download.version) {
let paths = app.state::<StoragePaths>();
let log_path = paths
.logs()
.join(format!("frpc-{}.log", Local::now().format("%Y-%m-%d")));
match runner.spawn(
&app,
&frpc_path,
&config_path_str,
&download.version,
&log_path,
) {
Ok(status) => Ok(ApiResponse::success(status)),
Err(e) => Ok(ApiResponse::error(500, &e)),
}
+45
View File
@@ -0,0 +1,45 @@
use std::collections::BTreeMap;
use std::sync::Mutex;
use tauri::State;
use tauri_helper::auto_collect_command;
use crate::services::storage::{StoragePaths, write_json};
#[derive(Default)]
pub struct PreferencesLock(pub Mutex<()>);
#[tauri::command]
#[auto_collect_command]
pub fn get_preferences(
paths: State<'_, StoragePaths>,
lock: State<'_, PreferencesLock>,
) -> Result<Option<BTreeMap<String, String>>, String> {
let _guard = lock.0.lock().map_err(|e| e.to_string())?;
let path = paths.data().join("preferences.json");
match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| e.to_string()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[auto_collect_command]
pub fn set_preferences(
paths: State<'_, StoragePaths>,
lock: State<'_, PreferencesLock>,
preferences: BTreeMap<String, String>,
) -> Result<(), String> {
if preferences
.iter()
.any(|(key, value)| !key.starts_with("frpcpilot:") || key.len() + value.len() > 65536)
|| preferences.len() > 100
{
return Err("设置数据无效或过大".into());
}
let _guard = lock.0.lock().map_err(|e| e.to_string())?;
write_json(&paths.data().join("preferences.json"), &preferences)
}
+6
View File
@@ -7,6 +7,12 @@ use tauri_helper::auto_collect_command;
use crate::models::{ApiResponse, SystemDetails, SystemInfo};
use crate::services::system::SystemStats;
#[tauri::command]
#[auto_collect_command]
pub fn is_autostart_supported() -> bool {
!tauri::is_dev() && !cfg!(debug_assertions)
}
/// 获取系统资源占用(CPU / 内存百分比)
#[tauri::command]
#[auto_collect_command]
+76 -11
View File
@@ -3,7 +3,9 @@ mod models;
mod services;
use tauri::{Manager, RunEvent};
use tauri_plugin_autostart::ManagerExt as _;
use crate::commands::background::{choose_custom_background, get_custom_background};
use crate::commands::frp::{
delete_frp, download_frp, get_frp_downloads, get_frp_releases, import_frp,
};
@@ -12,30 +14,86 @@ use crate::commands::server::{
create_server, delete_server, get_server_list, set_server_enabled, set_server_pinned,
update_server,
};
use crate::commands::system::{get_system_details, get_system_info};
use crate::commands::storage::{PreferencesLock, get_preferences, set_preferences};
use crate::commands::system::{get_system_details, get_system_info, is_autostart_supported};
use crate::commands::tunnel::{
create_tunnel, delete_tunnel, get_tunnel_list, set_tunnel_enabled, set_tunnel_pinned,
update_tunnel,
};
use crate::services::db::init_pool;
use crate::services::runner::FrpcRunner;
use crate::services::storage::{self, StoragePaths};
use crate::services::system::SystemStats;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let app = tauri::Builder::default()
let paths = StoragePaths::discover().expect("无法确定软件安装目录");
// 此入口在启动 Tauri、Tokio 或任何工作线程之前调用。
// 将更新、解压及子进程继承的临时文件目录统一指向安装目录。
if std::fs::create_dir_all(paths.root.join("tmp")).is_ok() {
unsafe {
std::env::set_var("TEMP", paths.root.join("tmp"));
std::env::set_var("TMP", paths.root.join("tmp"));
std::env::set_var("TMPDIR", paths.root.join("tmp"));
}
}
let mut context = tauri::generate_context!();
context.config_mut().app.app_directories_override = Some(paths.overrides());
// 在打开 WebView 前初始化数据目录,同时保留各平台的窗口配置。
for window in &mut context.config_mut().app.windows {
window.create = false;
}
let mut builder = tauri::Builder::default()
.manage(paths)
.manage(PreferencesLock::default())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) // 对话框插件
.plugin(tauri_plugin_updater::Builder::new().build()) // 更新插件
.plugin(tauri_plugin_process::init()) // 进程插件(更新后重启)
.plugin(tauri_plugin_prevent_default::Builder::new().build())
.plugin(tauri_plugin_prevent_default::Builder::new().build());
// 开发版依赖 Vite,调试构建在 Windows 上还会打开控制台窗口。
if is_autostart_supported() {
builder = builder.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
));
}
let app = builder
.setup(|app| {
let pool = tauri::async_runtime::block_on(async {
init_pool(app.handle())
.await
.expect("sqlite数据库连接池初始化失败!")
});
app.manage(pool);
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
let paths = app.state::<StoragePaths>();
let initialize = || -> Result<_, Box<dyn std::error::Error>> {
let (pool, lock) = tauri::async_runtime::block_on(storage::initialize(&paths))
.map_err(std::io::Error::other)?;
app.manage(pool);
app.manage(lock);
Ok(())
};
if let Err(error) = initialize() {
app.dialog().message(format!(
"无法初始化安装目录中的数据:{}\n\n{error}\n\n请确认安装目录可写、磁盘空间充足,并退出使用同一数据目录的其他实例。",
paths.root.display()
)).title("FrpcPilot 数据初始化失败").kind(MessageDialogKind::Error).blocking_show();
return Err(error);
}
// 插件会动态解析当前可执行文件的位置;重新安装或移动应用后刷新
// 已启用的自启动项,原本禁用的自启动项仍保持禁用。
if is_autostart_supported() {
let autostart = app.autolaunch();
let result = autostart.is_enabled().and_then(|enabled| {
if enabled {
autostart.enable()
} else {
Ok(())
}
});
if let Err(error) = result {
eprintln!("Failed to refresh autostart executable path: {error}");
}
}
// 系统资源占用(CPU / 内存)后台采集
let stats = SystemStats::default();
@@ -45,12 +103,19 @@ pub fn run() {
// Frpc 进程管理器(一键启动 / 停止与日志实时回显)
app.manage(FrpcRunner::default());
for config in &app.config().app.windows {
let window = tauri::WebviewWindowBuilder::from_config(app.handle(), config)?;
#[cfg(not(target_os = "macos"))]
let window = window.data_directory(paths.webview());
window.build()?;
}
Ok(())
})
.plugin(tauri_plugin_store::Builder::default().build())
// .invoke_handler(tauri::generate_handler![greet])
.invoke_handler(tauri_helper::tauri_collect_commands!())
.build(tauri::generate_context!())
.build(context)
.expect("error while building tauri application");
app.run(|app_handle, event| {
+137
View File
@@ -0,0 +1,137 @@
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::services::storage::StoragePaths;
const MAX_BACKGROUND_SIZE: u64 = 15 * 1024 * 1024;
pub fn import_custom_background(paths: &StoragePaths, source: &Path) -> Result<PathBuf, String> {
let size = fs::metadata(source)
.map_err(|e| format!("读取图片信息失败:{e}"))?
.len();
if size == 0 || size > MAX_BACKGROUND_SIZE {
return Err("图片大小必须在 1 字节到 15 MB 之间".into());
}
let bytes = fs::read(source).map_err(|e| format!("读取背景图片失败:{e}"))?;
if bytes.is_empty() || bytes.len() as u64 > MAX_BACKGROUND_SIZE {
return Err("图片大小必须在 1 字节到 15 MB 之间".into());
}
let extension = detect_extension(&bytes).ok_or("仅支持 PNG、JPG 和 WebP 图片")?;
let directory = paths.data().join("backgrounds");
fs::create_dir_all(&directory).map_err(|e| format!("创建背景图片目录失败:{e}"))?;
let destination = directory.join(format!("custom.{extension}"));
let temporary = directory.join(format!("custom.{extension}.tmp"));
let mut file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&temporary)
.map_err(|e| format!("创建背景图片文件失败:{e}"))?;
if let Err(error) = file.write_all(&bytes).and_then(|()| file.sync_all()) {
drop(file);
let _ = fs::remove_file(&temporary);
return Err(format!("保存背景图片失败:{error}"));
}
drop(file);
fs::rename(&temporary, &destination).map_err(|e| {
let _ = fs::remove_file(&temporary);
format!("更新背景图片失败:{e}")
})?;
for stale_extension in ["png", "jpg", "webp"] {
if stale_extension != extension {
let stale = directory.join(format!("custom.{stale_extension}"));
let _ = fs::remove_file(stale);
}
}
Ok(destination)
}
pub fn find_custom_background(paths: &StoragePaths) -> Option<PathBuf> {
["png", "jpg", "webp"]
.into_iter()
.map(|extension| {
paths
.data()
.join("backgrounds")
.join(format!("custom.{extension}"))
})
.filter(|path| path.is_file())
.max_by_key(|path| {
fs::metadata(path)
.and_then(|metadata| metadata.modified())
.unwrap_or(std::time::UNIX_EPOCH)
})
}
fn detect_extension(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Some("png")
} else if bytes.starts_with(b"\xff\xd8\xff") {
Some("jpg")
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
Some("webp")
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn imports_supported_image_and_replaces_previous_format() {
let root = std::env::temp_dir().join(format!(
"frpcpilot-background-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&root).unwrap();
let paths = StoragePaths { root: root.clone() };
let source = root.join("source-image.bin");
let png = b"\x89PNG\r\n\x1a\nimage-data";
fs::write(&source, png).unwrap();
let saved = import_custom_background(&paths, &source).unwrap();
assert_eq!(saved, paths.data().join("backgrounds/custom.png"));
assert_eq!(fs::read(&saved).unwrap(), png);
assert_eq!(find_custom_background(&paths), Some(saved.clone()));
let jpeg = b"\xff\xd8\xffimage-data";
fs::write(&source, jpeg).unwrap();
let replaced = import_custom_background(&paths, &source).unwrap();
assert_eq!(replaced, paths.data().join("backgrounds/custom.jpg"));
assert!(!saved.exists());
assert_eq!(fs::read(&replaced).unwrap(), jpeg);
assert_eq!(find_custom_background(&paths), Some(replaced));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn rejects_non_image_files() {
let root = std::env::temp_dir().join(format!(
"frpcpilot-background-invalid-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&root).unwrap();
let paths = StoragePaths { root: root.clone() };
let source = root.join("not-an-image");
fs::write(&source, b"not an image").unwrap();
assert!(import_custom_background(&paths, &source).is_err());
fs::remove_dir_all(root).unwrap();
}
}
+3 -7
View File
@@ -1,14 +1,10 @@
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use tauri::{AppHandle, Manager};
use std::path::Path;
pub async fn init_pool(app: &AppHandle) -> Result<SqlitePool, sqlx::Error> {
// 数据库文件路径
let app_dir = app.path().app_data_dir().expect("无法获取APP数据目录");
std::fs::create_dir_all(&app_dir).unwrap();
let db_path = app_dir.join("frpcpilot.db");
pub async fn init_pool(db_path: &Path) -> Result<SqlitePool, sqlx::Error> {
// 初始化数据库连接池
let options = SqliteConnectOptions::new()
.filename(&db_path)
.filename(db_path)
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
+2
View File
@@ -1,4 +1,6 @@
pub mod background;
pub mod config;
pub mod db;
pub mod runner;
pub mod storage;
pub mod system;
+161 -50
View File
@@ -6,7 +6,8 @@
//! - stdout / stderr 通过管道读取,按行解析后以 `frpc-log` 事件实时推送给前端;
//! - 后台守护线程轮询子进程状态,退出时推送 `frpc-exit` 事件。
use std::io::{BufRead, BufReader, Read};
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Write};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
@@ -14,6 +15,7 @@ use std::sync::{Arc, Mutex, MutexGuard};
use std::thread;
use std::time::Duration;
use chrono::Local;
use tauri::{AppHandle, Emitter};
use crate::models::{FrpcExitEvent, FrpcLogEvent, FrpcStatus};
@@ -23,6 +25,8 @@ pub const LOG_EVENT: &str = "frpc-log";
/// 进程退出事件名
pub const EXIT_EVENT: &str = "frpc-exit";
type SharedLogFile = Arc<Mutex<File>>;
/// 守护线程轮询间隔
const POLL_INTERVAL: Duration = Duration::from_millis(250);
@@ -34,6 +38,7 @@ struct Running {
version: String,
frpc_path: String,
config_path: String,
log_file: SharedLogFile,
}
/// Frpc 进程管理器(应用级单例)
@@ -78,6 +83,7 @@ impl FrpcRunner {
frpc_path: &str,
config_path: &str,
version: &str,
log_path: &Path,
) -> Result<FrpcStatus, String> {
let mut guard = self.lock();
if guard.is_some() {
@@ -89,6 +95,14 @@ impl FrpcRunner {
return Err(format!("未找到 frpc 可执行文件:{frpc_path}"));
}
let log_file = Arc::new(Mutex::new(
OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
.map_err(|e| format!("无法打开运行日志 {}:{e}", log_path.display()))?,
));
let mut command = Command::new(exe);
command
.arg("-c")
@@ -97,8 +111,8 @@ impl FrpcRunner {
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// 工作目录设为 frpc 所在目录,保证其相对路径配置可正常解析
if let Some(dir) = exe.parent() {
// 相对路径的运行产物统一保存在生成配置所在的目录。
if let Some(dir) = Path::new(config_path).parent() {
command.current_dir(dir);
}
@@ -126,21 +140,22 @@ impl FrpcRunner {
version: version.to_string(),
frpc_path: frpc_path.to_string(),
config_path: config_path.to_string(),
log_file: log_file.clone(),
});
drop(guard);
// 采集 stdout / stderr
if let Some(stdout) = stdout {
spawn_reader(app.clone(), stdout, "stdout");
spawn_reader(app.clone(), stdout, "stdout", log_file.clone());
}
if let Some(stderr) = stderr {
spawn_reader(app.clone(), stderr, "stderr");
spawn_reader(app.clone(), stderr, "stderr", log_file.clone());
}
// 守护线程:轮询子进程是否退出
spawn_waiter(app.clone(), self.slot.clone(), token);
emit_log(
emit_persisted_log(
app,
FrpcLogEvent {
time: None,
@@ -148,6 +163,7 @@ impl FrpcRunner {
source: "system".to_string(),
message: format!("Frpc {version} 已启动(PID {pid})"),
},
Some(&log_file),
);
Ok(self.status())
@@ -160,7 +176,9 @@ impl FrpcRunner {
return Ok(FrpcStatus::default());
}
emit_log(
let log_file = guard.as_ref().map(|running| running.log_file.clone());
emit_persisted_log(
app,
FrpcLogEvent {
time: None,
@@ -168,6 +186,7 @@ impl FrpcRunner {
source: "system".to_string(),
message: "正在停止 Frpc…".to_string(),
},
log_file.as_ref(),
);
let version = {
@@ -181,7 +200,7 @@ impl FrpcRunner {
*guard = None;
drop(guard);
emit_log(
emit_persisted_log(
app,
FrpcLogEvent {
time: None,
@@ -189,6 +208,7 @@ impl FrpcRunner {
source: "system".to_string(),
message: "Frpc 已停止".to_string(),
},
log_file.as_ref(),
);
emit_exit(
app,
@@ -208,13 +228,27 @@ impl FrpcRunner {
if let Some(running) = guard.as_mut() {
let _ = running.child.kill();
let _ = running.child.wait();
persist_log(
&running.log_file,
&FrpcLogEvent {
time: None,
level: "WARN".to_string(),
source: "system".to_string(),
message: "FrpcPilot 正在退出,Frpc 已停止".to_string(),
},
);
}
*guard = None;
}
}
/// 持续读取子进程输出并推送日志事件
fn spawn_reader<R: Read + Send + 'static>(app: AppHandle, pipe: R, source: &'static str) {
fn spawn_reader<R: Read + Send + 'static>(
app: AppHandle,
pipe: R,
source: &'static str,
log_file: SharedLogFile,
) {
thread::spawn(move || {
let mut reader = BufReader::new(pipe);
let mut buf: Vec<u8> = Vec::new();
@@ -228,7 +262,7 @@ fn spawn_reader<R: Read + Send + 'static>(app: AppHandle, pipe: R, source: &'sta
if line.trim().is_empty() {
continue;
}
emit_log(&app, parse_line(source, &line));
emit_persisted_log(&app, parse_line(source, &line), Some(&log_file));
}
Err(_) => break,
}
@@ -238,48 +272,58 @@ fn spawn_reader<R: Read + Send + 'static>(app: AppHandle, pipe: R, source: &'sta
/// 守护线程:轮询子进程状态,退出时清理并推送事件
fn spawn_waiter(app: AppHandle, slot: Arc<Mutex<Option<Running>>>, token: u64) {
thread::spawn(move || loop {
thread::sleep(POLL_INTERVAL);
thread::spawn(move || {
loop {
thread::sleep(POLL_INTERVAL);
let mut guard = slot.lock().unwrap_or_else(|e| e.into_inner());
let probe = match guard.as_mut() {
Some(running) if running.token == token => {
Some((running.child.try_wait(), running.version.clone()))
}
// 已被停止 / 已被新进程替换
_ => None,
};
let mut guard = slot.lock().unwrap_or_else(|e| e.into_inner());
let probe = match guard.as_mut() {
Some(running) if running.token == token => Some((
running.child.try_wait(),
running.version.clone(),
running.log_file.clone(),
)),
// 已被停止 / 已被新进程替换
_ => None,
};
match probe {
None => return,
Some((Ok(Some(status)), version)) => {
*guard = None;
drop(guard);
let code = status.code();
emit_log(
&app,
FrpcLogEvent {
time: None,
level: if code.unwrap_or(1) == 0 { "WARN" } else { "ERROR" }.to_string(),
source: "system".to_string(),
message: match code {
Some(code) => format!("Frpc 进程已退出(退出码 {code})"),
None => "Frpc 进程已退出".to_string(),
match probe {
None => return,
Some((Ok(Some(status)), version, log_file)) => {
*guard = None;
drop(guard);
let code = status.code();
emit_persisted_log(
&app,
FrpcLogEvent {
time: None,
level: if code.unwrap_or(1) == 0 {
"WARN"
} else {
"ERROR"
}
.to_string(),
source: "system".to_string(),
message: match code {
Some(code) => format!("Frpc 进程已退出(退出码 {code})"),
None => "Frpc 进程已退出".to_string(),
},
},
},
);
emit_exit(
&app,
FrpcExitEvent {
reason: "exited".to_string(),
code,
version: Some(version),
},
);
return;
Some(&log_file),
);
emit_exit(
&app,
FrpcExitEvent {
reason: "exited".to_string(),
code,
version: Some(version),
},
);
return;
}
Some((Ok(None), _, _)) => {} // 仍在运行
Some((Err(_), _, _)) => return,
}
Some((Ok(None), _)) => {} // 仍在运行
Some((Err(_), _)) => return,
}
});
}
@@ -393,6 +437,27 @@ fn emit_log(app: &AppHandle, event: FrpcLogEvent) {
let _ = app.emit(LOG_EVENT, event);
}
fn emit_persisted_log(app: &AppHandle, event: FrpcLogEvent, log_file: Option<&SharedLogFile>) {
if let Some(log_file) = log_file {
persist_log(log_file, &event);
}
emit_log(app, event);
}
fn persist_log(log_file: &SharedLogFile, event: &FrpcLogEvent) {
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
let mut file = log_file.lock().unwrap_or_else(|error| error.into_inner());
if let Err(error) = writeln!(
file,
"{timestamp} [{}] [{}] {}",
event.level, event.source, event.message
)
.and_then(|()| file.flush())
{
eprintln!("Failed to persist Frpc runtime log: {error}");
}
}
/// 发送退出事件
fn emit_exit(app: &AppHandle, event: FrpcExitEvent) {
let _ = app.emit(EXIT_EVENT, event);
@@ -404,8 +469,14 @@ mod tests {
#[test]
fn detects_log_level() {
assert_eq!(detect_level("2026/06/18 22:02:03 [I] [root.go:200] ok"), "INFO");
assert_eq!(detect_level("2026/06/18 22:02:03 [E] [root.go:200] bad"), "ERROR");
assert_eq!(
detect_level("2026/06/18 22:02:03 [I] [root.go:200] ok"),
"INFO"
);
assert_eq!(
detect_level("2026/06/18 22:02:03 [E] [root.go:200] bad"),
"ERROR"
);
assert_eq!(detect_level("[W] warn only"), "WARN");
assert_eq!(detect_level("plain message"), "INFO");
}
@@ -419,6 +490,46 @@ mod tests {
assert_eq!(extract_time("no time here"), None);
}
#[test]
fn persists_runtime_log_lines() {
let path = std::env::temp_dir().join(format!(
"frpcpilot-runtime-log-{}-{}.log",
std::process::id(),
Local::now().timestamp_millis()
));
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.unwrap();
let log_file = Arc::new(Mutex::new(file));
persist_log(
&log_file,
&FrpcLogEvent {
time: None,
level: "INFO".to_string(),
source: "system".to_string(),
message: "started".to_string(),
},
);
persist_log(
&log_file,
&FrpcLogEvent {
time: None,
level: "ERROR".to_string(),
source: "stderr".to_string(),
message: "connection failed".to_string(),
},
);
drop(log_file);
let contents = std::fs::read_to_string(&path).unwrap();
std::fs::remove_file(path).unwrap();
assert!(contents.contains("[INFO] [system] started"));
assert!(contents.contains("[ERROR] [stderr] connection failed"));
}
#[test]
fn strips_ansi_escape_codes() {
let input = "\u{1b}[1;34m2026-10-13 17:19:48.171 [I] hello\u{1b}[0m";
+156
View File
@@ -0,0 +1,156 @@
//! 按安装位置分类存储应用数据。
//! 此模块在创建任何 WebView 或打开应用数据库连接池之前执行。
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::Serialize;
use sqlx::SqlitePool;
use tauri::utils::config::{AppDirectoriesOverride, AppDirectoryOverrides};
use super::db::init_pool;
#[derive(Clone, Debug)]
pub struct StoragePaths {
pub root: PathBuf,
}
impl StoragePaths {
pub fn discover() -> Result<Self, String> {
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
// APPIMAGE 指向实际的安装文件,避免使用只读的 /tmp/.mount_* 挂载目录。
let appimage = if cfg!(target_os = "linux") && !cfg!(debug_assertions) {
std::env::var_os("APPIMAGE").map(PathBuf::from)
} else {
None
};
let mut root = installation_root(&exe, appimage.as_deref(), cfg!(target_os = "macos"))?;
// 开发和测试使用独立的数据目录,避免影响正式版配置。
if cfg!(debug_assertions) {
root = root.join("FrpcPilot-dev");
}
Ok(Self { root })
}
pub fn data(&self) -> PathBuf {
self.root.join("data")
}
pub fn db(&self) -> PathBuf {
self.data().join("frpcpilot.db")
}
pub fn versions(&self) -> PathBuf {
self.root.join("frp").join("versions")
}
pub fn run(&self) -> PathBuf {
self.root.join("run")
}
pub fn logs(&self) -> PathBuf {
self.root.join("logs")
}
pub fn webview(&self) -> PathBuf {
self.root.join("webview")
}
pub fn overrides(&self) -> AppDirectoriesOverride {
AppDirectoriesOverride::Directories(AppDirectoryOverrides {
config: Some(self.data()),
data: Some(self.data()),
local_data: Some(self.webview()),
cache: Some(self.root.join("cache")),
log: Some(self.root.join("logs")),
})
}
}
fn installation_root(exe: &Path, appimage: Option<&Path>, macos: bool) -> Result<PathBuf, String> {
if macos {
if let Some(bundle) = exe
.ancestors()
.find(|p| p.extension().is_some_and(|e| e == "app"))
{
// 数据保存在应用包同级目录,避免 macOS 更新器替换签名应用包时丢失数据。
return Ok(bundle.with_file_name("FrpcPilot-data"));
}
}
let location = appimage.unwrap_or(exe);
if !location.is_absolute() {
return Err(format!("安装位置必须为绝对路径:{}", location.display()));
}
location
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| "无法确定安装目录".into())
}
/// 在应用整个生命周期内持有文件锁,进程崩溃后操作系统也会自动释放锁。
pub struct StorageLock(File);
impl StorageLock {
fn acquire(path: &Path) -> Result<Self, String> {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
.map_err(|e| {
format!(
"无法写入 {}:{e}。请将软件安装到当前用户可写的目录。",
path.display()
)
})?;
file.try_lock().map_err(|e| {
format!(
"数据目录正在被另一个 FrpcPilot 使用:{}({e})。请先退出其他实例。",
path.display()
)
})?;
Ok(Self(file))
}
}
impl Drop for StorageLock {
fn drop(&mut self) {
let _ = self.0.unlock();
}
}
pub async fn initialize(paths: &StoragePaths) -> Result<(SqlitePool, StorageLock), String> {
fs::create_dir_all(paths.data()).map_err(|e| {
format!(
"无法创建数据目录 {}:{e}。请使用可写的安装目录。",
paths.data().display()
)
})?;
let lock = StorageLock::acquire(&paths.data().join("storage.lock"))?;
for dir in [
paths.versions(),
paths.run(),
paths.webview(),
paths.root.join("cache"),
paths.logs(),
paths.root.join("tmp"),
] {
fs::create_dir_all(&dir).map_err(|e| format!("无法创建 {}:{e}", dir.display()))?;
}
let pool = init_pool(&paths.db())
.await
.map_err(|e| format!("初始化数据库失败:{e}"))?;
Ok((pool, lock))
}
pub fn write_json(path: &Path, value: &impl Serialize) -> Result<(), String> {
let bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
let temp = path.with_extension("json.tmp");
let mut file = File::create(&temp).map_err(|e| e.to_string())?;
file.write_all(&bytes)
.and_then(|_| file.sync_all())
.map_err(|e| e.to_string())?;
drop(file);
// std::fs::rename 在 Windows 和 Unix 上均以原子方式替换已有文件。
fs::rename(&temp, path).map_err(|e| e.to_string())
}
#[cfg(test)]
#[path = "storage_tests.rs"]
mod tests;
+75
View File
@@ -0,0 +1,75 @@
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("storage-tests")
.join(format!(
"{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst)
));
fs::create_dir_all(&root).unwrap();
Self(root)
}
fn paths(&self, name: &str) -> StoragePaths {
StoragePaths {
root: self.0.join(name),
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn install_locations_cover_bundles_and_appimage() {
let f = Fixture::new();
assert_eq!(
installation_root(&f.0.join("pilot/frpcpilot"), None, false).unwrap(),
f.0.join("pilot")
);
assert_eq!(
installation_root(
&f.0.join(".mount_Pilot/usr/bin/frpcpilot"),
Some(&f.0.join("apps/FrpcPilot.AppImage")),
false
)
.unwrap(),
f.0.join("apps")
);
assert_eq!(
installation_root(
&f.0.join("Applications/FrpcPilot.app/Contents/MacOS/frpcpilot"),
None,
true
)
.unwrap(),
f.0.join("Applications/FrpcPilot-data")
);
assert!(installation_root(Path::new("relative/frpcpilot"), None, false).is_err());
}
#[test]
fn fresh_install_and_exclusive_storage_lock() {
tauri::async_runtime::block_on(async {
let f = Fixture::new();
let paths = f.paths("new");
let (pool, lock) = initialize(&paths).await.unwrap();
assert!(paths.db().is_file());
assert!(paths.run().is_dir());
assert!(paths.logs().is_dir());
assert!(paths.versions().is_dir());
assert!(initialize(&paths).await.is_err());
pool.close().await;
drop(lock);
let (pool, _lock) = initialize(&paths).await.unwrap();
pool.close().await;
});
}
+5 -1
View File
@@ -22,7 +22,11 @@
}
],
"security": {
"csp": null
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["$APPDATA/backgrounds/**"]
}
}
},
"bundle": {
+29
View File
@@ -0,0 +1,29 @@
import { isTauri } from "@/utils/window"
import { invoke } from "@tauri-apps/api/core"
export async function isAutostartSupported(): Promise<boolean> {
if (!isTauri()) return false
return invoke<boolean>("is_autostart_supported")
}
/**
* 开机自启封装(Tauri 官方 autostart 插件)。
*
* 自启状态由系统启动项决定(Windows 注册表 Run 键 / macOS LaunchAgent /
* Linux XDG autostart),这里只做读写,不在前端另行持久化,避免两端不一致。
* 非 Tauri 环境(纯浏览器调试)下调用会被安全忽略。
*/
/** 读取系统启动项:本应用是否已设为开机自启 */
export async function isAutostartEnabled(): Promise<boolean> {
if (!isTauri()) return false
const { isEnabled } = await import("@tauri-apps/plugin-autostart")
return isEnabled()
}
/** 开启 / 关闭开机自启(直接写入系统启动项) */
export async function setAutostartEnabled(enabled: boolean): Promise<void> {
if (!isTauri()) return
const { enable, disable } = await import("@tauri-apps/plugin-autostart")
await (enabled ? enable() : disable())
}
+20
View File
@@ -0,0 +1,20 @@
import { invoke } from "@tauri-apps/api/core"
import type { ApiResponse } from "@/types/api"
async function call<T>(command: string): Promise<T> {
const response = await invoke<ApiResponse<T>>(command)
if (!response?.success) {
throw new Error(response?.msg || "操作失败")
}
return response.data as T
}
/** 让用户选择并持久化一张自定义背景图片,取消时返回 null。 */
export function chooseCustomBackground() {
return call<string | null>("choose_custom_background")
}
/** 获取已保存的自定义背景图片路径。 */
export function getCustomBackground() {
return call<string | null>("get_custom_background")
}
+1 -1
View File
@@ -22,7 +22,7 @@ async function call<T>(command: string, args?: Record<string, unknown>): Promise
}
/**
* 获取 Frp 版本列表(返回所有架构,前端按开关过滤)
* 获取 Frp 版本列表(返回所有架构,前端按平台筛选)
*/
export function getFrpReleases() {
return call<FrpRelease[]>("get_frp_releases")
+2
View File
@@ -1,5 +1,6 @@
import type { Update } from "@tauri-apps/plugin-updater"
import { isTauri } from "@/utils/window"
import { flushPreferences } from "@/utils/preferences"
/** 项目 GitHub 仓库地址(与 tauri.conf.json 中 updater 端点保持一致) */
export const PROJECT_GITHUB_URL = "https://github.com/SparksFly91/FrpcPilot"
@@ -18,6 +19,7 @@ export async function checkForAppUpdate(): Promise<Update | null> {
/** 重启应用(macOS / Linux 安装更新后需要重启才能运行新版本) */
export async function relaunchApp() {
if (!isTauri()) return
await flushPreferences()
const { relaunch } = await import("@tauri-apps/plugin-process")
await relaunch()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+27 -14
View File
@@ -1,6 +1,6 @@
@import 'tailwindcss';
/* 使用 class 方式控制暗色模式(配合 @vueuse/core 的 useDark) */
/* 使用 class 方式控制暗色模式 */
@custom-variant dark (&:where(.dark, .dark *));
html,
@@ -11,7 +11,7 @@ body,
padding: 0;
}
/* 窗口背景色与渐变(供 html / #app 复用,避免重复定义) */
/* 窗口纯色背景与磨砂透明度 */
:root {
/* 全局文字颜色:普通 HTML 文本继承使用(Antd 组件由 CSS-in-JS 自行着色,不受影响) */
--app-text-color: rgba(15, 23, 42, 0.88);
@@ -19,20 +19,13 @@ body,
--app-card-alpha: 0.45;
--app-card-alpha-hover: 0.62;
--app-modal-alpha: 0.62;
--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%);
--app-background-overlay-alpha: 0.18;
--app-bg-color: #e9edef;
}
html.dark {
--app-text-color: rgba(226, 232, 240, 0.92);
--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%);
--app-bg-color: #151c1d;
}
/*
@@ -43,7 +36,6 @@ html.dark {
*/
html {
background-color: var(--app-bg-color);
background-image: var(--app-bg-gradient);
background-repeat: no-repeat;
}
@@ -71,8 +63,9 @@ html.is-macos #app {
inset: 0;
border-radius: 8px;
overflow: hidden;
position: relative;
isolation: isolate;
background-color: var(--app-bg-color);
background-image: var(--app-bg-gradient);
background-repeat: no-repeat;
}
@@ -113,10 +106,30 @@ body {
/* 窗口圆角固定 8px,与 Windows 11 系统默认圆角保持一致 */
#app {
position: relative;
isolation: isolate;
border-radius: 8px;
overflow: hidden;
}
#app::before {
position: fixed;
z-index: 0;
inset: 0;
background: rgba(255, 255, 255, var(--app-background-overlay-alpha));
content: "";
pointer-events: none;
}
.dark #app::before {
background: rgba(0, 0, 0, var(--app-background-overlay-alpha));
}
#app > * {
position: relative;
z-index: 1;
}
/* 悬浮磨砂玻璃面板 */
.glass-panel {
border-radius: 16px;
+44
View File
@@ -0,0 +1,44 @@
import { createApp } from "vue"
import { createPinia } from "pinia"
import App from "./App.vue"
import "@/assets/main.css"
// 引入 Ant Design Vue 组件库
import Antd from "ant-design-vue"
import "ant-design-vue/dist/reset.css"
// 引入路由
import router from "@/router/router"
// 引入国际化
import i18n from "@/i18n"
import { detectPlatform } from "@/composables/usePlatform"
import { nextTick } from "vue"
import { useOnboardingStore } from "@/stores/onboarding"
/*
* macOS 的无边框窗口系统不会自动裁圆角,需要配合 transparent 窗口,
* 由 #app 自身裁出 8px 圆角(见 assets/main.css)。这里在挂载前打上平台标记,
* 避免首帧出现方角闪烁。
*/
if (detectPlatform() === "macos") {
document.documentElement.classList.add("is-macos")
}
const app = createApp(App)
app.use(createPinia())
app.use(Antd)
app.use(i18n)
app.use(router)
// 路由懒加载完成后再挂载,避免启动画面移除后出现空白内容区。
async function mountApp() {
await router.isReady()
app.mount("#app")
await nextTick()
// 让浏览器先绘制完整布局,再揭开启动画面并开始首次引导。
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.getElementById("startup-loader")?.remove()
useOnboardingStore().ready = true
})
})
}
void mountApp()
+9 -7
View File
@@ -29,7 +29,7 @@ const model = defineModel<T>({ required: true })
.card-radio-group {
display: flex;
flex-direction: column;
gap: 10px;
gap: 6px;
width: 100%;
}
@@ -38,8 +38,8 @@ const model = defineModel<T>({ required: true })
align-items: flex-start;
width: 100%;
margin-inline-end: 0;
padding: 10px 12px;
border-radius: 10px;
padding: 7px 10px;
border-radius: 8px;
border: 1px solid rgba(15, 23, 42, 0.1);
transition: border-color 0.2s ease, background-color 0.2s ease;
@@ -54,13 +54,13 @@ const model = defineModel<T>({ required: true })
}
:deep(.ant-radio) {
margin-top: 2px;
margin-top: 1px;
}
:deep(> span:last-child) {
display: flex;
flex-direction: column;
gap: 2px;
gap: 0;
padding-inline-end: 0;
}
}
@@ -75,12 +75,14 @@ const model = defineModel<T>({ required: true })
}
.card-radio-title {
font-size: 13px;
font-weight: 500;
line-height: 1.5;
}
.card-radio-desc {
font-size: 12px;
line-height: 1.4;
font-size: 11.5px;
line-height: 1.35;
opacity: 0.6;
}
</style>
+10 -2
View File
@@ -7,15 +7,16 @@
:mask-closable="false"
:closable="!store.quitting"
:keyboard="!store.quitting"
:body-style="{ paddingTop: '10px', paddingBottom: '4px' }"
@cancel="store.cancelClose()"
>
<!-- 关闭方式:最小化到系统托盘 / 直接退出 -->
<CardRadioGroup v-model="draftChoice" :options="choiceOptions" :disabled="store.quitting" />
<a-divider class="!my-3" />
<a-divider class="close-divider" />
<!-- 关闭行为:每次询问 / 保持以上选择 -->
<div class="mb-2 text-xs opacity-60">{{ t("closeDialog.behavior") }}</div>
<div class="mb-1.5 text-xs opacity-60">{{ t("closeDialog.behavior") }}</div>
<CardRadioGroup v-model="draftBehavior" :options="behaviorOptions" :disabled="store.quitting" />
<template #footer>
@@ -68,3 +69,10 @@ function onConfirm() {
void store.confirmClose(draftChoice.value, draftBehavior.value)
}
</script>
<style scoped>
/* 弹窗内两组选项之间只留窄窄一段分隔,整体更紧凑 */
.close-divider {
margin: 8px 0;
}
</style>
+71
View File
@@ -0,0 +1,71 @@
<template>
<a-tour
:open="onboarding.active && visible"
:current="current"
:steps="steps"
:gap="{ offset: 8, radius: 10 }"
:mask="{ color: 'rgba(15, 23, 42, 0.55)' }"
@change="changeStep"
@close="onboarding.close"
/>
</template>
<script setup lang="ts">
import type { TourProps } from "ant-design-vue"
import { useI18n } from "vue-i18n"
import { useOnboardingStore } from "@/stores/onboarding"
const { t } = useI18n()
const router = useRouter()
const onboarding = useOnboardingStore()
const current = ref(0)
const visible = ref(false)
const navigating = ref(false)
const target = shallowRef<HTMLElement>()
const destinations = [
{ path: "/servers", target: "server-create", key: "servers" },
{ path: "/tunnels", target: "tunnel-create", key: "tunnels" },
{ path: "/download", target: "frp-download", key: "download" },
{ path: "/dashboard", target: "dashboard-start", key: "dashboard" },
] as const
const steps = computed<TourProps["steps"]>(() => destinations.map((step, index) => ({
title: t(`onboarding.${step.key}Title`),
description: t(`onboarding.${step.key}Description`),
target: index === current.value ? target.value : undefined,
placement: "bottom",
})))
async function changeStep(index: number) {
const destination = destinations[index]
if (!destination || navigating.value) return
navigating.value = true
visible.value = false
try {
await router.push(destination.path)
await nextTick()
current.value = index
target.value = document.querySelector<HTMLElement>(`[data-tour="${destination.target}"]`) ?? undefined
visible.value = onboarding.active
} catch (error) {
onboarding.close()
console.error("Unable to open onboarding page", error)
} finally {
navigating.value = false
}
}
watch(() => onboarding.active, (active) => {
if (active) void changeStep(0)
else visible.value = false
})
watch(() => onboarding.ready, (ready) => {
if (ready && !onboarding.dismissed) onboarding.start()
}, { immediate: true })
// Antdv 提供关闭按钮;同时允许键盘用户通过 Escape 跳过引导。
onKeyStroke("Escape", () => {
if (onboarding.active) onboarding.close()
})
</script>
+23 -16
View File
@@ -27,25 +27,26 @@
<p v-else class="m-0 opacity-80">{{ t("update.emptyLog") }}</p>
</div>
<!-- 下载 / 安装进度 -->
<div v-if="store.installing" class="update-progress">
<a-progress :percent="store.progress" :stroke-color="'#6366f1'" />
<p class="m-0 text-xs opacity-60">{{ t("update.downloading") }}</p>
</div>
<!-- 底部操作:忽略该版本 / 取消 / 确认更新 -->
<template #footer>
<div class="update-footer">
<a-button v-if="!store.installing" type="text" size="small" @click="onIgnore">
{{ t("update.ignore") }}
</a-button>
<div class="ml-auto flex items-center gap-2">
<a-button :disabled="store.installing" @click="onCancel">
{{ t("common.cancel") }}
</a-button>
<a-button type="primary" :loading="store.installing" @click="onConfirm">
{{ t("update.confirm") }}
<!-- 下载进度固定在 footer 的按钮操作上方 -->
<div v-if="store.installing" class="update-progress">
<a-progress :percent="store.progress" :stroke-color="'#6366f1'" />
<p class="m-0 text-xs opacity-60">{{ t("update.downloading") }}</p>
</div>
<div class="update-footer-actions">
<a-button v-if="!store.installing" type="text" size="small" @click="onIgnore">
{{ t("update.ignore") }}
</a-button>
<div class="ml-auto flex items-center gap-2">
<a-button :disabled="store.installing" @click="onCancel">
{{ t("common.cancel") }}
</a-button>
<a-button type="primary" :loading="store.installing" @click="onConfirm">
{{ t("update.confirm") }}
</a-button>
</div>
</div>
</div>
</template>
@@ -135,10 +136,16 @@ function onConfirm() {
}
.update-progress {
margin-top: 14px;
width: 100%;
}
.update-footer {
display: flex;
flex-direction: column;
gap: 12px;
}
.update-footer-actions {
display: flex;
align-items: center;
gap: 8px;
+1 -2
View File
@@ -11,7 +11,7 @@
<div class="mt-1 flex flex-wrap items-center gap-2">
<a-tag color="blue" class="!me-0">{{ release.platform }}</a-tag>
<a-tag class="!me-0">frpc</a-tag>
<a-tag v-if="showFrps" color="cyan" class="!me-0">frps</a-tag>
<a-tag color="cyan" class="!me-0">frps</a-tag>
</div>
</div>
</div>
@@ -93,7 +93,6 @@ import type { FrpDownloadProgress, FrpRelease } from "@/types/frp"
const props = defineProps<{
release: FrpRelease
progress?: FrpDownloadProgress | null
showFrps?: boolean
}>()
const emit = defineEmits<{
+7 -1
View File
@@ -1,5 +1,6 @@
import { getSystemInfo } from "@/api/system"
import { isTauri } from "@/utils/window"
import { createSharedComposable } from "@vueuse/core"
/**
* 系统资源占用(CPU / 内存 百分比)。
@@ -7,7 +8,7 @@ import { isTauri } from "@/utils/window"
* 调用后端命令 `get_system_info`(返回 `ApiResponse<{ cpu, memory }>`),
* 在非 Tauri 环境或后端不可用时回退到模拟数据,保证界面可用。
*/
export function useSystemStats(interval = 2000) {
const useSharedSystemStats = createSharedComposable((interval: number) => {
const cpu = ref(0)
const memory = ref(0)
const isMock = ref(true)
@@ -41,4 +42,9 @@ export function useSystemStats(interval = 2000) {
const { pause, resume } = useIntervalFn(refresh, interval, { immediate: true })
return { cpu, memory, isMock, refresh, pause, resume }
})
/** 所有页面共享同一份指标缓存与轮询,路由切换时不会重新请求。 */
export function useSystemStats(interval = 2000) {
return useSharedSystemStats(interval)
}
+2 -1
View File
@@ -9,7 +9,7 @@ import bg8 from "@/assets/images/bg-8.webp"
import bg9 from "@/assets/images/bg-9.webp"
/** 窗口背景图片标识 */
export type BackgroundKey = "none" | "bg-1" | "bg-2" | "bg-3" | "bg-4" | "bg-5" | "bg-6" | "bg-7" | "bg-8" | "bg-9"
export type BackgroundKey = "none" | "custom" | "bg-1" | "bg-2" | "bg-3" | "bg-4" | "bg-5" | "bg-6" | "bg-7" | "bg-8" | "bg-9"
export interface BackgroundOption {
/** 唯一标识 */
@@ -38,5 +38,6 @@ export const DEFAULT_BACKGROUND: BackgroundKey = "none"
/** 根据标识取图片地址 */
export function getBackgroundUrl(key: BackgroundKey): string {
if (key === "custom") return ""
return backgroundOptions.find((item) => item.key === key)?.url ?? ""
}
+54 -13
View File
@@ -25,9 +25,27 @@
"dashboard": "Dashboard",
"servers": "Servers",
"tunnels": "Tunnels",
"download": "Download frpc",
"download": "Frp Download",
"setting": "Settings"
},
"sidebar": {
"navigation": "Main navigation",
"collapse": "Collapse navigation",
"expand": "Expand navigation"
},
"onboarding": {
"title": "Getting started",
"replay": "View guide",
"replayDescription": "Learn the complete setup and startup workflow in four steps",
"serversTitle": "1 / 4 · Configure a server",
"serversDescription": "Choose Add Server, enter your deployed frps address, port and authentication token, then enable the server. Only one server needs to be enabled at a time.",
"tunnelsTitle": "2 / 4 · Create a tunnel",
"tunnelsDescription": "Choose Add Tunnel, select a proxy type such as TCP or HTTP, and enter your local service IP and port, plus a remote port or domain. Save and enable the tunnel. Multiple tunnels can run together.",
"downloadTitle": "3 / 4 · Download Frp",
"downloadDescription": "Filter by your system and architecture, then download Frp from a version card below. You can also import an existing local frpc. Downloaded or imported versions appear in the dashboard selector.",
"dashboardTitle": "4 / 4 · Start from the dashboard",
"dashboardDescription": "After setup, select a downloaded Frpc version and click Start to connect. Check the live logs and running status below to verify the connection. You are ready to configure your connection. Replay this guide anytime in Settings → General."
},
"tray": {
"show": "Show window",
"start": "Start tunnel",
@@ -62,7 +80,7 @@
"arch": "Architecture",
"ip": "IP address",
"frpcVersion": "Frpc version",
"frpcVersionEmpty": "No downloaded version yet. Download one from the Frpc download page first.",
"frpcVersionEmpty": "No downloaded version yet. Download one from the Frp download page first.",
"start": "One-click start",
"stop": "Stop",
"logs": "Live logs",
@@ -84,7 +102,14 @@
},
"general": {
"title": "General",
"subtitle": "Basic settings such as close behavior and app language",
"subtitle": "Basic settings such as launch at startup, close behavior and app language",
"autostart": "Launch at startup",
"autostartTip": "Start FrpcPilot automatically after you sign in. The switch writes straight to the system startup entry.",
"autostartUnsupported": "System startup entries are not accessible here, so this setting is unavailable.",
"autostartRequiresRelease": "Enable launch at startup in a release build. Development builds require a local server and cannot run independently after a restart.",
"autostartEnabled": "Launch at startup enabled",
"autostartDisabled": "Launch at startup disabled",
"autostartFailed": "Failed to update launch at startup: {message}",
"closeBehavior": "Close behavior",
"closeBehaviorTip": "Choose what happens when you click the close button. Kept in sync with the close dialog.",
"closeAction": "Close method",
@@ -96,11 +121,19 @@
"title": "Appearance",
"subtitle": "Customize the theme mode and window background",
"themeMode": "Theme mode",
"themeModeTip": "Switch between light and dark mode",
"themeModeTip": "Choose light, dark, or follow your operating system",
"light": "Light",
"dark": "Dark",
"system": "System",
"background": "Background image",
"backgroundTip": "Pick an image as the window background. It keeps its original aspect ratio and is never stretched.",
"customBackground": "Custom",
"chooseCustomBackground": "Choose image",
"changeCustomBackground": "Change custom background",
"backgroundOverlay": "Window background overlay",
"backgroundOverlayTip": "Apply a theme-aware overlay across the window to improve contrast with title bar icons.",
"overlayTransparency": "Overlay transparency",
"backgroundOverlayPreview": "Title bar icon preview",
"none": "Default",
"bg1": "Background 1",
"bg2": "Background 2",
@@ -229,8 +262,8 @@
"frp": {
"refresh": "Refresh",
"import": "Import local",
"showAllArchFrpc": "All-arch Frpc",
"showAllArchFrps": "All-arch Frps",
"platformFilter": "Platform",
"allPlatforms": "All",
"empty": "No Frp release data. Click Refresh to retry.",
"downloaded": "Downloaded",
"local": "Local",
@@ -257,12 +290,16 @@
"about": {
"title": "About FrpcPilot",
"subtitle": "A lightweight, friendly visual client for frp",
"versionTitle": "Version",
"currentVersion": "Current version",
"checkUpdate": "Check for updates",
"newVersion": "New version v{version} is available",
"introTitle": "Introduction",
"intro": "FrpcPilot is a visual client for frp built with Tauri 2 + Vue 3 + TypeScript. Manage frpc versions, servers and tunnels through a friendly GUI — and say goodbye to hand-writing frpc.toml.",
"flowTitle": "How to use",
"flow": {
"step1Title": "Download frpc",
"step1Desc": "Open \"Download frpc\", grab the build for your platform and architecture, or import a local one.",
"step1Title": "Download Frp",
"step1Desc": "Open \"Frp Download\", grab the build for your platform and architecture, or import a local one.",
"step2Title": "Configure a server",
"step2Desc": "Add the frps address, port and auth token under \"Servers\", then enable one of them.",
"step3Title": "Configure tunnels",
@@ -273,10 +310,11 @@
"repoTitle": "Open source",
"repoDesc": "Stars, issues and contributions are all welcome~",
"openRepo": "Open GitHub",
"versionTitle": "Version",
"currentVersion": "Current version",
"checkUpdate": "Check for updates",
"newVersion": "New version v{version} is available"
"contactTitle": "Contact",
"contactDesc": "Questions or ideas? Reach me by email or WeChat, or just open an issue on GitHub.",
"email": "Email",
"wechat": "WeChat",
"wechatTip": "Scan the QR code to add me"
},
"update": {
"title": "New version v{version} is available ✨",
@@ -296,6 +334,9 @@
"developing": "This feature is under construction",
"confirm": "OK",
"cancel": "Cancel",
"save": "Save"
"save": "Save",
"copy": "Copy",
"copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed, please copy manually"
}
}
+54 -13
View File
@@ -25,9 +25,27 @@
"dashboard": "仪表盘",
"servers": "服务器",
"tunnels": "隧道列表",
"download": "Frpc 下载",
"download": "Frp下载",
"setting": "设置"
},
"sidebar": {
"navigation": "主导航",
"collapse": "折叠导航",
"expand": "展开导航"
},
"onboarding": {
"title": "新手引导",
"replay": "重新查看",
"replayDescription": "用四个步骤了解从配置到启动的完整流程",
"serversTitle": "1 / 4 · 配置服务器",
"serversDescription": "从「新增服务器」开始,填写已部署的 frps 地址、端口和鉴权 Token,并启用这台服务器。同一时间只需启用一台。",
"tunnelsTitle": "2 / 4 · 创建隧道",
"tunnelsDescription": "点击「新增隧道」,选择 TCP、HTTP 等代理类型,填写本地服务的 IP 和端口,以及远程端口或域名,保存并启用隧道。可以同时启用多个隧道。",
"downloadTitle": "3 / 4 · 下载 Frp",
"downloadDescription": "按当前系统和架构筛选版本,在下方版本卡片中下载 Frp;也可以通过「导入本地」使用已有的 frpc。下载或导入后,即可在仪表盘中选择该版本。",
"dashboardTitle": "4 / 4 · 仪表盘一键启动",
"dashboardDescription": "完成前面的配置后,选择已下载的 Frpc 版本,点击「一键启动」开始穿透。下方实时日志和运行状态可帮助确认连接情况。现在可以开始配置了,也可随时在「设置 → 常规」重新查看引导。"
},
"tray": {
"show": "显示窗口",
"start": "启动穿透",
@@ -62,7 +80,7 @@
"arch": "系统架构",
"ip": "IP 地址",
"frpcVersion": "Frpc 版本",
"frpcVersionEmpty": "暂无已下载版本,请先前往 Frpc 下载页面下载",
"frpcVersionEmpty": "暂无已下载版本,请先前往 Frp下载页面下载",
"start": "一键启动",
"stop": "停止",
"logs": "实时日志",
@@ -84,7 +102,14 @@
},
"general": {
"title": "常规",
"subtitle": "关闭行为与应用语言等基础设置",
"subtitle": "开机自启、关闭行为与应用语言等基础设置",
"autostart": "开机自启",
"autostartTip": "登录系统后自动启动 FrpcPilot,开关状态直接写入系统启动项",
"autostartUnsupported": "当前环境无法读写系统启动项,暂不支持该设置",
"autostartRequiresRelease": "请在正式构建的应用中开启自启;开发版本依赖本地服务,无法在重启后独立运行",
"autostartEnabled": "已开启开机自启",
"autostartDisabled": "已关闭开机自启",
"autostartFailed": "设置开机自启失败:{message}",
"closeBehavior": "关闭行为",
"closeBehaviorTip": "设置点击关闭按钮时的处理方式,与关闭弹窗中的选项保持一致",
"closeAction": "关闭方式",
@@ -96,11 +121,19 @@
"title": "外观设置",
"subtitle": "自定义窗口的主题模式与背景图片",
"themeMode": "主题模式",
"themeModeTip": "切换窗口的明暗显示模式",
"themeModeTip": "选择浅色、深色或跟随操作系统主题",
"light": "浅色",
"dark": "深色",
"system": "跟随系统",
"background": "背景图片",
"backgroundTip": "选择一张图片作为窗口背景,图片保持原始比例自适应,不会变形拉伸",
"customBackground": "自定义",
"chooseCustomBackground": "选择图片",
"changeCustomBackground": "更换自定义背景",
"backgroundOverlay": "窗口背景遮罩",
"backgroundOverlayTip": "为整个窗口叠加随主题变化的遮罩,改善背景与顶部栏图标的对比度",
"overlayTransparency": "遮罩透明度",
"backgroundOverlayPreview": "顶部栏图标预览",
"none": "默认",
"bg1": "背景一",
"bg2": "背景二",
@@ -229,8 +262,8 @@
"frp": {
"refresh": "刷新",
"import": "导入本地",
"showAllArchFrpc": "所有架构 Frpc",
"showAllArchFrps": "所有架构 Frps",
"platformFilter": "架构",
"allPlatforms": "全部",
"empty": "暂无 Frp 版本数据,点击刷新重试",
"downloaded": "已下载",
"local": "本地",
@@ -257,12 +290,16 @@
"about": {
"title": "关于 FrpcPilot",
"subtitle": "轻量、顺手的 frp 可视化桌面客户端",
"versionTitle": "版本信息",
"currentVersion": "当前版本",
"checkUpdate": "检查更新",
"newVersion": "发现新版本 v{version}",
"introTitle": "项目介绍",
"intro": "FrpcPilot 是一个基于 Tauri 2 + Vue 3 + TypeScript 打造的 frp 可视化客户端,帮你用图形界面管理 frpc 版本、服务器与隧道,从此告别手写 frpc.toml。",
"flowTitle": "使用流程",
"flow": {
"step1Title": "下载 frpc",
"step1Desc": "进入「Frpc 下载」,按平台与架构下载,或导入本地已有的 frpc。",
"step1Title": "下载 Frp",
"step1Desc": "进入「Frp下载」,按平台与架构下载,或导入本地已有的 frpc。",
"step2Title": "配置服务器",
"step2Desc": "在「服务器」中填写 frps 地址、端口与鉴权 Token,并启用其中一台。",
"step3Title": "配置隧道",
@@ -273,10 +310,11 @@
"repoTitle": "开源地址",
"repoDesc": "欢迎 Star、提 Issue 或一起参与共建~",
"openRepo": "打开 GitHub",
"versionTitle": "版本信息",
"currentVersion": "当前版本",
"checkUpdate": "检查更新",
"newVersion": "发现新版本 v{version}"
"contactTitle": "联系方式",
"contactDesc": "遇到问题或有新想法,欢迎通过邮箱或微信联系我,也可以在 GitHub 上提 Issue。",
"email": "邮箱",
"wechat": "微信",
"wechatTip": "扫码添加微信"
},
"update": {
"title": "发现新版本 v{version} ✨",
@@ -296,6 +334,9 @@
"developing": "功能开发中,敬请期待",
"confirm": "确定",
"cancel": "取消",
"save": "保存"
"save": "保存",
"copy": "复制",
"copySuccess": "已复制到剪贴板",
"copyFailed": "复制失败,请手动复制"
}
}
+12 -3
View File
@@ -23,6 +23,7 @@
<!-- 关闭确认对话框(最小化到系统托盘 / 直接退出) -->
<CloseConfirmModal />
<OnboardingTour />
</div>
</template>
@@ -32,9 +33,12 @@ import Aside from "./aside/Aside.vue"
import UpdateModal from "@/components/UpdateModal.vue"
import CloseConfirmModal from "@/components/CloseConfirmModal.vue"
import { useUpdateStore } from "@/stores/update"
import { useOnboardingStore } from "@/stores/onboarding"
import OnboardingTour from "@/components/OnboardingTour.vue"
const router = useRouter()
const updateStore = useUpdateStore()
const onboarding = useOnboardingStore()
/**
* 需要 keep-alive 缓存的页面组件名列表(由路由 meta.isCache 决定)。
@@ -55,7 +59,12 @@ function goToSetting() {
}
// 应用启动后静默检查一次更新(已忽略的版本不会打扰用户)
onMounted(() => {
updateStore.checkForUpdate(true)
})
const stopUpdateWatch = watch(
() => onboarding.ready && onboarding.dismissed && !onboarding.active,
(canCheck) => {
if (!canCheck) return
void updateStore.checkForUpdate(true)
stopUpdateWatch()
},
)
</script>
+176 -3
View File
@@ -1,14 +1,19 @@
<template>
<!-- 侧边栏:磨砂玻璃悬浮面板 -->
<aside class="glass-panel flex w-56 shrink-0 flex-col overflow-hidden">
<aside
id="app-sidebar"
class="app-sidebar glass-panel flex shrink-0 flex-col overflow-hidden"
:class="{ 'is-collapsed': appStore.sidebarCollapsed }"
:aria-label="t('sidebar.navigation')"
>
<!-- 菜单区 -->
<div class="flex-1 overflow-y-auto p-2">
<a-menu class="aside-menu" mode="inline" :selected-keys="selectedKeys" :items="menuItems" @click="onMenuClick" />
<a-menu class="aside-menu" mode="inline" :inline-collapsed="appStore.sidebarCollapsed" :selected-keys="selectedKeys" :items="menuItems" @click="onMenuClick" />
</div>
<!-- 底部系统信息区(与菜单之间有间隔) -->
<div class="system-panel mt-2 border-t px-2 pb-2 pt-3">
<div class="px-1">
<div v-if="!appStore.sidebarCollapsed" class="px-1">
<!-- CPU -->
<div class="mb-2">
<div class="mb-1 flex items-center justify-between text-xs">
@@ -35,15 +40,54 @@
</div>
</div>
<div
v-else
class="collapsed-system-stats"
tabindex="0"
:aria-label="`${t('system.cpu')}: ${cpu}% · ${t('system.memory')}: ${memory}% · ${statusText}`"
>
<div class="collapsed-system-metric">
<span class="collapsed-system-label">CPU</span>
<span class="collapsed-system-value">{{ cpu }}%</span>
<span class="collapsed-system-track"><span :style="{ width: `${cpu}%`, backgroundColor: barColor(cpu) }" /></span>
</div>
<div class="collapsed-system-metric">
<span class="collapsed-system-label">MEM</span>
<span class="collapsed-system-value">{{ memory }}%</span>
<span class="collapsed-system-track"><span :style="{ width: `${memory}%`, backgroundColor: barColor(memory) }" /></span>
</div>
<div class="collapsed-system-status">
<span class="h-2 w-2 shrink-0 rounded-full" :class="statusDotClass" />
<span>{{ statusText }}</span>
</div>
</div>
<!-- 设置入口(位于运行状态下方) -->
<div class="system-divider mt-3 border-t pt-2">
<a-menu
class="aside-menu"
mode="inline"
:inline-collapsed="appStore.sidebarCollapsed"
:selected-keys="settingSelectedKeys"
:items="settingItems"
@click="onMenuClick"
/>
<a-tooltip placement="right" :title="collapseLabel">
<a-button
class="sidebar-toggle"
type="text"
:aria-label="collapseLabel"
:aria-expanded="!appStore.sidebarCollapsed"
aria-controls="app-sidebar"
@click="appStore.sidebarCollapsed = !appStore.sidebarCollapsed"
>
<template #icon>
<MenuUnfoldOutlined v-if="appStore.sidebarCollapsed" />
<MenuFoldOutlined v-else />
</template>
<span v-if="!appStore.sidebarCollapsed">{{ collapseLabel }}</span>
</a-button>
</a-tooltip>
</div>
</div>
</aside>
@@ -56,6 +100,8 @@ import {
CloudServerOutlined,
DashboardOutlined,
DownloadOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SettingOutlined,
} from "@ant-design/icons-vue"
import { useI18n } from "vue-i18n"
@@ -67,6 +113,7 @@ const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const collapseLabel = computed(() => t(appStore.sidebarCollapsed ? 'sidebar.expand' : 'sidebar.collapse'))
const { cpu, memory } = useSystemStats()
@@ -121,10 +168,136 @@ const statusText = computed(() => {
</script>
<style scoped lang="scss">
.app-sidebar {
width: 224px;
transition: width 200ms ease;
&.is-collapsed {
width: 72px;
}
}
.sidebar-toggle {
width: 100%;
height: 44px;
border: 0;
border-radius: 10px;
}
/* 展开时沿用菜单项的左内边距,让折叠操作与“设置”入口排成同一列。 */
.app-sidebar:not(.is-collapsed) .sidebar-toggle {
justify-content: flex-start;
gap: 10px;
padding-inline: 24px;
:deep(.anticon + span) {
margin-inline-start: 0;
}
}
/* 折叠时隐藏文字后,按钮和菜单图标都回到侧栏中心。 */
.app-sidebar.is-collapsed .sidebar-toggle {
justify-content: center;
padding-inline: 0;
}
.sidebar-toggle:focus-visible {
outline: 2px solid #6366f1;
outline-offset: -2px;
}
.is-collapsed .aside-menu {
width: 100%;
border-inline-end: none;
:deep(.ant-menu-item) {
display: flex;
align-items: center;
justify-content: center;
padding-inline: 0 !important;
height: 44px;
line-height: 44px;
}
:deep(.ant-menu-item-icon) {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 16px;
margin: 0;
font-size: 16px;
line-height: 1;
}
:deep(.ant-menu-title-content) {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.app-sidebar {
transition: none;
}
}
.system-panel {
border-color: rgba(15, 23, 42, 0.08);
}
.collapsed-system-stats {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 2px 0 4px;
outline: none;
}
.collapsed-system-metric {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 1px;
line-height: 1.2;
}
.collapsed-system-label {
font-size: 9px;
opacity: 0.62;
}
.collapsed-system-value {
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.collapsed-system-track {
width: 38px;
height: 2px;
overflow: hidden;
border-radius: 999px;
background: rgba(100, 116, 139, 0.2);
> span {
display: block;
height: 100%;
transition: width 200ms ease;
}
}
.collapsed-system-status {
display: flex;
max-width: 100%;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 9px;
line-height: 1.2;
opacity: 0.8;
}
.dark .system-panel {
border-color: rgba(255, 255, 255, 0.08);
}
+12 -25
View File
@@ -1,28 +1,15 @@
import { createApp } from "vue"
import { createPinia } from "pinia"
import App from "./App.vue"
import "@/assets/main.css"
// 引入 Ant Design Vue 组件库
import Antd from "ant-design-vue"
import "ant-design-vue/dist/reset.css"
// 引入路由
import router from "@/router/router"
// 引入国际化
import i18n from "@/i18n"
import { detectPlatform } from "@/composables/usePlatform"
import { initializePreferences } from "@/utils/preferences"
/*
* macOS 的无边框窗口系统不会自动裁圆角,需要配合 transparent 窗口,
* 由 #app 自身裁出 8px 圆角(见 assets/main.css)。这里在挂载前打上平台标记,
* 避免首帧出现方角闪烁。
*/
if (detectPlatform() === "macos") {
document.documentElement.classList.add("is-macos")
// 状态仓库创建时会读取初始设置,因此先加载安装目录内保存的设置。
async function start() {
try {
await initializePreferences()
await import("./bootstrap")
} catch (error) {
console.error("Failed to initialize application", error)
const loader = document.getElementById("startup-loader")
if (loader) loader.textContent = `无法读取应用设置 / Cannot load settings: ${String(error)}`
}
}
const app = createApp(App)
app.use(createPinia())
app.use(Antd)
app.use(i18n)
app.use(router)
app.mount("#app")
void start()
+69 -14
View File
@@ -1,4 +1,7 @@
import { defineStore } from "pinia"
import { convertFileSrc } from "@tauri-apps/api/core"
import { preferences } from "@/utils/preferences"
import { chooseCustomBackground as chooseCustomBackgroundFile, getCustomBackground } from "@/api/background"
import zhCN from "ant-design-vue/es/locale/zh_CN"
import enUS from "ant-design-vue/es/locale/en_US"
import { setI18nLanguage, type Language } from "@/i18n"
@@ -7,30 +10,74 @@ import {
getBackgroundUrl,
type BackgroundKey,
} from "@/config/backgrounds"
import { isTauri } from "@/utils/window"
/** Frpc 运行状态 */
export type RunStatus = "running" | "error" | "stopped"
export type ThemeMode = "light" | "dark" | "system"
/**
* 全局应用配置:语言、主题、窗口背景、Frpc 运行状态
*/
export const useAppStore = defineStore("app", () => {
// 语言(持久化到 localStorage)
const language = useStorage<Language>("frpcpilot:language", "zh-CN")
// 安装目录内的持久化设置
const language = useStorage<Language>("frpcpilot:language", "zh-CN", preferences)
// 明暗主题:useDark 会在 <html> 上切换 .dark 类名
const isDark = useDark({
storageKey: "frpcpilot:theme",
selector: "html",
attribute: "class",
valueDark: "dark",
valueLight: "",
const sidebarCollapsed = useStorage("frpcpilot:sidebar-collapsed", false, preferences)
// 兼容旧版保存的 "dark" / 空浅色值,并支持跟随操作系统主题。
const themePreference = useStorage<ThemeMode | "">("frpcpilot:theme", "system", preferences)
const preferredDark = usePreferredDark()
const themeMode = computed<ThemeMode>({
get: () => {
if (themePreference.value === "dark" || themePreference.value === "system") return themePreference.value
return "light"
},
set: (value) => { themePreference.value = value },
})
const isDark = computed({
get: () => themeMode.value === "dark" || (themeMode.value === "system" && preferredDark.value),
set: (value: boolean) => { themeMode.value = value ? "dark" : "light" },
})
function toggleDark() {
themeMode.value = isDark.value ? "light" : "dark"
}
watchEffect(() => {
document.documentElement.classList.toggle("dark", isDark.value)
})
const toggleDark = useToggle(isDark)
// 窗口背景图片(持久化)
const background = useStorage<BackgroundKey>("frpcpilot:background", DEFAULT_BACKGROUND)
const backgroundUrl = computed(() => getBackgroundUrl(background.value))
const background = useStorage<BackgroundKey>("frpcpilot:background", DEFAULT_BACKGROUND, preferences)
const customBackgroundPath = ref("")
const customBackgroundRevision = ref(0)
const customBackgroundUrl = computed(() => {
if (!customBackgroundPath.value) return ""
return `${convertFileSrc(customBackgroundPath.value)}?v=${customBackgroundRevision.value}`
})
const backgroundUrl = computed(() => background.value === "custom" ? customBackgroundUrl.value : getBackgroundUrl(background.value))
async function loadCustomBackground() {
if (!isTauri()) return
try {
customBackgroundPath.value = (await getCustomBackground()) ?? ""
customBackgroundRevision.value = Date.now()
} catch (error) {
console.error("Failed to load custom background", error)
}
}
async function chooseCustomBackground() {
if (!isTauri()) throw new Error("自定义背景图片仅在桌面应用中可用")
const path = await chooseCustomBackgroundFile()
if (!path) return false
customBackgroundPath.value = path
customBackgroundRevision.value = Date.now()
background.value = "custom"
return true
}
void loadCustomBackground()
// 把背景图同步到 <html>(整窗背景,避免元素高度误差造成的上下留白)
watchEffect(() => {
@@ -46,8 +93,9 @@ export const useAppStore = defineStore("app", () => {
})
// 磨砂玻璃透明度(0 - 100,数值越大越透明,持久化)
const cardTransparency = useStorage<number>("frpcpilot:card-transparency", 55)
const modalTransparency = useStorage<number>("frpcpilot:modal-transparency", 38)
const cardTransparency = useStorage<number>("frpcpilot:card-transparency", 55, preferences)
const modalTransparency = useStorage<number>("frpcpilot:modal-transparency", 38, preferences)
const backgroundOverlayTransparency = useStorage<number>("frpcpilot:background-overlay-transparency", 82, preferences)
/** 透明度百分比 → 背景 alpha(越大越透明,alpha 越小;并做范围兜底) */
function alphaFromTransparency(value: number) {
@@ -57,6 +105,7 @@ export const useAppStore = defineStore("app", () => {
const cardAlpha = computed(() => alphaFromTransparency(cardTransparency.value))
const modalAlpha = computed(() => alphaFromTransparency(modalTransparency.value))
const backgroundOverlayAlpha = computed(() => alphaFromTransparency(backgroundOverlayTransparency.value))
// 把透明度实时写入 <html> 的 CSS 变量,供磨砂玻璃样式(卡片 / 弹窗)即时生效
watchEffect(() => {
@@ -64,6 +113,7 @@ export const useAppStore = defineStore("app", () => {
root.style.setProperty("--app-card-alpha", String(cardAlpha.value))
root.style.setProperty("--app-card-alpha-hover", String(Math.min(cardAlpha.value + 0.15, 0.95)))
root.style.setProperty("--app-modal-alpha", String(modalAlpha.value))
root.style.setProperty("--app-background-overlay-alpha", String(backgroundOverlayAlpha.value))
})
// Frpc 运行状态(后续由后端驱动)
@@ -93,12 +143,17 @@ export const useAppStore = defineStore("app", () => {
return {
language,
sidebarCollapsed,
isDark,
themeMode,
toggleDark,
background,
backgroundUrl,
customBackgroundUrl,
chooseCustomBackground,
cardTransparency,
modalTransparency,
backgroundOverlayTransparency,
runStatus,
antdLocale,
setLanguage,
+72
View File
@@ -0,0 +1,72 @@
import { defineStore } from "pinia"
import { message } from "ant-design-vue"
import { isTauri } from "@/utils/window"
import { isAutostartEnabled, isAutostartSupported, setAutostartEnabled } from "@/api/autostart"
import { translate } from "@/i18n"
/**
* 开机自启:直接读写系统启动项,状态以系统为准,不做本地持久化。
*
* - 进入设置页时读一次系统启动项,保证开关展示的就是系统真实状态;
* - 切换后重新读取一次,写入失败则回滚开关并提示原因,避免界面与系统不一致;
* - 无法访问系统启动项(浏览器调试 / 插件不可用)时 supported 为 false,开关置灰。
*/
export const useAutostartStore = defineStore("autostart", () => {
/** 系统启动项是否已登记本应用 */
const enabled = ref(false)
/** 读取 / 写入系统启动项期间显示加载态,同时防止重复操作 */
const loading = ref(isTauri())
/** 当前环境能否读写系统启动项 */
const supported = ref(false)
const requiresRelease = ref(false)
function errMsg(e: unknown) {
return e instanceof Error ? e.message : String(e)
}
/** 读取系统启动项的真实状态(进入设置页时调用) */
async function refresh() {
if (!isTauri()) return
loading.value = true
try {
requiresRelease.value = !(await isAutostartSupported())
if (requiresRelease.value) {
enabled.value = false
supported.value = false
return
}
enabled.value = await isAutostartEnabled()
supported.value = true
} catch {
// 读取失败(如插件未注册、权限缺失):禁用开关,不展示可能错误的状态
supported.value = false
} finally {
loading.value = false
}
}
/** 切换开机自启:写入系统启动项,成功 / 失败都以系统状态为准并给出提示 */
async function toggle(value: boolean) {
if (!supported.value || loading.value || value === enabled.value) return
const previous = enabled.value
loading.value = true
try {
await setAutostartEnabled(value)
// 以系统启动项为准;读回失败时先按目标值展示,下次进入设置页会再次校准
enabled.value = await isAutostartEnabled().catch(() => value)
message.success(
translate(
value ? "settings.general.autostartEnabled" : "settings.general.autostartDisabled",
),
)
} catch (e) {
enabled.value = previous
message.error(translate("settings.general.autostartFailed", { message: errMsg(e) }))
} finally {
loading.value = false
}
}
return { enabled, loading, supported, requiresRelease, refresh, toggle }
})
+19
View File
@@ -0,0 +1,19 @@
import { defineStore } from "pinia"
import { preferences } from "@/utils/preferences"
export const useOnboardingStore = defineStore("onboarding", () => {
const dismissed = useStorage("frpcpilot:onboarding-dismissed", false, preferences)
const ready = ref(false)
const active = ref(false)
function start() {
active.value = true
}
function close() {
active.value = false
dismissed.value = true
}
return { dismissed, ready, active, start, close }
})
+3 -1
View File
@@ -1,4 +1,5 @@
import { defineStore } from "pinia"
import { preferences, flushPreferences } from "@/utils/preferences"
import { message } from "ant-design-vue"
import type { Update } from "@tauri-apps/plugin-updater"
import { checkForAppUpdate, relaunchApp } from "@/api/updater"
@@ -20,7 +21,7 @@ export interface UpdateInfo {
*/
export const useUpdateStore = defineStore("update", () => {
/** 用户选择「忽略该版本」后持久化的版本号,静默检查时不再提示 */
const ignoredVersion = useStorage<string>("frpcpilot:ignored-update", "")
const ignoredVersion = useStorage<string>("frpcpilot:ignored-update", "", preferences)
const checking = ref(false)
const installing = ref(false)
@@ -137,6 +138,7 @@ export const useUpdateStore = defineStore("update", () => {
installing.value = true
resetProgress()
try {
await flushPreferences()
await pending.downloadAndInstall((event) => {
switch (event.event) {
case "Started":
+3 -2
View File
@@ -1,4 +1,5 @@
import { defineStore } from "pinia"
import { preferences } from "@/utils/preferences"
import { exitApp, hideWindow, isTauri, revealWindow } from "@/utils/window"
import { useFrpcStore } from "@/stores/frpc"
@@ -24,9 +25,9 @@ export const useWindowStore = defineStore("window", () => {
/** 关闭确认对话框是否打开 */
const closeConfirmOpen = ref(false)
/** 记住的关闭方式(弹窗默认选中项;「保持以上选择」时直接生效,持久化) */
const closeChoice = useStorage<CloseChoice>("frpcpilot:close-choice", "tray")
const closeChoice = useStorage<CloseChoice>("frpcpilot:close-choice", "tray", preferences)
/** 关闭行为:每次询问 / 保持以上选择(持久化,默认每次询问) */
const closeBehavior = useStorage<CloseBehavior>("frpcpilot:close-behavior", "ask")
const closeBehavior = useStorage<CloseBehavior>("frpcpilot:close-behavior", "ask", preferences)
/** 是否正在退出(停止穿透 + 退出软件) */
const quitting = ref(false)
+3
View File
@@ -23,6 +23,7 @@ declare module 'vue' {
AEmpty: typeof import('ant-design-vue/es')['Empty']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AImage: typeof import('ant-design-vue/es')['Image']
AInput: typeof import('ant-design-vue/es')['Input']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
@@ -45,11 +46,13 @@ 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']
ATour: typeof import('ant-design-vue/es')['Tour']
CardRadioGroup: typeof import('./../components/CardRadioGroup.vue')['default']
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']
OnboardingTour: typeof import('./../components/OnboardingTour.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ServerCard: typeof import('./../components/server/ServerCard.vue')['default']
+61
View File
@@ -0,0 +1,61 @@
import { invoke } from "@tauri-apps/api/core"
const desktop = "__TAURI_INTERNALS__" in window
let values: Record<string, string> = {}
let pending: Promise<void> = Promise.resolve()
let saveError: unknown
/** 在加载 Vue 状态仓库前读取安装目录内的设置。 */
export async function initializePreferences() {
if (!desktop) return
const saved = await invoke<Record<string, string> | null>("get_preferences")
if (saved !== null) {
values = saved
} else {
values = {}
await invoke("set_preferences", { preferences: values })
}
}
function persist() {
const snapshot = { ...values }
pending = pending.then(async () => {
try {
await invoke("set_preferences", { preferences: snapshot })
saveError = undefined
} catch (error) {
saveError = error
console.error("Failed to save preferences", error)
const { message } = await import("ant-design-vue")
message.error(`保存设置失败 / Failed to save settings: ${String(error)}`)
}
})
}
/** VueUse 存储适配器,将应用偏好设置保存在 data/preferences.json 中。 */
export const preferences: Storage = {
get length() { return desktop ? Object.keys(values).length : localStorage.length },
key(index) { return desktop ? Object.keys(values)[index] ?? null : localStorage.key(index) },
getItem(key) { return desktop ? values[key] ?? null : localStorage.getItem(key) },
setItem(key, value) {
if (!desktop) return localStorage.setItem(key, value)
if (values[key] === value) return
values[key] = value
persist()
},
removeItem(key) {
if (!desktop) return localStorage.removeItem(key)
delete values[key]
persist()
},
clear() {
if (!desktop) return localStorage.clear()
values = {}
persist()
},
}
export async function flushPreferences() {
await pending
if (saveError) throw saveError
}
+3
View File
@@ -3,6 +3,8 @@
* 在纯浏览器环境(非 Tauri)下调用会被安全忽略,方便 `pnpm dev` 调试。
*/
import { flushPreferences } from "@/utils/preferences"
export function isTauri() {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window
}
@@ -49,6 +51,7 @@ export async function revealWindow() {
/** 退出整个应用(退出前由调用方负责停止穿透等收尾工作) */
export async function exitApp() {
if (!isTauri()) return
await flushPreferences()
const { exit } = await import("@tauri-apps/plugin-process")
await exit(0)
}
+1
View File
@@ -83,6 +83,7 @@
<!-- 快速操作 -->
<div
data-tour="dashboard-start"
class="glass-panel glass-panel-flat flex shrink-0 flex-wrap items-center justify-between gap-3 px-5 py-3"
>
<div class="flex items-center gap-3">
+53 -16
View File
@@ -2,6 +2,7 @@
<div class="flex h-full flex-col gap-3">
<!-- 顶部操作栏(独立磨砂卡片) -->
<div
data-tour="frp-download"
class="glass-panel glass-panel-flat flex shrink-0 flex-wrap items-center justify-between gap-3 px-5 py-3"
>
<div class="flex items-center gap-2">
@@ -10,13 +11,15 @@
</div>
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<label class="flex cursor-pointer items-center gap-2 text-xs">
<a-switch v-model:checked="showAllArchFrpc" size="small" />
<span>{{ t("frp.showAllArchFrpc") }}</span>
</label>
<label class="flex cursor-pointer items-center gap-2 text-xs">
<a-switch v-model:checked="showAllArchFrps" size="small" />
<span>{{ t("frp.showAllArchFrps") }}</span>
<label class="flex items-center gap-2 text-xs">
<span class="shrink-0 opacity-70">{{ t("frp.platformFilter") }}</span>
<a-select
v-model:value="selectedPlatform"
:aria-label="t('frp.platformFilter')"
:options="platformOptions"
class="w-44"
size="small"
/>
</label>
<a-space>
@@ -41,7 +44,6 @@
:key="`${item.version}-${item.platform}-${item.file_name}`"
:release="item"
:progress="progressMap[item.file_name]"
:show-frps="showAllArchFrps"
@download="onDownload(item)"
@copy="onCopy(item)"
@delete="onDelete(item)"
@@ -71,21 +73,46 @@ defineOptions({ name: "Download" })
const loading = ref(false)
const importing = ref(false)
/** 是否展示所有架构的 Frpc */
const showAllArchFrpc = useStorage("frpcpilot:frp:allArchFrpc", false)
/** 是否作为 frp 下载器展示所有架构的 Frps */
const showAllArchFrps = useStorage("frpcpilot:frp:allArchFrps", false)
const ALL_PLATFORMS = "all"
const selectedPlatform = ref(ALL_PLATFORMS)
const platformInitialized = ref(false)
/** 后端返回的全部发行包(含所有架构) */
const allReleases = ref<FrpRelease[]>([])
/**
* 按开关过滤后的展示列表:
* 默认仅展示当前运行平台(以及已下载的记录);开启任一开关后展示所有架构。
* 从发行包数据中生成平台筛选项,首项固定为“全部”。
*/
const platformOptions = computed(() => [
{ value: ALL_PLATFORMS, label: t("frp.allPlatforms") },
...Array.from(new Set(allReleases.value.map((item) => item.platform)))
.sort((a, b) => a.localeCompare(b))
.map((platform) => ({ value: platform, label: platform })),
])
/** 按版本降序排列;同版本按平台稳定排序,保证“全部”视图按版本分组展示。 */
function compareReleases(a: FrpRelease, b: FrpRelease) {
const aVersion = (a.version.match(/\d+/g) ?? []).slice(0, 3).map(Number)
const bVersion = (b.version.match(/\d+/g) ?? []).slice(0, 3).map(Number)
for (let index = 0; index < 3; index += 1) {
const difference = (bVersion[index] ?? 0) - (aVersion[index] ?? 0)
if (difference !== 0) return difference
}
return (
b.published_at.localeCompare(a.published_at) ||
a.platform.localeCompare(b.platform) ||
a.file_name.localeCompare(b.file_name)
)
}
const releases = computed(() => {
if (showAllArchFrpc.value || showAllArchFrps.value) return allReleases.value
return allReleases.value.filter((item) => item.is_current || item.downloaded)
const filtered =
selectedPlatform.value === ALL_PLATFORMS
? allReleases.value
: allReleases.value.filter((item) => item.platform === selectedPlatform.value)
return [...filtered].sort(compareReleases)
})
/** 各发行包的下载进度(以文件名为键) */
@@ -101,6 +128,16 @@ async function refresh(silent = false) {
if (!silent) loading.value = true
try {
allReleases.value = (await getFrpReleases()) ?? []
if (!platformInitialized.value) {
selectedPlatform.value =
allReleases.value.find((item) => item.is_current)?.platform ?? ALL_PLATFORMS
platformInitialized.value = true
} else if (
selectedPlatform.value !== ALL_PLATFORMS &&
!allReleases.value.some((item) => item.platform === selectedPlatform.value)
) {
selectedPlatform.value = ALL_PLATFORMS
}
} catch (e) {
message.error(errMsg(e))
} finally {
+1 -1
View File
@@ -7,7 +7,7 @@
<a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()">
{{ t("server.refresh") }}
</a-button>
<a-button type="primary" :icon="h(PlusOutlined)" @click="openCreate">
<a-button data-tour="server-create" type="primary" :icon="h(PlusOutlined)" @click="openCreate">
{{ t("server.add") }}
</a-button>
</a-space>
+1 -1
View File
@@ -7,7 +7,7 @@
<a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()">
{{ t("tunnel.refresh") }}
</a-button>
<a-button type="primary" :icon="h(PlusOutlined)" @click="openCreate">
<a-button data-tour="tunnel-create" type="primary" :icon="h(PlusOutlined)" @click="openCreate">
{{ t("tunnel.add") }}
</a-button>
</a-space>
+121 -15
View File
@@ -3,6 +3,25 @@
<h2 class="mb-1 text-lg font-semibold">{{ t("about.title") }}</h2>
<p class="mb-6 text-xs opacity-60">{{ t("about.subtitle") }}</p>
<!-- 版本信息 / 检查更新:最常用的信息放在最前面 -->
<section class="mb-6">
<div class="mb-3 text-sm font-medium">{{ t("about.versionTitle") }}</div>
<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-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>
</div>
</section>
<a-divider class="!my-5" />
<!-- 项目介绍 -->
<section class="mb-6">
<div class="mb-2 text-sm font-medium">{{ t("about.introTitle") }}</div>
@@ -42,30 +61,57 @@
<a-divider class="!my-5" />
<!-- 版本信息 / 检查更新 -->
<!-- 联系方式:邮箱可唤起邮件客户端并支持一键复制,微信以二维码图片展示(可点击放大) -->
<section>
<div class="mb-3 text-sm font-medium">{{ t("about.versionTitle") }}</div>
<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-badge :dot="updateStore.hasUpdate">
<a-button size="small" :loading="updateStore.checking" @click="onCheckUpdate">
{{ t("about.checkUpdate") }}
<div class="mb-3 text-sm font-medium">{{ t("about.contactTitle") }}</div>
<a-card class="glass-card" size="small" :body-style="{ padding: '14px 16px' }">
<div class="flex items-center gap-3">
<MailOutlined class="text-2xl" />
<div class="min-w-0 flex-1">
<div class="text-xs opacity-60">{{ t("about.email") }}</div>
<a
class="contact-value"
:href="`mailto:${CONTACT_EMAIL}`"
@click.prevent="onOpenEmail"
>
{{ CONTACT_EMAIL }}
</a>
</div>
<a-button size="small" :icon="h(CopyOutlined)" @click="onCopy(CONTACT_EMAIL)">
{{ t("common.copy") }}
</a-button>
</a-badge>
<span v-if="updateStore.hasUpdate" class="text-xs text-[#6366f1]">
{{ t("about.newVersion", { version: updateStore.info?.version }) }}
</span>
</div>
</div>
<a-divider class="!my-3" />
<div class="flex items-center gap-3">
<WechatOutlined class="text-2xl" />
<div class="min-w-0 flex-1">
<div class="text-xs opacity-60">{{ t("about.wechat") }}</div>
<div class="truncate text-sm font-medium">{{ t("about.wechatTip") }}</div>
</div>
<!-- 微信二维码:点击可用 a-image 内置的预览放大,方便用手机扫码 -->
<a-image :src="wechatQr" :width="100" class="wechat-qr" />
</div>
</a-card>
<p class="mt-2 text-xs opacity-60">{{ t("about.contactDesc") }}</p>
</section>
</div>
</template>
<script setup lang="ts">
import { h } from "vue"
import { GithubOutlined, LinkOutlined } from "@ant-design/icons-vue"
import { message } from "ant-design-vue"
import {
CopyOutlined,
GithubOutlined,
LinkOutlined,
MailOutlined,
WechatOutlined,
} from "@ant-design/icons-vue"
import { useI18n } from "vue-i18n"
import { getAppVersion, openProjectRepo, PROJECT_GITHUB_URL } from "@/api/updater"
import wechatQr from "@/assets/images/wx.webp"
import { getAppVersion, openExternal, openProjectRepo, PROJECT_GITHUB_URL } from "@/api/updater"
import { useUpdateStore } from "@/stores/update"
const { t } = useI18n()
@@ -75,6 +121,9 @@ const version = ref("")
const stack = ["Tauri 2", "Vue 3", "TypeScript", "Rust", "Vite"]
/** 作者邮箱:展示、复制、唤起邮件客户端共用同一份常量 */
const CONTACT_EMAIL = "wu434425608@163.com"
const flowItems = computed(() => [
{ title: t("about.flow.step1Title"), description: t("about.flow.step1Desc") },
{ title: t("about.flow.step2Title"), description: t("about.flow.step2Desc") },
@@ -93,4 +142,61 @@ function onOpenRepo() {
function onCheckUpdate() {
void updateStore.checkForUpdate(false)
}
/** 复制到剪贴板,失败时提示手动复制 */
async function onCopy(text: string) {
try {
await navigator.clipboard.writeText(text)
message.success(t("common.copySuccess"))
} catch {
message.error(t("common.copyFailed"))
}
}
/** 用系统默认邮件客户端写信;没有可用客户端时退化为复制邮箱 */
async function onOpenEmail() {
try {
await openExternal(`mailto:${CONTACT_EMAIL}`)
} catch {
await onCopy(CONTACT_EMAIL)
}
}
</script>
<style scoped>
/* 邮箱可点击:悬停时高亮,提示可以唤起邮件客户端 */
.contact-value {
display: block;
overflow: hidden;
font-size: 13px;
font-weight: 500;
line-height: 1.5;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.2s ease;
}
.contact-value:hover {
color: #6366f1;
text-decoration: underline;
}
/* 微信二维码:白底圆角,深色模式下也保持足够对比度,便于手机扫描 */
.wechat-qr {
flex-shrink: 0;
padding: 4px;
cursor: zoom-in;
background-color: #fff;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 8px;
}
.wechat-qr :deep(img) {
display: block;
border-radius: 4px;
}
.dark .wechat-qr {
border-color: rgba(255, 255, 255, 0.16);
}
</style>
+149 -16
View File
@@ -6,12 +6,28 @@
<!-- 主题模式 -->
<section class="mb-6">
<div class="mb-2 text-sm font-medium">{{ t("settings.appearance.themeMode") }}</div>
<a-segmented v-model:value="themeMode" :options="themeOptions" block />
<a-segmented v-model:value="appStore.themeMode" :options="themeOptions" block />
<p class="mt-2 text-xs opacity-60">{{ t("settings.appearance.themeModeTip") }}</p>
</section>
<a-divider />
<section class="mb-6">
<div class="mb-1 text-sm font-medium">{{ t("settings.appearance.backgroundOverlay") }}</div>
<p class="mb-3 text-xs opacity-60">{{ t("settings.appearance.backgroundOverlayTip") }}</p>
<div class="mb-1 flex items-center justify-between text-xs">
<span>{{ t("settings.appearance.overlayTransparency") }}</span>
<span class="font-medium tabular-nums">{{ appStore.backgroundOverlayTransparency }}%</span>
</div>
<a-slider v-model:value="appStore.backgroundOverlayTransparency" :min="0" :max="100" />
<div class="overlay-preview" :style="{ '--preview-overlay-alpha': `${(100 - appStore.backgroundOverlayTransparency) / 100}` }" aria-hidden="true">
<span>{{ t("settings.appearance.backgroundOverlayPreview") }}</span>
<div class="overlay-preview-toolbar"><i /><i /><i /></div>
</div>
</section>
<a-divider />
<!-- 背景图片 -->
<section>
<div class="mb-1 text-sm font-medium">{{ t("settings.appearance.background") }}</div>
@@ -31,6 +47,44 @@
<CheckCircleFilled v-if="appStore.background === bg.key" class="shrink-0 text-[#6366f1]" />
</div>
</a-card>
<a-card
hoverable
size="small"
class="bg-card overflow-hidden"
:class="{ 'bg-card--active': appStore.background === 'custom' }"
:body-style="{ padding: '8px 10px' }"
@click="selectCustomBackground"
>
<template #cover>
<div
v-if="appStore.customBackgroundUrl"
class="bg-thumb"
:style="{ backgroundImage: `url('${appStore.customBackgroundUrl}')` }"
/>
<div v-else class="bg-thumb bg-thumb--custom-empty">
<a-button type="link" :icon="h(PictureOutlined)" @click.stop="chooseCustomBackground">
{{ t("settings.appearance.chooseCustomBackground") }}
</a-button>
</div>
</template>
<div class="flex items-center justify-between gap-1 text-xs">
<span class="truncate">{{ t("settings.appearance.customBackground") }}</span>
<a-space :size="2">
<a-tooltip :title="t('settings.appearance.changeCustomBackground')">
<a-button
type="text"
size="small"
:icon="h(UploadOutlined)"
:aria-label="t('settings.appearance.changeCustomBackground')"
@click.stop="chooseCustomBackground"
/>
</a-tooltip>
<CheckCircleFilled v-if="appStore.background === 'custom'" class="shrink-0 text-[#6366f1]" />
</a-space>
</div>
</a-card>
</div>
</section>
@@ -69,7 +123,8 @@
<script setup lang="ts">
import { h } from "vue"
import { BulbFilled, BulbOutlined, CheckCircleFilled } from "@ant-design/icons-vue"
import { BulbFilled, BulbOutlined, CheckCircleFilled, DesktopOutlined, PictureOutlined, UploadOutlined } from "@ant-design/icons-vue"
import { message } from "ant-design-vue"
import { useI18n } from "vue-i18n"
import { backgroundOptions, type BackgroundKey } from "@/config/backgrounds"
import { useAppStore } from "@/stores/app"
@@ -77,14 +132,6 @@ import { useAppStore } from "@/stores/app"
const { t } = useI18n()
const appStore = useAppStore()
/** 主题模式:与 store 中的 isDark 双向绑定 */
const themeMode = computed({
get: () => (appStore.isDark ? "dark" : "light"),
set: (value: string | number) => {
appStore.isDark = value === "dark"
},
})
const themeOptions = computed(() => [
{
value: "light",
@@ -100,11 +147,34 @@ const themeOptions = computed(() => [
t("settings.appearance.dark"),
]),
},
{
value: "system",
label: h("span", { class: "inline-flex items-center gap-2" }, [
h(DesktopOutlined),
t("settings.appearance.system"),
]),
},
])
function selectBackground(key: BackgroundKey) {
appStore.setBackground(key)
}
async function selectCustomBackground() {
if (appStore.customBackgroundUrl) {
appStore.setBackground("custom")
return
}
await chooseCustomBackground()
}
async function chooseCustomBackground() {
try {
await appStore.chooseCustomBackground()
} catch (error) {
message.error(error instanceof Error ? error.message : String(error))
}
}
</script>
<style scoped lang="scss">
@@ -132,23 +202,86 @@ function selectBackground(key: BackgroundKey) {
}
.bg-thumb--none {
background-image: repeating-linear-gradient(45deg,
rgba(148, 163, 184, 0.25) 0 8px,
transparent 8px 16px);
background-color: #e9edef;
}
/* 透明度实时预览:纯色底图(暖调中性色,不用渐变)+ 卡片 / 弹窗两种磨砂效果 */
.bg-thumb--custom-empty {
display: flex;
align-items: center;
justify-content: center;
background: #dfe5e4;
}
.dark .bg-thumb--custom-empty {
background: #252f30;
}
.dark .bg-thumb--none {
background-color: #151c1d;
}
.overlay-preview {
position: relative;
display: flex;
height: 88px;
align-items: flex-end;
justify-content: space-between;
overflow: hidden;
padding: 12px;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 8px;
background-color: #71817b;
color: #14201d;
font-size: 11px;
}
.overlay-preview::before {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, var(--preview-overlay-alpha));
content: "";
pointer-events: none;
}
.overlay-preview > * {
position: relative;
}
.dark .overlay-preview {
border-color: rgba(255, 255, 255, 0.12);
background-color: #71817b;
color: #f1f5f3;
}
.dark .overlay-preview::before {
background: rgba(0, 0, 0, var(--preview-overlay-alpha));
}
.overlay-preview-toolbar {
display: flex;
gap: 5px;
}
.overlay-preview-toolbar i {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
opacity: 0.7;
}
/* 透明度实时预览:中性纯色底图 + 卡片 / 弹窗两种磨砂效果 */
.glass-preview {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border-radius: 12px;
background-color: #a09adf;
background-color: #778880;
}
.dark .glass-preview {
background-color: #a09adf;
background-color: #778880;
}
.glass-preview-card,
+82 -19
View File
@@ -1,33 +1,66 @@
<template>
<div class="max-w-2xl">
<h2 class="mb-1 text-lg font-semibold">{{ t("settings.general.title") }}</h2>
<p class="mb-6 text-xs opacity-60">{{ t("settings.general.subtitle") }}</p>
<p class="mb-5 text-xs opacity-60">{{ t("settings.general.subtitle") }}</p>
<!-- 重新开始引导:与引导弹窗一致,可在此再次触发 -->
<section class="flex items-center justify-between gap-4">
<div>
<div class="text-sm font-medium">{{ t('onboarding.title') }}</div>
<p class="mt-1 text-xs opacity-60">{{ t('onboarding.replayDescription') }}</p>
</div>
<a-button class="shrink-0" @click="onboarding.start">{{ t('onboarding.replay') }}</a-button>
</section>
<a-divider class="general-divider" />
<!-- 语言:与顶部栏语言切换共用同一状态 -->
<section class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium">{{ t("settings.general.language") }}</div>
<p class="mt-1 text-xs opacity-60">{{ t("settings.general.languageTip") }}</p>
</div>
<a-select
v-model:value="appStore.language"
:options="languageSelectOptions"
:aria-label="t('settings.general.language')"
class="w-36 shrink-0"
/>
</section>
<a-divider class="general-divider" />
<!-- 开机自启:开关直接写入系统启动项,状态由系统决定 -->
<section>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium">{{ t("settings.general.autostart") }}</div>
<p class="mt-1 text-xs opacity-60">{{ autostartTip }}</p>
</div>
<a-switch
class="shrink-0"
:checked="autostartStore.enabled"
:loading="autostartStore.loading"
:disabled="!autostartStore.supported"
@change="onAutostartChange"
/>
</div>
</section>
<a-divider class="general-divider" />
<!-- 关闭行为:与关闭弹窗内容保持一致,可在此再次修改 -->
<section class="mb-6">
<section>
<div class="mb-1 text-sm font-medium">{{ t("settings.general.closeBehavior") }}</div>
<p class="mb-3 text-xs opacity-60">{{ t("settings.general.closeBehaviorTip") }}</p>
<p class="mb-2 text-xs opacity-60">{{ t("settings.general.closeBehaviorTip") }}</p>
<CardRadioGroup v-model="windowStore.closeBehavior" :options="behaviorOptions" />
<!-- 关闭方式:与关闭弹窗一致,可随时修改;选择「保持以上选择」后关闭时直接生效 -->
<div class="mt-4 mb-1 text-sm font-medium">{{ t("settings.general.closeAction") }}</div>
<p class="mb-3 text-xs opacity-60">{{ t("settings.general.closeActionTip") }}</p>
<div class="mt-3 mb-1 text-sm font-medium">{{ t("settings.general.closeAction") }}</div>
<p class="mb-2 text-xs opacity-60">{{ t("settings.general.closeActionTip") }}</p>
<CardRadioGroup v-model="windowStore.closeChoice" :options="choiceOptions" />
</section>
<a-divider />
<!-- 语言:与顶部栏语言切换共用同一状态 -->
<section>
<div class="mb-1 text-sm font-medium">{{ t("settings.general.language") }}</div>
<p class="mb-3 text-xs opacity-60">{{ t("settings.general.languageTip") }}</p>
<a-radio-group
v-model:value="appStore.language"
:options="languageRadios"
option-type="button"
button-style="solid"
/>
</section>
</div>
</template>
@@ -35,11 +68,15 @@
import { useI18n } from "vue-i18n"
import { languageOptions } from "@/i18n"
import { useAppStore } from "@/stores/app"
import { useAutostartStore } from "@/stores/autostart"
import { useWindowStore } from "@/stores/window"
import { useOnboardingStore } from "@/stores/onboarding"
const { t } = useI18n()
const appStore = useAppStore()
const autostartStore = useAutostartStore()
const windowStore = useWindowStore()
const onboarding = useOnboardingStore()
const behaviorOptions = computed(() => [
{ value: "ask" as const, label: t("closeDialog.ask"), description: t("closeDialog.askDesc") },
@@ -56,5 +93,31 @@ const choiceOptions = computed(() => [
])
/** 语言选项与顶部栏共用 languageOptions,保证两端一致 */
const languageRadios = languageOptions.map((item) => ({ label: item.label, value: item.key }))
const languageSelectOptions = languageOptions.map((item) => ({ label: item.label, value: item.key }))
/** 无法访问系统启动项时(非桌面环境)给出说明,避免开关无故置灰 */
const autostartTip = computed(() =>
autostartStore.requiresRelease
? t("settings.general.autostartRequiresRelease")
: autostartStore.supported
? t("settings.general.autostartTip")
: t("settings.general.autostartUnsupported"),
)
/** 切换开机自启:失败时由 store 回滚开关状态并提示原因 */
async function onAutostartChange(checked: boolean | string | number) {
await autostartStore.toggle(Boolean(checked))
}
// 每次进入设置页都读一次系统启动项,用户可能在系统设置里改过
onMounted(() => {
void autostartStore.refresh()
})
</script>
<style scoped>
/* 各组设置之间留白收紧,避免分隔线两侧过于松散 */
.general-divider {
margin: 16px 0;
}
</style>