Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8185275937 | ||
|
|
827075bdef | ||
|
|
4adde51bef | ||
|
|
cbd5096595 |
@@ -9,6 +9,11 @@ on:
|
||||
permissions:
|
||||
contents: write # 需要写入权限来创建 Release
|
||||
|
||||
env:
|
||||
# 更新产物镜像前缀:latest.json 里的 GitHub 下载直链会被改写为 <前缀> + 原始直链。
|
||||
# 更换镜像时,请同步修改 src-tauri/tauri.conf.json 中 updater.endpoints 的镜像地址。
|
||||
MIRROR_PREFIX: "https://ghproxy.net/"
|
||||
|
||||
jobs:
|
||||
publish-tauri:
|
||||
strategy:
|
||||
@@ -132,3 +137,45 @@ jobs:
|
||||
releaseDraft: true # 先创建草稿,手动确认后再发布
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
# 所有平台构建完成后,把 latest.json 里的下载直链改写为镜像前缀,
|
||||
# 让「下载安装包」这一步也走镜像加速(此前只有检查更新走镜像)。
|
||||
mirror-updater-manifest:
|
||||
needs: publish-tauri
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Rewrite latest.json download URLs to mirror
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="v$(node -p "require('./package.json').version")"
|
||||
echo "Release tag: $TAG"
|
||||
|
||||
mkdir -p updater
|
||||
# 从草稿 Release 中取回 tauri-action 生成的更新清单
|
||||
gh release download "$TAG" --pattern latest.json --dir updater --clobber
|
||||
|
||||
node <<'NODE'
|
||||
const fs = require("fs");
|
||||
const file = "updater/latest.json";
|
||||
const prefix = process.env.MIRROR_PREFIX;
|
||||
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
let count = 0;
|
||||
for (const info of Object.values(data.platforms || {})) {
|
||||
if (info && typeof info.url === "string" && info.url.startsWith("https://github.com/")) {
|
||||
info.url = prefix + info.url;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
|
||||
console.log(`Rewrote ${count} download URL(s) with mirror prefix: ${prefix}`);
|
||||
NODE
|
||||
|
||||
# 覆盖上传(签名针对安装包字节校验,与 URL 无关,改地址不影响更新校验)
|
||||
gh release upload "$TAG" updater/latest.json --clobber
|
||||
|
||||
@@ -1,56 +1,107 @@
|
||||
# Frpilot
|
||||
|
||||
> 一个基于 **Tauri 2 + Vue 3 + TypeScript** 的 [frp](https://github.com/fatedier/frp) 可视化客户端,帮助用户快速配置 frpc 版本、服务器与隧道。
|
||||
> 一个基于 **Tauri 2 + Vue 3 + TypeScript** 的 [frp](https://github.com/fatedier/frp) 可视化客户端,帮你用图形界面管理 frpc 版本、服务器与隧道,从此告别手写 `frpc.toml`。
|
||||
|
||||
Frpilot 目前处于早期开发阶段,已完成应用骨架(自定义标题栏、多语言、明暗主题、磨砂玻璃布局)。
|
||||
Frpilot 已从「应用骨架」成长为可用的一体化客户端:下载 frpc、配置服务器、编排隧道、一键启动并实时查看日志,全部在同一个窗口里完成,并内置应用内自动更新。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能特性
|
||||
## ✨ 已实现功能
|
||||
|
||||
### 已实现
|
||||
### Frpc 版本管理
|
||||
|
||||
- **i18n 国际化**:内置简体中文 / English,语言选择持久化,Ant Design Vue 组件库语言包联动
|
||||
- **自定义标题栏**:按操作系统自动切换
|
||||
- Windows:左侧 `Frpilot` 标题,右侧「语言 / 明暗 / 设置 / 最小化 / 关闭」
|
||||
- macOS:左侧红绿灯(关闭 / 最小化 / 最大化),右侧同上一组按钮
|
||||
- **明暗主题**:基于 `@vueuse/core` 的 `useDark`,Tailwind `dark:` 变体 + Ant Design Vue 暗色算法联动,选择持久化
|
||||
- **悬浮磨砂玻璃布局**:侧边栏(Aside)与内容区(Content)均为圆角 + `backdrop-filter` 毛玻璃面板
|
||||
- **侧边栏菜单**:仪表盘 / 服务器 / 隧道列表 / Frpc 下载
|
||||
- **底部系统信息**:CPU 占用、内存占用(百分比 + 进度条)、运行状态指示点
|
||||
- 🟢 运行正常 · 🔴 运行异常 · 🟡 未启动
|
||||
- 在线拉取 Frp 官方版本列表,支持「所有架构 frpc / frps」开关,按平台与架构筛选
|
||||
- 一键下载并自动解压,实时显示下载 / 解压进度与状态
|
||||
- 导入本地已有的 `frpc` / `frps`
|
||||
- 删除已下载版本、复制下载直链
|
||||
|
||||
### 规划中
|
||||
### 服务器管理
|
||||
|
||||
- frpc 版本管理与下载
|
||||
- 服务器(frps)连接配置管理
|
||||
- 隧道配置的增删改查与启停
|
||||
- 配置文件的导入 / 导出与校验
|
||||
- frps 服务器的新增 / 编辑 / 删除
|
||||
- 服务器地址、端口、鉴权 Token、传输协议、TLS 开关、备注
|
||||
- 置顶与排序;启用开关(**同一时间仅允许启用一台**服务器)
|
||||
|
||||
### 隧道管理
|
||||
|
||||
- 覆盖 **TCP / UDP / HTTP / HTTPS / STCP / XTCP / SUDP / TCPMUX** 等代理类型
|
||||
- 本地 IP / 端口、远程端口、自定义域名、子域名、URL 前缀
|
||||
- 高级配置:密钥、加密、压缩、带宽限制、Proxy Protocol、健康检查、负载均衡
|
||||
- 启用 / 置顶 / 排序
|
||||
|
||||
### 一键启动与实时日志
|
||||
|
||||
- 仪表盘选择 frpc 版本后「一键启动 / 停止」
|
||||
- 自动依据所选版本、已启用的服务器与隧道生成 `frpc.toml` 并拉起独立进程
|
||||
- 通过 `frpc-log` / `frpc-exit` 事件实时回显运行日志与进程退出信息
|
||||
|
||||
### 仪表盘
|
||||
|
||||
- 系统信息:主机名 / 系统 / 架构 / IP 地址
|
||||
- 服务器与隧道数量统计
|
||||
- 侧边栏常驻 CPU 占用、内存占用与运行状态指示点(🟢 运行正常 · 🔴 运行异常 · 🟡 未启动)
|
||||
|
||||
### 应用内自动更新
|
||||
|
||||
- 启动时静默检查,支持手动检查更新
|
||||
- 更新弹窗以 **Markdown** 渲染更新说明,展示下载进度
|
||||
- 支持「忽略该版本」、一键更新,安装完成后自动重启
|
||||
|
||||
### 关于页面
|
||||
|
||||
- 项目介绍、技术栈标签、使用流程指引
|
||||
- 开源地址一键跳转、当前版本信息与「检查更新」入口
|
||||
|
||||
### 界面与体验
|
||||
|
||||
- **自定义标题栏**:Windows / macOS 双套方案,窗口可拖拽移动;顶部栏集成语言 / 主题 / 检查更新 / GitHub / 设置与窗口控制按钮
|
||||
- **macOS 特化**:还原原生红绿灯,隐藏冗余的最小化 / 关闭按钮,禁用 macOS 下不需要的最大化
|
||||
- **明暗主题**:跟随切换,Tailwind 暗色变体与 Ant Design Vue 暗色算法同步,选择持久化
|
||||
- **悬浮磨砂玻璃**:侧边栏、内容区与卡片统一为圆角 + `backdrop-filter` 半透明面板
|
||||
- **国际化**:内置简体中文 / English,语言选择持久化,Ant Design Vue 组件语言包联动
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ 界面预览
|
||||
|
||||
| 仪表盘 | 服务器 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
| 隧道列表 | Frpc 下载 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
| 设置 · 外观 | 设置 · 关于 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
## 🧱 技术栈
|
||||
|
||||
| 分类 | 选型 |
|
||||
| ---------- | ---------------------------------------------------------------- |
|
||||
| 桌面框架 | [Tauri 2](https://v2.tauri.app/)(Rust 后端) |
|
||||
| 前端框架 | Vue 3(`<script setup>`)+ TypeScript |
|
||||
| 构建工具 | Vite 8 |
|
||||
| UI 组件库 | Ant Design Vue 4 + `@ant-design/icons-vue` |
|
||||
| 样式 | Tailwind CSS 4(原子化)+ SCSS |
|
||||
| 状态管理 | Pinia |
|
||||
| 路由 | Vue Router |
|
||||
| 国际化 | Vue I18n 11 |
|
||||
| 工具库 | `@vueuse/core` |
|
||||
| 分类 | 选型 |
|
||||
| ---------- | --------------------------------------------------------------------- |
|
||||
| 桌面框架 | [Tauri 2](https://v2.tauri.app/)(Rust 后端) |
|
||||
| 前端框架 | Vue 3(`<script setup>`)+ TypeScript |
|
||||
| 构建工具 | Vite 8 |
|
||||
| UI 组件库 | Ant Design Vue 4 + `@ant-design/icons-vue` |
|
||||
| 样式 | Tailwind CSS 4(原子化)+ SCSS |
|
||||
| 状态管理 | Pinia |
|
||||
| 路由 | Vue Router |
|
||||
| 国际化 | Vue I18n 11 |
|
||||
| Markdown | `markdown-it`(更新日志渲染) |
|
||||
| 工具库 | `@vueuse/core` |
|
||||
| 后端 | Rust:`sqlx` + SQLite、`reqwest`(下载)、`sysinfo`(系统采集) |
|
||||
| 插件 | `updater` / `process` / `dialog` / `opener` / `store` / `prevent-default` |
|
||||
| 自动化 | `unplugin-auto-import`、`unplugin-vue-components`(Antd 按需自动导入) |
|
||||
|
||||
---
|
||||
|
||||
## 📦 环境要求
|
||||
|
||||
- **Node.js** ≥ 18(推荐 20 / 22 LTS)
|
||||
- **Node.js** ≥ 20(推荐 20 / 22 LTS)
|
||||
- **pnpm** ≥ 9(项目使用 pnpm,含 `pnpm-lock.yaml`)
|
||||
- **Rust** 稳定版工具链(`rustup`)
|
||||
- **Rust** 稳定版工具链(`rustup`,建议 ≥ 1.90)
|
||||
- **系统依赖**:请参考 [Tauri 前置依赖](https://v2.tauri.app/start/prerequisites/)(Windows 需 WebView2 与 MSVC 构建工具;macOS 需 Xcode Command Line Tools;Linux 需 `webkit2gtk` 等)
|
||||
|
||||
---
|
||||
@@ -61,7 +112,7 @@ Frpilot 目前处于早期开发阶段,已完成应用骨架(自定义标题
|
||||
# 1. 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 2. 仅启动前端(浏览器调试,窗口控制类操作会安全空转)
|
||||
# 2. 仅启动前端(浏览器调试,窗口 / 命令类操作会安全空转)
|
||||
pnpm dev
|
||||
|
||||
# 3. 启动完整桌面应用(推荐,含 Rust 后端)
|
||||
@@ -70,12 +121,12 @@ pnpm tauri dev
|
||||
|
||||
### 常用脚本
|
||||
|
||||
| 命令 | 说明 |
|
||||
| ---------------- | -------------------------------------------------------- |
|
||||
| `pnpm dev` | 启动 Vite 开发服务器(端口 `1420`) |
|
||||
| `pnpm tauri dev` | 启动 Tauri 桌面开发环境(自动拉起前端) |
|
||||
| `pnpm build` | 类型检查(`vue-tsc --noEmit`)+ 构建前端产物到 `dist/` |
|
||||
| `pnpm preview` | 预览前端构建产物 |
|
||||
| 命令 | 说明 |
|
||||
| ------------------ | ------------------------------------------------------ |
|
||||
| `pnpm dev` | 启动 Vite 开发服务器(端口 `1420`) |
|
||||
| `pnpm tauri dev` | 启动 Tauri 桌面开发环境(自动拉起前端) |
|
||||
| `pnpm build` | 类型检查(`vue-tsc --noEmit`)+ 构建前端产物到 `dist/` |
|
||||
| `pnpm preview` | 预览前端构建产物 |
|
||||
| `pnpm tauri build` | 打包生成各平台安装包 |
|
||||
|
||||
> ⚠️ `pnpm dev` 的端口固定为 `1420` 且 `strictPort: true`,占用时会直接报错退出。
|
||||
@@ -86,40 +137,39 @@ pnpm tauri dev
|
||||
|
||||
```
|
||||
Frpilot/
|
||||
├── src/
|
||||
│ ├── api/ # 接口封装(预留)
|
||||
│ ├── assets/
|
||||
│ │ └── main.css # Tailwind 入口 + 全局样式(.glass-panel / dark 变体)
|
||||
│ ├── components/ # 通用组件
|
||||
│ │ ├── TitleBarActions.vue # 标题栏右侧按钮组(语言/主题/设置/最小化/关闭)
|
||||
│ │ ├── WindowsTitleBar.vue # Windows 自定义标题栏
|
||||
│ │ └── MacTitleBar.vue # macOS 自定义标题栏(红绿灯)
|
||||
│ ├── composables/
|
||||
│ │ ├── usePlatform.ts # 操作系统类型判断
|
||||
│ │ └── useSystemStats.ts # CPU / 内存占用采集
|
||||
│ ├── i18n/
|
||||
│ │ ├── index.ts # createI18n 初始化
|
||||
│ │ └── locales/
|
||||
│ │ ├── zh-CN.json # 中文语言包
|
||||
│ │ └── en-US.json # 英文语言包
|
||||
│ ├── layout/
|
||||
│ │ ├── Layout.vue # 整体布局(背景 + Header + Aside + Content)
|
||||
│ │ ├── aside/Aside.vue # 侧边栏(菜单 + 系统信息)
|
||||
│ │ └── header/Header.vue # 顶部栏(按系统加载 TitleBar)
|
||||
│ ├── router/router.ts # 路由表
|
||||
│ ├── stores/app.ts # 全局状态:语言 / 主题 / 运行状态
|
||||
│ ├── types/ # 自动生成的类型声明
|
||||
│ ├── utils/window.ts # Tauri 窗口控制封装
|
||||
│ ├── views/ # 页面(仪表盘 / 服务器 / 隧道 / 下载)
|
||||
│ ├── App.vue # ConfigProvider(语言 + 主题)
|
||||
│ └── main.ts # 应用入口
|
||||
├── src-tauri/ # Rust 后端
|
||||
│ ├── src/lib.rs # 命令注册入口
|
||||
│ ├── capabilities/default.json# 权限声明(窗口控制等)
|
||||
│ └── tauri.conf.json # Tauri 配置(无边框窗口 1100×720)
|
||||
├── src/ # 前端(Vue 3 + TypeScript)
|
||||
│ ├── api/ # Tauri 命令封装(frp / server / tunnel / system / updater)
|
||||
│ ├── assets/main.css # Tailwind 入口 + 全局样式(磨砂玻璃 / Markdown / 弹窗)
|
||||
│ ├── components/ # 通用组件
|
||||
│ │ ├── frp/FrpCard.vue
|
||||
│ │ ├── server/ServerCard.vue · ServerFormModal.vue
|
||||
│ │ ├── tunnel/TunnelCard.vue · TunnelFormModal.vue
|
||||
│ │ ├── WindowsTitleBar.vue · MacTitleBar.vue · TitleBarActions.vue
|
||||
│ │ ├── UpdateModal.vue # 更新弹窗(Markdown 更新日志 + 进度)
|
||||
│ │ └── MarkdownBlock.vue # Markdown 渲染
|
||||
│ ├── composables/ # usePlatform · useSystemStats
|
||||
│ ├── config/backgrounds.ts # 内置窗口背景图
|
||||
│ ├── i18n/ # 国际化(zh-CN / en-US)
|
||||
│ ├── layout/ # 主布局 + 标题栏 + 侧边栏
|
||||
│ ├── router/router.ts # 路由表
|
||||
│ ├── stores/ # app · frpc · update(Pinia)
|
||||
│ ├── types/ # 自动生成的类型声明与领域类型
|
||||
│ ├── utils/ # window(窗口控制)· markdown(渲染)
|
||||
│ └── views/ # 页面(仪表盘 / 服务器 / 隧道 / 下载 / 设置)
|
||||
├── src-tauri/ # 后端(Rust)
|
||||
│ ├── src/
|
||||
│ │ ├── commands/ # Tauri 命令(frp / runner / server / system / tunnel)
|
||||
│ │ ├── services/ # 数据库 / frpc.toml 生成 / 进程管理 / 系统采集
|
||||
│ │ ├── models.rs # ApiResponse / PageResult 等通用模型
|
||||
│ │ └── lib.rs # 应用入口与命令注册
|
||||
│ ├── capabilities/default.json # 权限声明(窗口控制 / updater / process 等)
|
||||
│ └── tauri.conf.json # Tauri 配置(无边框窗口 1100×720)
|
||||
├── docs/
|
||||
│ ├── CHANGELOG.md # 版本更新日志(发布流程会读取)
|
||||
│ └── snipastes/ # 界面截图
|
||||
├── .github/workflows/release.yml # 多平台发布工作流
|
||||
├── index.html
|
||||
├── vite.config.ts # 别名 / 自动导入 / Tailwind 插件
|
||||
├── vite.config.ts # 别名 / 自动导入 / Tailwind 插件
|
||||
└── package.json
|
||||
```
|
||||
|
||||
@@ -158,7 +208,7 @@ const { t } = useI18n()
|
||||
### 新增侧边栏菜单项与页面
|
||||
|
||||
1. 在 `src/views/` 新建页面组件。
|
||||
2. 在 `src/router/router.ts` 的 `Layout` 子路由中注册(路径需以 `/` 开头,与菜单 `key` 一致)。
|
||||
2. 在 `src/router/router.ts` 的 `Layout` 子路由中注册(路径与菜单 `key` 一致)。
|
||||
3. 在 `src/layout/aside/Aside.vue` 的 `menuItems` 中追加一项(`key` = 路由路径)。
|
||||
4. 在语言包中补充对应 `menu.*` 文案。
|
||||
|
||||
@@ -168,48 +218,41 @@ const { t } = useI18n()
|
||||
- Tailwind 使用 class 策略:在 `src/assets/main.css` 中已声明 `@custom-variant dark`。
|
||||
- Ant Design Vue 主题在 `src/App.vue` 中通过 `a-config-provider` 的 `theme.algorithm` 联动。
|
||||
|
||||
### 玻璃拟态样式
|
||||
### 磨砂玻璃样式
|
||||
|
||||
`src/assets/main.css` 中的 `.glass-panel` 为通用毛玻璃面板,直接给元素加 `class="glass-panel"` 即可(已含暗色适配)。
|
||||
`src/assets/main.css` 中的 `.glass-panel`(面板)与 `.glass-card`(卡片)为通用毛玻璃样式,直接加类名即可(已含暗色适配)。
|
||||
|
||||
### 接入真实的 CPU / 内存占用
|
||||
### Markdown 渲染
|
||||
|
||||
`src/composables/useSystemStats.ts` 会优先调用 Tauri 命令 `get_system_info`,在命令不存在时回退到**模拟数据**。接入真实数据只需在 Rust 端实现该命令:
|
||||
|
||||
```rust
|
||||
// src-tauri/src/lib.rs
|
||||
#[tauri::command]
|
||||
fn get_system_info() -> serde_json::Value {
|
||||
// 返回 { "cpu": f64, "memory": f64 },单位为百分比
|
||||
serde_json::json!({ "cpu": 12.0, "memory": 45.0 })
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![get_system_info])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
```
|
||||
|
||||
前端会自动切换为真实数据(`isMock` 会变为 `false`)。
|
||||
|
||||
### 运行状态指示点
|
||||
|
||||
运行状态由 `src/stores/app.ts` 的 `runStatus` 控制,取值:
|
||||
|
||||
```ts
|
||||
type RunStatus = 'running' | 'error' | 'stopped' // 🟢 | 🔴 | 🟡
|
||||
```
|
||||
|
||||
后续接入 frpc 进程状态时,更新该字段即可。
|
||||
更新日志、说明类富文本统一使用 `src/components/MarkdownBlock.vue`(内部调用 `src/utils/markdown.ts` 的 `renderMarkdown`)。渲染关闭了原始 HTML,并把链接点击接管到系统浏览器打开。
|
||||
|
||||
### 窗口控制与权限
|
||||
|
||||
- 前端封装:`src/utils/window.ts`(`minimizeWindow` / `toggleMaximizeWindow` / `closeWindow` / `startDragging`),在非 Tauri 环境(浏览器)下自动空转。
|
||||
- 前端封装:`src/utils/window.ts`(`minimizeWindow` / `toggleMaximizeWindow` / `closeWindow` / `startDragging` 等),在非 Tauri 环境(浏览器)下自动空转。
|
||||
- 自定义标题栏依赖无边框窗口(`src-tauri/tauri.conf.json` 中 `decorations: false`),拖拽区域通过 `data-tauri-drag-region` 实现。
|
||||
- 相关权限在 `src-tauri/capabilities/default.json` 中声明(`core:window:allow-minimize`、`allow-close`、`allow-toggle-maximize`、`allow-start-dragging` 等)。**新增窗口 API 调用时需同步补充对应权限**,否则会报 `not allowed` 错误。
|
||||
- **窗口圆角(8px)**:Windows 11 由系统 DWM 自动裁切;macOS 的无边框窗口系统不会裁圆角,因此:
|
||||
- `src-tauri/tauri.macos.conf.json` 中把窗口设为 `transparent: true`(**Tauri 2.12.1+ 无需再开启 `macOSPrivateApi`**);
|
||||
- `src/assets/main.css` 中在 `html.is-macos` 下把背景从 `<html>` 移到 `#app`,由 `#app` 裁出 8px 圆角,并让 `html`/`body` 透明以透出桌面;
|
||||
- `src/main.ts` 在挂载前根据 UA 给 `<html>` 打上 `is-macos` 标记。
|
||||
- ⚠️ 平台配置文件按 **JSON Merge Patch** 合并,且**数组整体替换**,因此 `tauri.macos.conf.json` 里必须完整重复 `app.windows` 的字段;**修改基础配置的窗口参数时,记得同步这份文件**。
|
||||
- 相关权限在 `src-tauri/capabilities/default.json` 中声明(`core:window:*`、`updater:default`、`process:default` 等)。**新增窗口 / 插件 API 调用时需同步补充对应权限**,否则会报 `not allowed` 错误。
|
||||
|
||||
### 发布与自动更新
|
||||
|
||||
- 版本号取自 `package.json` 的 `version`(`src-tauri/tauri.conf.json` 引用之),`src-tauri/Cargo.toml` 保持一致。
|
||||
- 推送 `v*` tag 会触发 `.github/workflows/release.yml`:构建 **Windows x64 / macOS Apple Silicon / Linux x64** 产物,签名生成更新产物与 `latest.json`,并自动从 `docs/CHANGELOG.md` 提取对应版本说明作为 Release 正文。
|
||||
- 发布前请在 `docs/CHANGELOG.md` 中补上 `## v<版本号>` 段落。
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ 路线图
|
||||
|
||||
- frpc 配置文件的导入 / 导出与校验
|
||||
- frps 服务端信息的集中管理与状态检测
|
||||
- 日志持久化与导出
|
||||
- 系统托盘 / 开机自启 / 关闭到托盘
|
||||
- 主题跟随系统
|
||||
- 更多语言与自定义背景
|
||||
|
||||
---
|
||||
|
||||
@@ -227,4 +270,4 @@ type RunStatus = 'running' | 'error' | 'stopped' // 🟢 | 🔴 | 🟡
|
||||
|
||||
## 📄 License
|
||||
|
||||
License 待定(待补充)。
|
||||
本项目基于 [Apache License 2.0](LICENSE) 开源。
|
||||
|
||||
@@ -4,6 +4,32 @@
|
||||
|
||||
---
|
||||
|
||||
## v1.1.0(2026-10-14)
|
||||
|
||||
### ✨ 新增功能
|
||||
|
||||
- **系统托盘**:新增常驻系统托盘图标与菜单(**显示窗口 / 启动穿透 / 停止穿透 / 退出**)。
|
||||
- 左键单击托盘图标直接显示并聚焦主窗口,右键单击弹出菜单;
|
||||
- 「启动穿透」「停止穿透」与仪表盘的一键启动 / 停止共用同一套逻辑,并随运行状态联动:未运行时禁用「停止穿透」;启动成功后自动禁用「启动穿透」、启用「停止穿透」,停止后恢复;
|
||||
- 「退出」会在停止正在运行的穿透后再退出整个软件;
|
||||
- 托盘菜单文案随语言切换同步更新。
|
||||
- **关闭确认对话框**:点击标题栏关闭按钮(Windows 关闭按钮 / macOS 红绿灯)不再直接退出,改为先弹出对话框,提供两个选项:
|
||||
- **最小化到系统托盘**:隐藏窗口、保持程序后台运行,可从托盘随时恢复;
|
||||
- **直接退出**:停止穿透并完全退出软件;
|
||||
- Windows 与 macOS 行为一致,同时拦截 `Alt+F4` 等系统原生关闭,一并走确认流程。
|
||||
|
||||
### 🎨 优化
|
||||
|
||||
- **更新提示**:顶部栏「检查更新」按钮新增「有新版本」圆点徽标;关闭更新弹窗后保留已检测到的版本,再次点击即可重新打开,避免重复联网检查。
|
||||
- **标题栏**:以应用图标替换原先的文字 / 渐变方块 Logo。
|
||||
- **macOS 窗口**:适配无边框窗口的圆角与透明背景,消除首帧方角闪烁;窗口失焦时红绿灯自动置灰。
|
||||
|
||||
### 🧱 工程
|
||||
|
||||
- 应用版本升级至 **v1.1.0**;`tauri` 启用 `tray-icon` 特性,capabilities 新增 `core:app:allow-default-window-icon` 权限。
|
||||
- 更新端点增加 GitHub 直连作为镜像备用;修正应用名称与标识符大小写(统一为 `Frpilot`)。
|
||||
- 重写 README,并补充仪表盘 / 服务器 / 隧道 / 下载 / 设置等页面的界面截图。
|
||||
|
||||
## v1.0.0(2026-10-14)
|
||||
|
||||
### 🎉 首个正式版本发布!
|
||||
|
||||
@@ -127,3 +127,15 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
|
||||
|
||||
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||
|
||||
#### 3.7 系统托盘功能开发
|
||||
|
||||
- 这是Tauri2文档里面关于系统托盘的指示:https://tauri.app/zh-cn/learn/system-tray/
|
||||
- 使用文档中前端的方式开发系统托盘+托盘图标
|
||||
- 托盘菜单内容暂定四项,显示窗口、启动穿透、停止穿透、退出。
|
||||
- 点击显示窗口,窗口显示
|
||||
- 穿透未启动时,停止穿透禁止操作,点击启动穿透,同步仪表盘页面的一键启动操作,启动成功后,启动穿透禁止操作,停止穿透允许操作
|
||||
- 穿透启动时,启动穿透禁止操作,停止穿透允许操作,点击停止穿透,同步仪表盘页面的停止操作,停止后,启动穿透允许操作,暂停穿透禁止操作
|
||||
- 点击退出,整个程序退出,如果穿透正在启动运行,一并停止并退出软件
|
||||
- 点击Header栏里面的关闭按钮,目前是直接退出整个程序,现在改成先出现一个对话框,对话框内有两个单选项分别是最小化到系统托盘、直接退出,如果选择最小化到系统托盘,则保持程序运行状态,隐藏到系统托盘,如果是直接退出,则停止穿透并退出软件(Mac和Windows同步)
|
||||
|
||||
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frpilot",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -22,7 +22,7 @@ tauri-build = { version = "2", features = [] }
|
||||
tauri-helper = "0.2.1"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus"
|
||||
"core:window:allow-set-focus",
|
||||
"core:app:allow-default-window-icon"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "frpilot",
|
||||
"productName": "Frpilot",
|
||||
"version": "../package.json",
|
||||
"identifier": "frpilot",
|
||||
"identifier": "Frpilot",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
@@ -41,7 +41,10 @@
|
||||
"updater": {
|
||||
"active": true,
|
||||
"dialog": false,
|
||||
"endpoints": ["https://ghproxy.net/https://github.com/SparksFly91/Frpilot/releases/latest/download/latest.json"],
|
||||
"endpoints": [
|
||||
"https://ghproxy.net/https://github.com/SparksFly91/Frpilot/releases/latest/download/latest.json",
|
||||
"https://github.com/SparksFly91/Frpilot/releases/latest/download/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0REVGMTREODFFOTI2MUEKUldRYUp1bUJUZkhlNU1qRmlPQm5xSmdQd24waFltVmNtTjlUallYVmdva1VGNkp5bTVGcVlsZUgK"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Frpilot",
|
||||
"width": 1100,
|
||||
"height": 720,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"decorations": false,
|
||||
"transparent": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,12 @@
|
||||
import { theme as antdTheme } from "ant-design-vue"
|
||||
import { useAppStore } from "@/stores/app"
|
||||
import { useFrpcStore } from "@/stores/frpc"
|
||||
import { useWindowStore } from "@/stores/window"
|
||||
import { initTray } from "@/composables/useTray"
|
||||
|
||||
const appStore = useAppStore()
|
||||
const frpcStore = useFrpcStore()
|
||||
const windowStore = useWindowStore()
|
||||
|
||||
/** 跟随明暗主题切换 Ant Design Vue 的算法 */
|
||||
const themeConfig = computed(() => ({
|
||||
@@ -24,6 +27,9 @@ const themeConfig = computed(() => ({
|
||||
// 应用根组件初始化一次:订阅 frpc 日志/退出事件,离开仪表盘后仍持续收集
|
||||
onMounted(() => {
|
||||
frpcStore.init()
|
||||
// 系统托盘(前端 API 方式)与窗口关闭拦截
|
||||
initTray()
|
||||
windowStore.init()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -11,28 +11,35 @@ body,
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* 背景统一绘制在 <html> 上:
|
||||
* 根元素的背景会被绘制到整个画布(canvas)上,铺满窗口的每一个像素,
|
||||
* 不会出现“元素高度(100vh) 与实际窗口差几个像素”导致的上下留白。
|
||||
*/
|
||||
html {
|
||||
background-color: #eef2ff;
|
||||
background-image:
|
||||
/* 窗口背景色与渐变(供 html / #app 复用,避免重复定义) */
|
||||
:root {
|
||||
--app-bg-color: #eef2ff;
|
||||
--app-bg-gradient:
|
||||
radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.28), transparent 60%),
|
||||
radial-gradient(1000px 500px at 110% 10%, rgba(34, 211, 238, 0.28), transparent 55%),
|
||||
linear-gradient(160deg, #eef2ff 0%, #f8fafc 45%, #eef7ff 100%);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background-color: #0b1220;
|
||||
background-image:
|
||||
--app-bg-color: #0b1220;
|
||||
--app-bg-gradient:
|
||||
radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.35), transparent 60%),
|
||||
radial-gradient(1000px 500px at 110% 10%, rgba(14, 165, 233, 0.28), transparent 55%),
|
||||
linear-gradient(160deg, #0b1220 0%, #0f172a 45%, #111827 100%);
|
||||
}
|
||||
|
||||
/*
|
||||
* 默认(Windows / Linux):背景绘制在 <html> 上。
|
||||
* 根元素的背景会被绘制到整个画布(canvas)上,铺满窗口的每一个像素,
|
||||
* 不会出现“元素高度(100vh) 与实际窗口差几个像素”导致的上下留白;
|
||||
* 窗口圆角交给系统裁切(Windows 11 的 DWM 默认就是 8px 圆角)。
|
||||
*/
|
||||
html {
|
||||
background-color: var(--app-bg-color);
|
||||
background-image: var(--app-bg-gradient);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* 选中自定义背景图时替换为图片:等比缩放铺满,不拉伸变形 */
|
||||
html.has-bg-image {
|
||||
background-image: var(--app-bg-image, none);
|
||||
@@ -41,11 +48,44 @@ html.has-bg-image {
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/*
|
||||
* macOS:无边框窗口系统不会自动裁圆角(不像 Windows 11 的 DWM),
|
||||
* 因此通过 tauri.macos.conf.json 开启 transparent 窗口,
|
||||
* 再把背景从 <html> 移到 #app 上并由 #app 裁出 8px 圆角,
|
||||
* html/body 保持完全透明,让圆角之外的区域透出桌面壁纸。
|
||||
*/
|
||||
html.is-macos,
|
||||
html.is-macos body {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
html.is-macos #app {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: var(--app-bg-color);
|
||||
background-image: var(--app-bg-gradient);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
html.is-macos.has-bg-image #app {
|
||||
background-image: var(--app-bg-image, none);
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* 全屏遮罩跟随窗口圆角,避免弹窗打开时遮罩把圆角「填方」 */
|
||||
html.is-macos .ant-modal-mask {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', 'HarmonyOS Sans SC', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* 背景交由 <html> 绘制,body 保持透明避免覆盖画布背景 */
|
||||
/* 背景交由 <html>(或 macOS 下的 #app)绘制,body 保持透明避免覆盖画布背景 */
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@@ -72,7 +112,7 @@ body {
|
||||
.glass-panel {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
backdrop-filter: blur(18px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(160%);
|
||||
box-shadow: 0 8px 32px rgba(15, 23, 42, 0.12);
|
||||
@@ -80,7 +120,7 @@ body {
|
||||
|
||||
.dark .glass-panel {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
@@ -91,7 +131,7 @@ body {
|
||||
*/
|
||||
.glass-card.ant-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.5) !important;
|
||||
background: rgba(255, 255, 255, 0.5) !important;
|
||||
background: rgba(255, 255, 255, 0.45) !important;
|
||||
backdrop-filter: blur(16px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(160%);
|
||||
box-shadow: 0 6px 22px rgba(15, 23, 42, 0.08) !important;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="store.closeConfirmOpen"
|
||||
:title="t('closeDialog.title')"
|
||||
:width="380"
|
||||
centered
|
||||
:mask-closable="false"
|
||||
:closable="!store.quitting"
|
||||
:keyboard="!store.quitting"
|
||||
@cancel="store.cancelClose()"
|
||||
>
|
||||
<a-radio-group v-model:value="store.closeChoice" class="close-choice">
|
||||
<a-radio value="tray" class="close-option">
|
||||
<span class="close-option-title">{{ t("closeDialog.tray") }}</span>
|
||||
<span class="close-option-desc">{{ t("closeDialog.trayDesc") }}</span>
|
||||
</a-radio>
|
||||
<a-radio value="exit" class="close-option">
|
||||
<span class="close-option-title">{{ t("closeDialog.exit") }}</span>
|
||||
<span class="close-option-desc">{{ t("closeDialog.exitDesc") }}</span>
|
||||
</a-radio>
|
||||
</a-radio-group>
|
||||
|
||||
<template #footer>
|
||||
<a-button :disabled="store.quitting" @click="store.cancelClose()">
|
||||
{{ t("common.cancel") }}
|
||||
</a-button>
|
||||
<a-button type="primary" :loading="store.quitting" @click="store.confirmClose()">
|
||||
{{ t("common.confirm") }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n"
|
||||
import { useWindowStore } from "@/stores/window"
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useWindowStore()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.close-choice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.close-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
margin-inline-end: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
// 选中态高亮
|
||||
&.ant-radio-wrapper-checked {
|
||||
border-color: #6366f1;
|
||||
background-color: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
:deep(.ant-radio) {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
:deep(> span:last-child) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .close-option {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
|
||||
&.ant-radio-wrapper-checked {
|
||||
border-color: #6366f1;
|
||||
background-color: rgba(99, 102, 241, 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
.close-option-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.close-option-desc {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -10,7 +10,7 @@
|
||||
class="traffic-light bg-[#ff5f57]"
|
||||
:class="{ 'traffic-light--idle': !focused }"
|
||||
:title="t('titlebar.close')"
|
||||
@click="closeWindow()"
|
||||
@click="windowStore.requestClose()"
|
||||
>
|
||||
<svg viewBox="0 0 12 12" class="light-icon opacity-0 group-hover/lights:opacity-100">
|
||||
<path d="M3.5 3.5l5 5M8.5 3.5l-5 5" />
|
||||
@@ -45,11 +45,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n"
|
||||
import TitleBarActions from "./TitleBarActions.vue"
|
||||
import { closeWindow, minimizeWindow } from "@/utils/window"
|
||||
import { useWindowStore } from "@/stores/window"
|
||||
import { minimizeWindow } from "@/utils/window"
|
||||
|
||||
const emit = defineEmits<{ settings: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const windowStore = useWindowStore()
|
||||
const focused = useWindowFocus()
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,22 +2,14 @@
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- 语言切换 -->
|
||||
<a-dropdown placement="bottomRight" trigger="click">
|
||||
<a-button
|
||||
type="text"
|
||||
class="tb-btn"
|
||||
:title="t('titlebar.language')"
|
||||
>
|
||||
<template #icon><TranslationOutlined /></template>
|
||||
<a-button type="text" class="tb-btn" :title="t('titlebar.language')">
|
||||
<template #icon>
|
||||
<TranslationOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu
|
||||
:selected-keys="[appStore.language]"
|
||||
@click="onLanguageClick"
|
||||
>
|
||||
<a-menu-item
|
||||
v-for="item in languageOptions"
|
||||
:key="item.key"
|
||||
>
|
||||
<a-menu :selected-keys="[appStore.language]" @click="onLanguageClick">
|
||||
<a-menu-item v-for="item in languageOptions" :key="item.key">
|
||||
{{ item.label }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
@@ -25,69 +17,50 @@
|
||||
</a-dropdown>
|
||||
|
||||
<!-- 明暗切换 -->
|
||||
<a-button
|
||||
type="text"
|
||||
class="tb-btn"
|
||||
:title="appStore.isDark ? t('titlebar.lightMode') : t('titlebar.darkMode')"
|
||||
@click="appStore.toggleDark()"
|
||||
>
|
||||
<a-button type="text" class="tb-btn" :title="appStore.isDark ? t('titlebar.lightMode') : t('titlebar.darkMode')"
|
||||
@click="appStore.toggleDark()">
|
||||
<template #icon>
|
||||
<BulbFilled v-if="appStore.isDark" />
|
||||
<BulbOutlined v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
|
||||
<!-- 检查更新 -->
|
||||
<a-button
|
||||
type="text"
|
||||
class="tb-btn"
|
||||
:title="t('titlebar.checkUpdate')"
|
||||
:loading="updateStore.checking"
|
||||
@click="onCheckUpdate"
|
||||
>
|
||||
<template #icon><CloudSyncOutlined /></template>
|
||||
</a-button>
|
||||
<!-- 检查更新(有新版本时显示圆点徽标) -->
|
||||
<a-badge :dot="updateStore.hasUpdate">
|
||||
<a-button type="text" class="tb-btn" :title="t('titlebar.checkUpdate')" :loading="updateStore.checking"
|
||||
@click="onCheckUpdate">
|
||||
<template #icon>
|
||||
<CloudSyncOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-badge>
|
||||
|
||||
<!-- GitHub -->
|
||||
<a-button
|
||||
type="text"
|
||||
class="tb-btn"
|
||||
:title="t('titlebar.github')"
|
||||
@click="onOpenGithub"
|
||||
>
|
||||
<template #icon><GithubOutlined /></template>
|
||||
<a-button type="text" class="tb-btn" :title="t('titlebar.github')" @click="onOpenGithub">
|
||||
<template #icon>
|
||||
<GithubOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
|
||||
<!-- 设置 -->
|
||||
<a-button
|
||||
type="text"
|
||||
class="tb-btn"
|
||||
:title="t('titlebar.settings')"
|
||||
@click="emit('settings')"
|
||||
>
|
||||
<template #icon><SettingOutlined /></template>
|
||||
<a-button type="text" class="tb-btn" :title="t('titlebar.settings')" @click="emit('settings')">
|
||||
<template #icon>
|
||||
<SettingOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
|
||||
<!-- 最小化(macOS 由左侧红绿灯负责,此处隐藏) -->
|
||||
<a-button
|
||||
v-if="!isMac"
|
||||
type="text"
|
||||
class="tb-btn"
|
||||
:title="t('titlebar.minimize')"
|
||||
@click="minimizeWindow()"
|
||||
>
|
||||
<template #icon><MinusOutlined /></template>
|
||||
<a-button v-if="!isMac" type="text" class="tb-btn" :title="t('titlebar.minimize')" @click="minimizeWindow()">
|
||||
<template #icon>
|
||||
<MinusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
|
||||
<!-- 关闭(macOS 由左侧红绿灯负责,此处隐藏) -->
|
||||
<a-button
|
||||
v-if="!isMac"
|
||||
type="text"
|
||||
class="tb-btn tb-btn-close"
|
||||
:title="t('titlebar.close')"
|
||||
@click="closeWindow()"
|
||||
>
|
||||
<template #icon><CloseOutlined /></template>
|
||||
<a-button v-if="!isMac" type="text" class="tb-btn tb-btn-close" :title="t('titlebar.close')" @click="windowStore.requestClose()">
|
||||
<template #icon>
|
||||
<CloseOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -107,8 +80,9 @@ import { useI18n } from "vue-i18n"
|
||||
import type { MenuProps } from "ant-design-vue"
|
||||
import { useAppStore } from "@/stores/app"
|
||||
import { useUpdateStore } from "@/stores/update"
|
||||
import { useWindowStore } from "@/stores/window"
|
||||
import { languageOptions, type Language } from "@/i18n"
|
||||
import { closeWindow, minimizeWindow } from "@/utils/window"
|
||||
import { minimizeWindow } from "@/utils/window"
|
||||
import { openProjectRepo } from "@/api/updater"
|
||||
import { usePlatform } from "@/composables/usePlatform"
|
||||
|
||||
@@ -117,6 +91,7 @@ const emit = defineEmits<{ settings: [] }>()
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const updateStore = useUpdateStore()
|
||||
const windowStore = useWindowStore()
|
||||
const { isMac } = usePlatform()
|
||||
|
||||
const onLanguageClick: MenuProps["onClick"] = ({ key }) => {
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="store.modalOpen"
|
||||
:title="modalTitle"
|
||||
:closable="!store.installing"
|
||||
:mask-closable="false"
|
||||
:keyboard="!store.installing"
|
||||
:width="520"
|
||||
centered
|
||||
wrap-class-name="update-modal-wrap"
|
||||
@cancel="onCancel"
|
||||
>
|
||||
<a-modal :open="store.modalOpen" :title="modalTitle" :closable="!store.installing" :mask-closable="false"
|
||||
:keyboard="!store.installing" :width="520" centered wrap-class-name="update-modal-wrap" @cancel="onCancel">
|
||||
<!-- 俏皮的问候 + 版本变化 -->
|
||||
<div class="update-hero">
|
||||
<span class="update-hero-emoji">🎉</span>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<template>
|
||||
<!-- Windows 自定义标题栏 -->
|
||||
<div
|
||||
class="titlebar flex h-12 shrink-0 items-center justify-between pl-4 pr-2 select-none"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<!-- 左侧:标题 -->
|
||||
<div class="titlebar flex h-12 shrink-0 items-center justify-between pl-4 pr-2 select-none" data-tauri-drag-region>
|
||||
<!-- 左侧:图标 + 标题 -->
|
||||
<div class="flex items-center gap-2" data-tauri-drag-region>
|
||||
<span class="brand-logo">F</span>
|
||||
<img
|
||||
class="h-[22px] w-[22px] shrink-0 object-contain"
|
||||
:src="appIcon"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
<span class="text-sm font-semibold tracking-wide">Frpilot</span>
|
||||
</div>
|
||||
|
||||
@@ -17,21 +19,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import TitleBarActions from "./TitleBarActions.vue"
|
||||
import appIcon from "@/assets/images/app-icon.webp"
|
||||
|
||||
const emit = defineEmits<{ settings: [] }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.brand-logo {
|
||||
display: inline-flex;
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, #6366f1, #22d3ee);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,15 +5,17 @@ export type Platform = "windows" | "macos" | "linux" | "unknown"
|
||||
* 判断当前运行的操作系统类型。
|
||||
* 通过 navigator.userAgent 推断,避免额外引入 @tauri-apps/plugin-os。
|
||||
*/
|
||||
export function usePlatform() {
|
||||
export function detectPlatform(): Platform {
|
||||
const ua = (navigator.userAgent || "").toLowerCase()
|
||||
if (ua.includes("mac") || ua.includes("darwin")) return "macos"
|
||||
if (ua.includes("win")) return "windows"
|
||||
if (ua.includes("linux") || ua.includes("x11")) return "linux"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
const platform = computed<Platform>(() => {
|
||||
if (ua.includes("mac") || ua.includes("darwin")) return "macos"
|
||||
if (ua.includes("win")) return "windows"
|
||||
if (ua.includes("linux") || ua.includes("x11")) return "linux"
|
||||
return "unknown"
|
||||
})
|
||||
/** 在组件中响应式地获取当前平台 */
|
||||
export function usePlatform() {
|
||||
const platform = ref<Platform>(detectPlatform())
|
||||
|
||||
const isMac = computed(() => platform.value === "macos")
|
||||
const isWindows = computed(() => platform.value === "windows")
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { message } from "ant-design-vue"
|
||||
import { translate } from "@/i18n"
|
||||
import { useAppStore } from "@/stores/app"
|
||||
import { useFrpcStore } from "@/stores/frpc"
|
||||
import { useWindowStore } from "@/stores/window"
|
||||
import { isTauri } from "@/utils/window"
|
||||
|
||||
/** 是否已初始化,避免重复创建托盘 */
|
||||
let initialized = false
|
||||
|
||||
function errMsg(e: unknown) {
|
||||
return e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化系统托盘(Tauri 前端 API 方式,参见 Tauri 文档「系统托盘」)。
|
||||
*
|
||||
* 在应用根组件挂载时调用一次即可。托盘菜单共四项:
|
||||
* 显示窗口 / 启动穿透 / 停止穿透 / 退出。
|
||||
*
|
||||
* - 「启动穿透」「停止穿透」与仪表盘的一键启动 / 停止共用 store 逻辑,
|
||||
* 并随 Frpc 运行状态联动禁用:未运行时禁用「停止穿透」,运行中禁用「启动穿透」;
|
||||
* - 「退出」会先停止正在运行的穿透,再退出整个软件。
|
||||
*/
|
||||
export function initTray() {
|
||||
if (initialized || !isTauri()) return
|
||||
initialized = true
|
||||
void createTray().catch((e) => {
|
||||
initialized = false
|
||||
console.error("系统托盘初始化失败:", e)
|
||||
})
|
||||
}
|
||||
|
||||
async function createTray() {
|
||||
const appStore = useAppStore()
|
||||
const frpcStore = useFrpcStore()
|
||||
const windowStore = useWindowStore()
|
||||
|
||||
const [{ TrayIcon }, { Menu, MenuItem, PredefinedMenuItem }, { defaultWindowIcon }] =
|
||||
await Promise.all([
|
||||
import("@tauri-apps/api/tray"),
|
||||
import("@tauri-apps/api/menu"),
|
||||
import("@tauri-apps/api/app"),
|
||||
])
|
||||
|
||||
// 显示窗口(始终可用)
|
||||
const showItem = await MenuItem.new({
|
||||
id: "tray-show",
|
||||
text: translate("tray.show"),
|
||||
action: () => windowStore.showWindow(),
|
||||
})
|
||||
|
||||
// 启动穿透(未运行时可用)
|
||||
const startItem = await MenuItem.new({
|
||||
id: "tray-start",
|
||||
text: translate("tray.start"),
|
||||
enabled: frpcStore.canStart,
|
||||
action: () => void startFromTray(frpcStore),
|
||||
})
|
||||
|
||||
// 停止穿透(运行中才可用)
|
||||
const stopItem = await MenuItem.new({
|
||||
id: "tray-stop",
|
||||
text: translate("tray.stop"),
|
||||
enabled: frpcStore.canStop,
|
||||
action: () => void stopFromTray(frpcStore),
|
||||
})
|
||||
|
||||
// 退出(先停止穿透,再退出软件)
|
||||
const quitItem = await MenuItem.new({
|
||||
id: "tray-quit",
|
||||
text: translate("tray.quit"),
|
||||
action: () => windowStore.quitApp(),
|
||||
})
|
||||
|
||||
const [separatorTop, separatorBottom] = await Promise.all([
|
||||
PredefinedMenuItem.new({ item: "Separator" }),
|
||||
PredefinedMenuItem.new({ item: "Separator" }),
|
||||
])
|
||||
|
||||
const menu = await Menu.new({
|
||||
items: [showItem, separatorTop, startItem, stopItem, separatorBottom, quitItem],
|
||||
})
|
||||
|
||||
// 托盘图标复用应用自身的窗口图标
|
||||
const icon = await defaultWindowIcon().catch(() => null)
|
||||
|
||||
await TrayIcon.new({
|
||||
id: "frpilot-tray",
|
||||
icon: icon ?? undefined,
|
||||
tooltip: "Frpilot",
|
||||
menu,
|
||||
// 左键点击直接显示窗口,右键才弹出菜单
|
||||
showMenuOnLeftClick: false,
|
||||
action: (event) => {
|
||||
// 仅在左键按下时响应,避免 Down / Up 触发两次
|
||||
if (event.type === "Click" && event.button === "Left" && event.buttonState === "Down") {
|
||||
void windowStore.showWindow()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Frpc 运行状态变化时同步菜单可用状态
|
||||
watch(
|
||||
[() => frpcStore.canStart, () => frpcStore.canStop],
|
||||
([canStart, canStop]) => {
|
||||
void startItem.setEnabled(canStart)
|
||||
void stopItem.setEnabled(canStop)
|
||||
},
|
||||
)
|
||||
|
||||
// 语言切换时同步菜单文案
|
||||
watch(
|
||||
() => appStore.language,
|
||||
() => {
|
||||
void showItem.setText(translate("tray.show"))
|
||||
void startItem.setText(translate("tray.start"))
|
||||
void stopItem.setText(translate("tray.stop"))
|
||||
void quitItem.setText(translate("tray.quit"))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 托盘「启动穿透」:复用仪表盘的一键启动逻辑 */
|
||||
async function startFromTray(frpcStore: ReturnType<typeof useFrpcStore>) {
|
||||
if (!frpcStore.canStart) return
|
||||
try {
|
||||
await frpcStore.startSelected()
|
||||
} catch (e) {
|
||||
useAppStore().runStatus = "error"
|
||||
frpcStore.pushLog("ERROR", translate("dashboard.logStartFailed", { message: errMsg(e) }))
|
||||
message.error(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
/** 托盘「停止穿透」:复用仪表盘的停止逻辑 */
|
||||
async function stopFromTray(frpcStore: ReturnType<typeof useFrpcStore>) {
|
||||
if (!frpcStore.canStop) return
|
||||
try {
|
||||
await frpcStore.stop()
|
||||
} catch (e) {
|
||||
message.error(errMsg(e))
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,19 @@
|
||||
"download": "Download frpc",
|
||||
"setting": "Settings"
|
||||
},
|
||||
"tray": {
|
||||
"show": "Show window",
|
||||
"start": "Start tunnel",
|
||||
"stop": "Stop tunnel",
|
||||
"quit": "Quit"
|
||||
},
|
||||
"closeDialog": {
|
||||
"title": "Close Frpilot",
|
||||
"tray": "Minimize to system tray",
|
||||
"trayDesc": "Keep Frpilot running in the background; reopen it from the system tray",
|
||||
"exit": "Quit directly",
|
||||
"exitDesc": "Stop the tunnel and exit Frpilot completely"
|
||||
},
|
||||
"system": {
|
||||
"cpu": "CPU usage",
|
||||
"memory": "Memory usage",
|
||||
|
||||
@@ -28,6 +28,19 @@
|
||||
"download": "Frpc 下载",
|
||||
"setting": "设置"
|
||||
},
|
||||
"tray": {
|
||||
"show": "显示窗口",
|
||||
"start": "启动穿透",
|
||||
"stop": "停止穿透",
|
||||
"quit": "退出"
|
||||
},
|
||||
"closeDialog": {
|
||||
"title": "关闭 Frpilot",
|
||||
"tray": "最小化到系统托盘",
|
||||
"trayDesc": "保持 Frpilot 在后台运行,可从系统托盘随时打开",
|
||||
"exit": "直接退出",
|
||||
"exitDesc": "停止穿透并完全退出 Frpilot"
|
||||
},
|
||||
"system": {
|
||||
"cpu": "CPU 占用",
|
||||
"memory": "内存占用",
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
|
||||
<!-- 全局更新弹窗 -->
|
||||
<UpdateModal />
|
||||
|
||||
<!-- 关闭确认对话框(最小化到系统托盘 / 直接退出) -->
|
||||
<CloseConfirmModal />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -22,6 +25,7 @@
|
||||
import Header from "./header/Header.vue"
|
||||
import Aside from "./aside/Aside.vue"
|
||||
import UpdateModal from "@/components/UpdateModal.vue"
|
||||
import CloseConfirmModal from "@/components/CloseConfirmModal.vue"
|
||||
import { useUpdateStore } from "@/stores/update"
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -9,6 +9,16 @@ import "ant-design-vue/dist/reset.css"
|
||||
import router from "@/router/router"
|
||||
// 引入国际化
|
||||
import i18n from "@/i18n"
|
||||
import { detectPlatform } from "@/composables/usePlatform"
|
||||
|
||||
/*
|
||||
* macOS 的无边框窗口系统不会自动裁圆角,需要配合 transparent 窗口,
|
||||
* 由 #app 自身裁出 8px 圆角(见 assets/main.css)。这里在挂载前打上平台标记,
|
||||
* 避免首帧出现方角闪烁。
|
||||
*/
|
||||
if (detectPlatform() === "macos") {
|
||||
document.documentElement.classList.add("is-macos")
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from "pinia"
|
||||
import dayjs from "dayjs"
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
import { getFrpcStatus, startFrpc, stopFrpc } from "@/api/frp"
|
||||
import { getFrpcStatus, getFrpDownloads, startFrpc, stopFrpc } from "@/api/frp"
|
||||
import { translate } from "@/i18n"
|
||||
import { useAppStore } from "@/stores/app"
|
||||
import type { FrpcExitEvent, FrpcLogEvent, FrpcStatus } from "@/types/frp"
|
||||
@@ -42,6 +42,12 @@ export const useFrpcStore = defineStore("frpc", () => {
|
||||
/** 是否正在运行 */
|
||||
const isRunning = computed(() => appStore.runStatus === "running")
|
||||
|
||||
/** 是否可启动(未运行且不在启动中,供托盘菜单联动) */
|
||||
const canStart = computed(() => !isRunning.value && !starting.value)
|
||||
|
||||
/** 是否可停止(运行中且不在停止中,供托盘菜单联动) */
|
||||
const canStop = computed(() => isRunning.value && !stopping.value)
|
||||
|
||||
/** 追加一行日志(最多保留 MAX_LOGS 行) */
|
||||
function pushLog(level: LogLevel, message: string, time?: string) {
|
||||
logs.value.push({ time: time || dayjs().format("HH:mm:ss"), level, message })
|
||||
@@ -136,6 +142,32 @@ export const useFrpcStore = defineStore("frpc", () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保已选中一个可用的 Frpc 版本。
|
||||
*
|
||||
* 托盘可能在未打开仪表盘时触发启动,此时下拉框尚未加载数据、selectedId 为空,
|
||||
* 这里自行拉取并回退到第一个带 frpc 可执行文件的版本。
|
||||
*/
|
||||
async function ensureSelectedVersion() {
|
||||
if (selectedId.value) return
|
||||
try {
|
||||
const downloads = (await getFrpDownloads()) ?? []
|
||||
const first = downloads.find((item) => item.frpc_path)
|
||||
if (first) selectedId.value = String(first.id)
|
||||
} catch {
|
||||
// 读取失败时保持为空,由调用方提示
|
||||
}
|
||||
}
|
||||
|
||||
/** 使用当前选中版本一键启动(供托盘菜单复用仪表盘的启动逻辑) */
|
||||
async function startSelected(): Promise<FrpcStatus> {
|
||||
await ensureSelectedVersion()
|
||||
if (!selectedId.value) {
|
||||
throw new Error(translate("dashboard.frpcVersionEmpty"))
|
||||
}
|
||||
return start(Number(selectedId.value))
|
||||
}
|
||||
|
||||
return {
|
||||
logs,
|
||||
selectedId,
|
||||
@@ -143,10 +175,14 @@ export const useFrpcStore = defineStore("frpc", () => {
|
||||
starting,
|
||||
stopping,
|
||||
isRunning,
|
||||
canStart,
|
||||
canStop,
|
||||
pushLog,
|
||||
clearLogs,
|
||||
init,
|
||||
start,
|
||||
stop,
|
||||
ensureSelectedVersion,
|
||||
startSelected,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -60,11 +60,19 @@ export const useUpdateStore = defineStore("update", () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 已检测到可用更新(例如用户点过「取消」关掉了弹窗):直接重新打开,
|
||||
// 避免重复联网检查、重复创建后端资源;徽标也因此能持续提示。
|
||||
if (pending) {
|
||||
modalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
checking.value = true
|
||||
try {
|
||||
const update = await checkForAppUpdate()
|
||||
|
||||
if (!update) {
|
||||
info.value = null
|
||||
if (!silent) message.success(translate("update.upToDate"))
|
||||
return
|
||||
}
|
||||
@@ -107,9 +115,12 @@ export const useUpdateStore = defineStore("update", () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 取消(本次不更新,下次启动仍会提示) */
|
||||
/**
|
||||
* 取消:仅关闭弹窗,保留已检测到的可用更新。
|
||||
* 顶部栏 / 关于页的圆点徽标会继续提示,再次点击「检查更新」可重新打开弹窗。
|
||||
*/
|
||||
function cancel() {
|
||||
void discard()
|
||||
modalOpen.value = false
|
||||
}
|
||||
|
||||
/** 忽略该版本(持久化,静默检查不再提示) */
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { exitApp, hideWindow, isTauri, revealWindow } from "@/utils/window"
|
||||
import { useFrpcStore } from "@/stores/frpc"
|
||||
|
||||
/** 关闭窗口时用户的选择:最小化到系统托盘 / 直接退出 */
|
||||
export type CloseChoice = "tray" | "exit"
|
||||
|
||||
/**
|
||||
* 窗口生命周期:关闭确认、最小化到系统托盘、退出软件。
|
||||
*
|
||||
* - 顶部栏关闭按钮与系统原生关闭(如 Alt+F4)都会先弹出确认对话框;
|
||||
* - 选择「最小化到系统托盘」时隐藏窗口,程序保持后台运行;
|
||||
* - 选择「直接退出」时若穿透正在运行会先停止,再退出整个软件。
|
||||
*/
|
||||
export const useWindowStore = defineStore("window", () => {
|
||||
const frpcStore = useFrpcStore()
|
||||
|
||||
/** 关闭确认对话框是否打开 */
|
||||
const closeConfirmOpen = ref(false)
|
||||
/** 当前选中的关闭方式 */
|
||||
const closeChoice = ref<CloseChoice>("tray")
|
||||
/** 是否正在退出(停止穿透 + 退出软件) */
|
||||
const quitting = ref(false)
|
||||
|
||||
let initialized = false
|
||||
|
||||
/** 应用启动时调用一次:拦截系统原生关闭,改为弹出确认对话框 */
|
||||
async function init() {
|
||||
if (initialized || !isTauri()) return
|
||||
initialized = true
|
||||
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window")
|
||||
await getCurrentWindow().onCloseRequested((event) => {
|
||||
event.preventDefault()
|
||||
requestClose()
|
||||
})
|
||||
}
|
||||
|
||||
/** 请求关闭窗口:弹出确认对话框(默认选中最小化到系统托盘) */
|
||||
function requestClose() {
|
||||
closeChoice.value = "tray"
|
||||
closeConfirmOpen.value = true
|
||||
}
|
||||
|
||||
/** 取消关闭 */
|
||||
function cancelClose() {
|
||||
closeConfirmOpen.value = false
|
||||
}
|
||||
|
||||
/** 确认关闭:按所选方式处理 */
|
||||
async function confirmClose() {
|
||||
const choice = closeChoice.value
|
||||
closeConfirmOpen.value = false
|
||||
if (choice === "tray") {
|
||||
await minimizeToTray()
|
||||
} else {
|
||||
await quitApp()
|
||||
}
|
||||
}
|
||||
|
||||
/** 最小化到系统托盘(隐藏窗口,保持程序运行) */
|
||||
async function minimizeToTray() {
|
||||
await hideWindow()
|
||||
}
|
||||
|
||||
/** 显示并聚焦主窗口(从系统托盘恢复) */
|
||||
async function showWindow() {
|
||||
await revealWindow()
|
||||
}
|
||||
|
||||
/** 退出软件:穿透正在运行则先停止,再退出 */
|
||||
async function quitApp() {
|
||||
if (quitting.value) return
|
||||
quitting.value = true
|
||||
try {
|
||||
if (frpcStore.isRunning) {
|
||||
await frpcStore.stop()
|
||||
}
|
||||
} catch {
|
||||
// 停止失败不阻塞退出,后端在应用退出时会兜底结束 frpc 子进程
|
||||
}
|
||||
await exitApp()
|
||||
}
|
||||
|
||||
return {
|
||||
closeConfirmOpen,
|
||||
closeChoice,
|
||||
quitting,
|
||||
init,
|
||||
requestClose,
|
||||
cancelClose,
|
||||
confirmClose,
|
||||
minimizeToTray,
|
||||
showWindow,
|
||||
quitApp,
|
||||
}
|
||||
})
|
||||
@@ -11,6 +11,7 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ABadge: typeof import('ant-design-vue/es')['Badge']
|
||||
AButton: typeof import('ant-design-vue/es')['Button']
|
||||
ACard: typeof import('ant-design-vue/es')['Card']
|
||||
ACol: typeof import('ant-design-vue/es')['Col']
|
||||
@@ -30,6 +31,8 @@ declare module 'vue' {
|
||||
AModal: typeof import('ant-design-vue/es')['Modal']
|
||||
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
|
||||
AProgress: typeof import('ant-design-vue/es')['Progress']
|
||||
ARadio: typeof import('ant-design-vue/es')['Radio']
|
||||
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
|
||||
ARow: typeof import('ant-design-vue/es')['Row']
|
||||
ASegmented: typeof import('ant-design-vue/es')['Segmented']
|
||||
ASelect: typeof import('ant-design-vue/es')['Select']
|
||||
@@ -41,6 +44,7 @@ declare module 'vue' {
|
||||
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||
CloseConfirmModal: typeof import('./../components/CloseConfirmModal.vue')['default']
|
||||
FrpCard: typeof import('./../components/frp/FrpCard.vue')['default']
|
||||
MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default']
|
||||
MarkdownBlock: typeof import('./../components/MarkdownBlock.vue')['default']
|
||||
|
||||
@@ -31,6 +31,28 @@ export async function closeWindow() {
|
||||
await win?.close()
|
||||
}
|
||||
|
||||
/** 隐藏窗口(最小化到系统托盘,保持程序在后台运行) */
|
||||
export async function hideWindow() {
|
||||
const win = await currentWindow()
|
||||
await win?.hide()
|
||||
}
|
||||
|
||||
/** 显示并聚焦窗口(从系统托盘恢复) */
|
||||
export async function revealWindow() {
|
||||
const win = await currentWindow()
|
||||
if (!win) return
|
||||
await win.show()
|
||||
await win.unminimize()
|
||||
await win.setFocus()
|
||||
}
|
||||
|
||||
/** 退出整个应用(退出前由调用方负责停止穿透等收尾工作) */
|
||||
export async function exitApp() {
|
||||
if (!isTauri()) return
|
||||
const { exit } = await import("@tauri-apps/plugin-process")
|
||||
await exit(0)
|
||||
}
|
||||
|
||||
/** 开始拖拽窗口(配合 data-tauri-drag-region 使用) */
|
||||
export async function startDragging() {
|
||||
const win = await currentWindow()
|
||||
|
||||
@@ -48,9 +48,11 @@
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="text-xs opacity-60">{{ t("about.currentVersion") }}</span>
|
||||
<a-tag color="purple" class="!me-0">v{{ version }}</a-tag>
|
||||
<a-button size="small" :loading="updateStore.checking" @click="onCheckUpdate">
|
||||
{{ t("about.checkUpdate") }}
|
||||
</a-button>
|
||||
<a-badge :dot="updateStore.hasUpdate">
|
||||
<a-button size="small" :loading="updateStore.checking" @click="onCheckUpdate">
|
||||
{{ t("about.checkUpdate") }}
|
||||
</a-button>
|
||||
</a-badge>
|
||||
<span v-if="updateStore.hasUpdate" class="text-xs text-[#6366f1]">
|
||||
{{ t("about.newVersion", { version: updateStore.info?.version }) }}
|
||||
</span>
|
||||
|
||||