Compare commits

...
5 Commits
Author SHA1 Message Date
Yuhang Wu bc4da1c7a4 feat: 新增 Markdown 渲染组件并适配 macOS 标题栏
- 新增 MarkdownBlock 组件实现更新日志的 Markdown 渲染
- 新增 markdown 工具函数和样式
- 适配 macOS 标题栏,隐藏最小化/关闭按钮并禁用最大化按钮
- 新增 1.0.0 版本更新日志文档
2026-10-14 10:40:34 +08:00
Yuhang Wu 746a4be2bc feat: 新增应用更新功能和关于页面
- 集成 Tauri updater 实现应用版本检查与更新
- 添加关于页面,展示项目介绍、使用流程和版本信息
- 新增顶部栏检查更新和 GitHub 入口
- 完善国际化文案和更新弹窗组件
2026-10-14 10:13:31 +08:00
Yuhang Wu f6d8d783b4 feat: 集成 Tauri 更新插件并配置自动发布工作流 2026-10-14 10:03:22 +08:00
Yuhang Wu cdc9507209 feat: 将卡片和弹窗改为透明磨砂玻璃效果
修改服务器、隧道、Frpc列表卡片及对应弹窗的背景为透明磨砂玻璃样式,同时调整弹窗高度限制为窗口的80%,优化滚动行为。
2026-10-14 09:42:53 +08:00
Yuhang Wu 58ac7931c8 build: 更新应用多平台图标及相关资源
新增并更新了 Tauri 项目所需的各类平台图标,包含 iOS、Android、Windows、macOS 等平台的图标文件,同时添加了 Android 自适应图标所需的背景资源配置,确保应用在各平台能正确显示图标。
2026-10-14 09:30:41 +08:00
82 changed files with 1239 additions and 57 deletions
+134
View File
@@ -0,0 +1,134 @@
name: "Publish Release"
on:
push:
tags:
- "v*" # 推送 v1.0.0 之类的 tag 时触发
workflow_dispatch: # 也支持手动触发
permissions:
contents: write # 需要写入权限来创建 Release
jobs:
publish-tauri:
strategy:
fail-fast: false
matrix:
include:
# macOS Apple Silicon (M1/M2/M3)
- platform: "macos-latest"
args: "--target aarch64-apple-darwin --bundles app,dmg"
# Windows x64
- platform: "windows-latest"
args: "--bundles nsis"
# Linux x64(用 22.04 以获得更宽的 glibc 兼容性)
- platform: "ubuntu-22.04"
args: "--bundles deb,appimage"
runs-on: ${{ matrix.platform }}
steps:
# 1. 检出代码
- name: Checkout
uses: actions/checkout@v5
# 2. 安装 pnpm(必须先于 setup-node 的 pnpm 缓存)
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 11
# 3. 安装 Node.js
- name: Setup Node
uses: actions/setup-node@v5
with:
node-version: 24
cache: "pnpm"
# 4. 安装 Rust
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Add macOS Apple Silicon target
if: matrix.platform == 'macos-latest'
run: rustup target add aarch64-apple-darwin
# 4.1 安装 Linux 构建所需的系统依赖(WebKitGTK / GTK / OpenSSL 等)
- name: Install Linux dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev
# 5. Rust 缓存(大幅加速后续构建)
- name: Cache Rust build
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
# 6. 安装前端依赖
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
# 7. 从 docs/CHANGELOG.md 提取当前 tag 对应版本的更新日志
- name: Extract changelog for release
id: changelog
shell: bash
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
node -e '
const fs = require("fs");
const content = fs.readFileSync("docs/CHANGELOG.md", "utf8");
const lines = content.split(/\r?\n/);
const marker = "## " + process.env.RELEASE_TAG;
const startIdx = lines.findIndex((l) => l.trim().startsWith(marker));
if (startIdx === -1) {
fs.writeFileSync("release_body.md", "");
process.exit(0);
}
let endIdx = lines.length;
for (let i = startIdx + 1; i < lines.length; i++) {
if (/^##\s/.test(lines[i])) {
endIdx = i;
break;
}
}
fs.writeFileSync(
"release_body.md",
lines.slice(startIdx + 1, endIdx).join("\n").trim()
);
'
{
echo "body<<CHANGELOG_BODY_EOF"
cat release_body.md
# release_body.md is deliberately trimmed and may not end in a newline.
# Ensure the closing delimiter occupies its own line in GITHUB_OUTPUT.
printf '\nCHANGELOG_BODY_EOF\n'
} >> "$GITHUB_OUTPUT"
# 8. 构建并发布(核心步骤)
- name: Build app & publish release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
tagName: v__VERSION__ # 自动替换为 src-tauri/tauri.conf.json(即 package.json)里的版本号
releaseName: "Frpilot v__VERSION__"
releaseBody: ${{ steps.changelog.outputs.body }}
releaseDraft: true # 先创建草稿,手动确认后再发布
prerelease: false
args: ${{ matrix.args }}
+1
View File
@@ -22,3 +22,4 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.tauri
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 676 KiB

+39
View File
@@ -0,0 +1,39 @@
# 更新日志
本文件记录 Frpilot 各版本的更新内容。
---
## v1.0.0(2026-10-14)
### 🎉 首个正式版本发布!
Frpilot 迎来 1.0.0 正式版 —— 从「手写 frpc.toml」到「点几下就能跑起来」,frp 的日常管理终于有了顺手的图形界面。
### ✨ 新增功能
- **仪表盘**:一屏总览系统信息(主机名 / 系统 / 架构 / IP)与服务器、隧道数量;选择 frpc 版本后「一键启动 / 停止」,并实时回显运行日志与进程退出信息。
- **服务器管理**:frps 服务器的新增 / 编辑 / 删除,支持地址、端口、鉴权 Token、传输协议、TLS、置顶与排序;同一时间仅允许启用一台服务器。
- **隧道管理**:覆盖 TCP / UDP / HTTP / HTTPS / STCP / XTCP / SUDP / TCPMUX 等代理类型,支持自定义域名、子域名、URL 前缀、密钥、加密与压缩、带宽限制、Proxy Protocol、健康检查、负载均衡等高级配置,并统一管理启用 / 置顶 / 排序。
- **Frpc 下载管理**:在线拉取 Frp 官方版本列表,按平台与架构筛选;支持下载(含进度与解压状态)、导入本地已下载的 frpc / frps、删除与复制下载直链。
- **一键启动与实时日志**:依据所选版本、已启用的服务器与隧道自动生成 frpc.toml 并拉起独立进程,通过 `frpc-log` / `frpc-exit` 事件实时推送日志。
- **应用内自动更新**:接入 Tauri updater 与 process 插件,启动时静默检查更新;支持手动检查、「忽略该版本」与下载进度展示,更新说明以 **Markdown** 渲染,安装完成后自动重启。
- **关于页面**:集中展示项目介绍、技术栈、使用流程、开源地址与版本信息,并提供「检查更新」入口。
- **国际化**:内置简体中文 / English,语言选择持久化,并与 Ant Design Vue 组件语言包联动。
- **明暗主题**:基于 `useDark` 一键切换,Tailwind 暗色变体与 Ant Design Vue 暗色算法同步,选择持久化。
- **自定义标题栏**:Windows 与 macOS 双套方案(macOS 还原原生红绿灯),支持拖拽移动窗口;顶部栏集成语言 / 主题 / 检查更新 / GitHub / 设置与窗口控制按钮。
### 🎨 优化
- **透明磨砂玻璃视觉**:侧边栏、内容区与卡片统一为圆角 + 背景模糊的半透明面板,明暗主题下均有细腻的层次与投影表现。
- **统一弹窗布局**:表单弹窗与更新弹窗统一为「标题 / 底部按钮固定、内容区滚动」的结构,内容过多时仅弹窗主体滚动,并限制最大高度。
- **macOS 标题栏细节**:隐藏右侧冗余的最小化 / 关闭按钮,禁用 macOS 下并不需要的最大化(绿灯)按钮。
- **下载卡片交互**:状态标签、大小 / 下载次数 / 发布日期等元信息与操作按钮重新排布,下载与解压进度直观可见。
### 🚀 工程
- 后端采用 **Rust + Tauri 2**,使用 `sqlx`(SQLite)持久化服务器、隧道与 Frpc 下载记录,并通过 `tauri-helper` 统一注册命令。
- 前端技术栈:**Vue 3 + TypeScript + Vite 8 + Pinia + Vue Router + Vue I18n 11**,UI 使用 **Ant Design Vue 4 + Tailwind CSS 4**,借助 `unplugin-auto-import` / `unplugin-vue-components` 实现按需自动导入。
- 开启 `createUpdaterArtifacts` 并结合签名密钥生成更新产物与 `latest.json`,打通应用内自动更新链路。
- 新增 GitHub Actions 发布工作流,覆盖 **Windows x64 / macOS Apple Silicon / Linux x64** 多平台构建,并自动从本文件提取对应版本说明作为 Release 正文。
- 补充多平台应用图标,项目以 **Apache License 2.0** 开源。
+15
View File
@@ -49,6 +49,12 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
现在的弹窗里面内容比较多的时候会出现弹窗高度超出窗口高度,然后整个弹窗可以滚动,我想要改成弹窗高度不能超过窗口高度的80%,然后弹窗内容较多的时候,滚动仅限于弹窗里面可以进行滚动,按照我的要求修改一下 现在的弹窗里面内容比较多的时候会出现弹窗高度超出窗口高度,然后整个弹窗可以滚动,我想要改成弹窗高度不能超过窗口高度的80%,然后弹窗内容较多的时候,滚动仅限于弹窗里面可以进行滚动,按照我的要求修改一下
#### 2.4 弹窗、卡片效果修改
- 服务器列表卡片、新增服务器弹窗的背景效果都改成透明磨砂玻璃效果
- 隧道列表卡片、新建隧道弹窗的背景效果都改成透明磨砂玻璃效果
- Frpc下载列表卡片的背景效果都改成透明磨砂玻璃效果
### 3. 功能开发 ### 3. 功能开发
#### 3.1 开发服务器配置功能 #### 3.1 开发服务器配置功能
@@ -112,3 +118,12 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
- 切换菜单之后再切回仪表盘页面,实时日志内容没了,需要保持实时日志内容不会随页面切换而清空 - 切换菜单之后再切回仪表盘页面,实时日志内容没了,需要保持实时日志内容不会随页面切换而清空
#### 3.6 自动更新功能开发 #### 3.6 自动更新功能开发
- 设置页增加一个子页面(关于),里面内容是项目介绍、使用流程、GitHub地址、检查更新相关等内容
- 顶部栏增加一个检查更新图标操作按钮,按钮样式注意风格跟顶部栏统一
- 顶部栏再增加一个GitHub图标,点击跳转浏览器打开本项目GitHub地址
- 做一个更新弹窗,弹窗标题是最新版本号,以及一些说明性文字,例如:发现新版本啦等,语气尽量俏皮可爱,二次元语气。弹窗内容是更新日志,注意弹窗高度不能超过窗口高度的60%,如果更新日志内容过多,则在弹窗主内容区域内可以上下滚动。
- 弹窗底部是忽略该版本、取消、确认更新等操作
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
+4 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frpilot", "name": "frpilot",
"private": true, "private": true,
"version": "0.1.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -13,9 +13,12 @@
"@ant-design/icons-vue": "^7.0.1", "@ant-design/icons-vue": "^7.0.1",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.4.0",
"@tauri-apps/plugin-updater": "^2.13.3",
"@vueuse/core": "^15.0.0", "@vueuse/core": "^15.0.0",
"ant-design-vue": "^4.2.6", "ant-design-vue": "^4.2.6",
"dayjs": "^1.11.23", "dayjs": "^1.11.23",
"markdown-it": "^15.0.2",
"pinia": "^4.0.3", "pinia": "^4.0.3",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "^11.4.14", "vue-i18n": "^11.4.14",
+70
View File
@@ -17,6 +17,12 @@ importers:
'@tauri-apps/plugin-opener': '@tauri-apps/plugin-opener':
specifier: ^2 specifier: ^2
version: 2.7.1 version: 2.7.1
'@tauri-apps/plugin-process':
specifier: ^2.4.0
version: 2.4.0
'@tauri-apps/plugin-updater':
specifier: ^2.13.3
version: 2.13.3
'@vueuse/core': '@vueuse/core':
specifier: ^15.0.0 specifier: ^15.0.0
version: 15.0.0(vue@3.5.43(typescript@6.0.3)) version: 15.0.0(vue@3.5.43(typescript@6.0.3))
@@ -26,6 +32,9 @@ importers:
dayjs: dayjs:
specifier: ^1.11.23 specifier: ^1.11.23
version: 1.11.23 version: 1.11.23
markdown-it:
specifier: ^15.0.2
version: 15.0.2
pinia: pinia:
specifier: ^4.0.3 specifier: ^4.0.3
version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.43(typescript@6.0.3)) version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.43(typescript@6.0.3))
@@ -529,6 +538,12 @@ packages:
'@tauri-apps/plugin-opener@2.7.1': '@tauri-apps/plugin-opener@2.7.1':
resolution: {integrity: sha512-XkXdt30rontti9TII/gBBxFRXWfj8DiprFW7RgBFRBj1OhdfjBNVcFqSEWkkZ4hXafHnuEnB7F1sFsmu6uyywQ==} resolution: {integrity: sha512-XkXdt30rontti9TII/gBBxFRXWfj8DiprFW7RgBFRBj1OhdfjBNVcFqSEWkkZ4hXafHnuEnB7F1sFsmu6uyywQ==}
'@tauri-apps/plugin-process@2.4.0':
resolution: {integrity: sha512-GddrmsSLAYrLa/yTXlXK/YRDzUgYeeOnUXTbiyvJWkmI424RmDPD3FVLeqcon7rv6O6ZfhBilcrHWnSo7o6hIQ==}
'@tauri-apps/plugin-updater@2.13.3':
resolution: {integrity: sha512-CtbA8CZ79WJry2HbRiFGx6WZN7uspho6A4jM+TxmuqwWL2EEfJEMw6BIYgRncIZyqp4vEXw3U/97ZjQ33dKUgg==}
'@types/estree@1.0.9': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -640,6 +655,9 @@ packages:
peerDependencies: peerDependencies:
vue: '>=3.2.0' vue: '>=3.2.0'
argparse@3.0.2:
resolution: {integrity: sha512-mFdDM6WqWKraGLsVb+C9CahPnzTXOefAOLq3jYcca2YZ8bEWpr++Tzj+zSaKW9+X9L5uSxcm1AZ3Y6aZJ09OhQ==}
array-tree-filter@2.1.0: array-tree-filter@2.1.0:
resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==} resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==}
@@ -701,6 +719,10 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
entities@8.1.0:
resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==}
engines: {node: '>=20.19.0'}
escape-string-regexp@5.0.0: escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -907,6 +929,9 @@ packages:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
linkify-it@6.1.0:
resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==}
local-pkg@1.2.1: local-pkg@1.2.1:
resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -931,6 +956,13 @@ packages:
magic-string@1.4.3: magic-string@1.4.3:
resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==}
markdown-it@15.0.2:
resolution: {integrity: sha512-q4IGxMv56jCqT4OCRCADBoDP3LO4MhmTXjFbphHPXs4g3j9Xg5RDnxqN8IF/3vIWEU+VCnUq+7JUg/cfy2E6Qw==}
hasBin: true
mdurl@2.1.0:
resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==}
mlly@1.8.2: mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
@@ -991,6 +1023,10 @@ packages:
resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
quansync@0.2.11: quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -1050,6 +1086,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
uc.micro@3.0.0:
resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==}
ufo@1.6.4: ufo@1.6.4:
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
@@ -1543,6 +1582,14 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.12.3 '@tauri-apps/api': 2.12.3
'@tauri-apps/plugin-process@2.4.0':
dependencies:
'@tauri-apps/api': 2.12.3
'@tauri-apps/plugin-updater@2.13.3':
dependencies:
'@tauri-apps/api': 2.12.3
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
'@types/node@26.6.5': '@types/node@26.6.5':
@@ -1703,6 +1750,8 @@ snapshots:
vue-types: 3.0.2(vue@3.5.43(typescript@6.0.3)) vue-types: 3.0.2(vue@3.5.43(typescript@6.0.3))
warning: 4.0.3 warning: 4.0.3
argparse@3.0.2: {}
array-tree-filter@2.1.0: {} array-tree-filter@2.1.0: {}
ast-kit@2.2.0: ast-kit@2.2.0:
@@ -1755,6 +1804,8 @@ snapshots:
entities@7.0.1: {} entities@7.0.1: {}
entities@8.1.0: {}
escape-string-regexp@5.0.0: {} escape-string-regexp@5.0.0: {}
estree-walker@2.0.2: {} estree-walker@2.0.2: {}
@@ -1892,6 +1943,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0
linkify-it@6.1.0:
dependencies:
uc.micro: 3.0.0
local-pkg@1.2.1: local-pkg@1.2.1:
dependencies: dependencies:
mlly: 1.8.2 mlly: 1.8.2
@@ -1918,6 +1973,17 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/sourcemap-codec': 1.6.0
markdown-it@15.0.2:
dependencies:
argparse: 3.0.2
entities: 8.1.0
linkify-it: 6.1.0
mdurl: 2.1.0
punycode.js: 2.3.1
uc.micro: 3.0.0
mdurl@2.1.0: {}
mlly@1.8.2: mlly@1.8.2:
dependencies: dependencies:
acorn: 8.19.0 acorn: 8.19.0
@@ -1974,6 +2040,8 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.2 source-map-js: 1.2.2
punycode.js@2.3.1: {}
quansync@0.2.11: {} quansync@0.2.11: {}
readdirp@5.1.1: {} readdirp@5.1.1: {}
@@ -2038,6 +2106,8 @@ snapshots:
typescript@6.0.3: {} typescript@6.0.3: {}
uc.micro@3.0.0: {}
ufo@1.6.4: {} ufo@1.6.4: {}
undici-types@8.9.0: {} undici-types@8.9.0: {}
+2
View File
@@ -6,6 +6,8 @@
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default", "opener:default",
"updater:default",
"process:default",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-minimize", "core:window:allow-minimize",
"core:window:allow-unminimize", "core:window:allow-unminimize",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 777 B

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1020 B

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+3 -2
View File
@@ -28,6 +28,7 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"createUpdaterArtifacts": true,
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
@@ -39,9 +40,9 @@
"plugins": { "plugins": {
"updater": { "updater": {
"active": true, "active": true,
"dialog": 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"],
"pubkey": "" "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0REVGMTREODFFOTI2MUEKUldRYUp1bUJUZkhlNU1qRmlPQm5xSmdQd24waFltVmNtTjlUallYVmdva1VGNkp5bTVGcVlsZUgK"
} }
} }
} }
+46
View File
@@ -0,0 +1,46 @@
import type { Update } from "@tauri-apps/plugin-updater"
import { isTauri } from "@/utils/window"
/** 项目 GitHub 仓库地址(与 tauri.conf.json 中 updater 端点保持一致) */
export const PROJECT_GITHUB_URL = "https://github.com/SparksFly91/Frpilot"
/**
* 检查是否有可用更新。
*
* 非 Tauri 环境(纯浏览器调试)下无法调用插件,直接返回 null。
*/
export async function checkForAppUpdate(): Promise<Update | null> {
if (!isTauri()) return null
const { check } = await import("@tauri-apps/plugin-updater")
return check()
}
/** 重启应用(macOS / Linux 安装更新后需要重启才能运行新版本) */
export async function relaunchApp() {
if (!isTauri()) return
const { relaunch } = await import("@tauri-apps/plugin-process")
await relaunch()
}
/** 用系统默认浏览器打开外部链接 */
export async function openExternal(url: string) {
if (!isTauri()) {
// 浏览器调试环境退化为新标签页打开
window.open(url, "_blank", "noopener,noreferrer")
return
}
const { openUrl } = await import("@tauri-apps/plugin-opener")
await openUrl(url)
}
/** 打开项目 GitHub 仓库首页 */
export function openProjectRepo() {
return openExternal(PROJECT_GITHUB_URL)
}
/** 读取当前应用版本(来自 tauri.conf.json / package.json) */
export async function getAppVersion(): Promise<string> {
if (!isTauri()) return "dev"
const { getVersion } = await import("@tauri-apps/api/app")
return getVersion()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 676 KiB

+258 -1
View File
@@ -85,7 +85,39 @@ body {
} }
/* /*
* 表单弹窗通用布局: * 透明磨砂玻璃卡片(服务器 / 隧道 / Frpc 列表卡片)
* 叠加在 .glass-panel 之上,用 .ant-card 提升优先级,
* 覆盖 Antd 运行时注入的卡片背景与边框(需 !important 才能稳定生效)。
*/
.glass-card.ant-card {
border: 1px solid rgba(255, 255, 255, 0.5) !important;
background: rgba(255, 255, 255, 0.5) !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;
transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.glass-card.ant-card:hover {
border-color: rgba(99, 102, 241, 0.45) !important;
background: rgba(255, 255, 255, 0.62) !important;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.14) !important;
}
.dark .glass-card.ant-card {
border-color: rgba(255, 255, 255, 0.1) !important;
background: rgba(30, 41, 59, 0.5) !important;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.35) !important;
}
.dark .glass-card.ant-card:hover {
border-color: rgba(129, 140, 248, 0.5) !important;
background: rgba(30, 41, 59, 0.62) !important;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45) !important;
}
/*
* 表单弹窗通用布局 + 透明磨砂玻璃背景:
* 1. 弹窗整体高度不超过窗口的 80%; * 1. 弹窗整体高度不超过窗口的 80%;
* 2. 内容过多时仅弹窗主体(body)滚动,标题与底部按钮固定可见; * 2. 内容过多时仅弹窗主体(body)滚动,标题与底部按钮固定可见;
* 3. 只保留纵向滚动条,避免出现横向滚动条。 * 3. 只保留纵向滚动条,避免出现横向滚动条。
@@ -99,11 +131,29 @@ body {
flex-direction: column; flex-direction: column;
max-height: 80vh; max-height: 80vh;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.62) !important;
backdrop-filter: blur(24px) saturate(170%);
-webkit-backdrop-filter: blur(24px) saturate(170%);
box-shadow: 0 16px 48px rgba(15, 23, 42, 0.18) !important;
} }
.form-modal-wrap .ant-modal-header, .form-modal-wrap .ant-modal-header,
.form-modal-wrap .ant-modal-footer { .form-modal-wrap .ant-modal-footer {
flex: 0 0 auto; flex: 0 0 auto;
border-color: rgba(15, 23, 42, 0.06);
background: transparent !important;
}
.dark .form-modal-wrap .ant-modal-content {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(30, 41, 59, 0.62) !important;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5) !important;
}
.dark .form-modal-wrap .ant-modal-header,
.dark .form-modal-wrap .ant-modal-footer {
border-color: rgba(255, 255, 255, 0.08);
} }
.form-modal-wrap .ant-modal-body { .form-modal-wrap .ant-modal-body {
@@ -113,6 +163,213 @@ body {
overflow-y: auto; overflow-y: auto;
} }
/*
* 更新弹窗:高度不超过窗口的 60%,内容过多时仅主体(body)滚动,
* 标题与底部按钮固定可见;沿用磨砂玻璃质感。
*/
.update-modal-wrap .ant-modal {
padding-bottom: 0;
}
.update-modal-wrap .ant-modal-content {
display: flex;
flex-direction: column;
max-height: 60vh;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.72) !important;
backdrop-filter: blur(24px) saturate(170%);
-webkit-backdrop-filter: blur(24px) saturate(170%);
box-shadow: 0 16px 48px rgba(15, 23, 42, 0.18) !important;
}
.update-modal-wrap .ant-modal-header,
.update-modal-wrap .ant-modal-footer {
flex: 0 0 auto;
border-color: rgba(15, 23, 42, 0.06);
background: transparent !important;
}
.dark .update-modal-wrap .ant-modal-content {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(30, 41, 59, 0.72) !important;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5) !important;
}
.dark .update-modal-wrap .ant-modal-header,
.dark .update-modal-wrap .ant-modal-footer {
border-color: rgba(255, 255, 255, 0.08);
}
.update-modal-wrap .ant-modal-body {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
}
/*
* Markdown 渲染内容(更新日志等)的排版。类名唯一,使用全局样式;
* 内容由 v-html 注入,不参与组件 scoped 样式,故放在此处统一维护。
*/
.markdown-body {
font-size: 13px;
line-height: 1.75;
word-break: break-word;
}
.markdown-body > :first-child {
margin-top: 0 !important;
}
.markdown-body > :last-child {
margin-bottom: 0 !important;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin: 14px 0 8px;
font-weight: 600;
line-height: 1.4;
}
.markdown-body h1 {
font-size: 17px;
}
.markdown-body h2 {
font-size: 15px;
}
.markdown-body h3 {
font-size: 14px;
}
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
font-size: 13px;
}
.markdown-body p {
margin: 8px 0;
}
.markdown-body ul,
.markdown-body ol {
margin: 8px 0;
padding-left: 20px;
}
.markdown-body ul {
list-style: disc;
}
.markdown-body ol {
list-style: decimal;
}
.markdown-body li {
margin: 4px 0;
}
.markdown-body li > ul,
.markdown-body li > ol {
margin: 4px 0;
}
.markdown-body a {
color: #6366f1;
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
.markdown-body code {
padding: 1px 5px;
border-radius: 5px;
background: rgba(148, 163, 184, 0.22);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.markdown-body pre {
margin: 10px 0;
padding: 10px 12px;
border-radius: 8px;
background: rgba(15, 23, 42, 0.06);
overflow-x: auto;
}
.markdown-body pre code {
padding: 0;
background: transparent;
font-size: 12px;
}
.markdown-body blockquote {
margin: 8px 0;
padding: 2px 12px;
border-left: 3px solid rgba(99, 102, 241, 0.5);
opacity: 0.85;
}
.markdown-body hr {
margin: 12px 0;
border: none;
border-top: 1px solid rgba(15, 23, 42, 0.1);
}
.markdown-body table {
width: 100%;
margin: 10px 0;
border-collapse: collapse;
font-size: 12px;
}
.markdown-body th,
.markdown-body td {
padding: 6px 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
}
.markdown-body th {
background: rgba(148, 163, 184, 0.14);
font-weight: 600;
}
.markdown-body img {
max-width: 100%;
border-radius: 6px;
}
.dark .markdown-body a {
color: #a5b4fc;
}
.dark .markdown-body pre {
background: rgba(255, 255, 255, 0.06);
}
.dark .markdown-body hr {
border-top-color: rgba(255, 255, 255, 0.12);
}
.dark .markdown-body th,
.dark .markdown-body td {
border-color: rgba(255, 255, 255, 0.14);
}
.dark .markdown-body th {
background: rgba(255, 255, 255, 0.08);
}
/* 细滚动条 */ /* 细滚动条 */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
+14 -7
View File
@@ -27,18 +27,17 @@
</svg> </svg>
</button> </button>
<button <button
class="traffic-light bg-[#28c840]" class="traffic-light traffic-light--disabled bg-[#28c840]"
:class="{ 'traffic-light--idle': !focused }" :title="t('titlebar.maximizeDisabled')"
:title="t('titlebar.maximize')" disabled
@click="toggleMaximizeWindow()"
> >
<svg viewBox="0 0 12 12" class="light-icon opacity-0 group-hover/lights:opacity-100"> <svg viewBox="0 0 12 12" class="light-icon opacity-0">
<path d="M3.5 3.5h5v5h-5z" /> <path d="M3.5 3.5h5v5h-5z" />
</svg> </svg>
</button> </button>
</div> </div>
<!-- 右侧:语言 / 主题 / 设置 / 最小化 / 关闭 --> <!-- 右侧:语言 / 主题 / 检查更新 / GitHub / 设置(最小化与关闭由左侧红绿灯负责) -->
<TitleBarActions @settings="emit('settings')" /> <TitleBarActions @settings="emit('settings')" />
</div> </div>
</template> </template>
@@ -46,7 +45,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from "vue-i18n" import { useI18n } from "vue-i18n"
import TitleBarActions from "./TitleBarActions.vue" import TitleBarActions from "./TitleBarActions.vue"
import { closeWindow, minimizeWindow, toggleMaximizeWindow } from "@/utils/window" import { closeWindow, minimizeWindow } from "@/utils/window"
const emit = defineEmits<{ settings: [] }>() const emit = defineEmits<{ settings: [] }>()
@@ -79,6 +78,14 @@ const focused = useWindowFocus()
background-color: #cbd5e1 !important; background-color: #cbd5e1 !important;
} }
/* macOS 不需要最大化:绿灯禁用置灰 */
.traffic-light--disabled,
.traffic-light:disabled {
cursor: not-allowed;
filter: saturate(0.35) brightness(1.05);
opacity: 0.55;
}
.light-icon { .light-icon {
height: 8px; height: 8px;
width: 8px; width: 8px;
+29
View File
@@ -0,0 +1,29 @@
<template>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="markdown-body" v-html="html" @click="onActivate" @auxclick="onActivate" />
</template>
<script setup lang="ts">
import { openExternal } from "@/api/updater"
import { renderMarkdown } from "@/utils/markdown"
const props = defineProps<{
/** Markdown 源文本 */
source?: string | null
}>()
const html = computed(() => renderMarkdown(props.source))
/** 拦截链接点击,统一用系统默认浏览器打开,避免 webview 自身跳转 */
function onActivate(e: MouseEvent) {
const target = e.target
const anchor = target instanceof Element ? target.closest("a") : null
if (!(anchor instanceof HTMLAnchorElement)) return
const href = anchor.getAttribute("href")
if (!href || href.startsWith("#")) return
e.preventDefault()
void openExternal(href)
}
</script>
+42 -2
View File
@@ -37,6 +37,27 @@
</template> </template>
</a-button> </a-button>
<!-- 检查更新 -->
<a-button
type="text"
class="tb-btn"
:title="t('titlebar.checkUpdate')"
:loading="updateStore.checking"
@click="onCheckUpdate"
>
<template #icon><CloudSyncOutlined /></template>
</a-button>
<!-- GitHub -->
<a-button
type="text"
class="tb-btn"
:title="t('titlebar.github')"
@click="onOpenGithub"
>
<template #icon><GithubOutlined /></template>
</a-button>
<!-- 设置 --> <!-- 设置 -->
<a-button <a-button
type="text" type="text"
@@ -47,8 +68,9 @@
<template #icon><SettingOutlined /></template> <template #icon><SettingOutlined /></template>
</a-button> </a-button>
<!-- 最小化 --> <!-- 最小化(macOS 由左侧红绿灯负责,此处隐藏) -->
<a-button <a-button
v-if="!isMac"
type="text" type="text"
class="tb-btn" class="tb-btn"
:title="t('titlebar.minimize')" :title="t('titlebar.minimize')"
@@ -57,8 +79,9 @@
<template #icon><MinusOutlined /></template> <template #icon><MinusOutlined /></template>
</a-button> </a-button>
<!-- 关闭 --> <!-- 关闭(macOS 由左侧红绿灯负责,此处隐藏) -->
<a-button <a-button
v-if="!isMac"
type="text" type="text"
class="tb-btn tb-btn-close" class="tb-btn tb-btn-close"
:title="t('titlebar.close')" :title="t('titlebar.close')"
@@ -74,6 +97,8 @@ import {
BulbFilled, BulbFilled,
BulbOutlined, BulbOutlined,
CloseOutlined, CloseOutlined,
CloudSyncOutlined,
GithubOutlined,
MinusOutlined, MinusOutlined,
SettingOutlined, SettingOutlined,
TranslationOutlined, TranslationOutlined,
@@ -81,17 +106,32 @@ import {
import { useI18n } from "vue-i18n" import { useI18n } from "vue-i18n"
import type { MenuProps } from "ant-design-vue" import type { MenuProps } from "ant-design-vue"
import { useAppStore } from "@/stores/app" import { useAppStore } from "@/stores/app"
import { useUpdateStore } from "@/stores/update"
import { languageOptions, type Language } from "@/i18n" import { languageOptions, type Language } from "@/i18n"
import { closeWindow, minimizeWindow } from "@/utils/window" import { closeWindow, minimizeWindow } from "@/utils/window"
import { openProjectRepo } from "@/api/updater"
import { usePlatform } from "@/composables/usePlatform"
const emit = defineEmits<{ settings: [] }>() const emit = defineEmits<{ settings: [] }>()
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const updateStore = useUpdateStore()
const { isMac } = usePlatform()
const onLanguageClick: MenuProps["onClick"] = ({ key }) => { const onLanguageClick: MenuProps["onClick"] = ({ key }) => {
appStore.setLanguage(key as Language) appStore.setLanguage(key as Language)
} }
/** 顶部栏手动检查更新(非静默,无更新 / 失败都会给出提示) */
function onCheckUpdate() {
void updateStore.checkForUpdate(false)
}
/** 打开项目 GitHub 仓库 */
function onOpenGithub() {
void openProjectRepo()
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
+155
View File
@@ -0,0 +1,155 @@
<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"
>
<!-- 俏皮的问候 + 版本变化 -->
<div class="update-hero">
<span class="update-hero-emoji">🎉</span>
<div class="min-w-0 flex-1">
<p class="m-0 text-sm font-medium">{{ t("update.hello") }}</p>
<div class="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
<a-tag class="!me-0">v{{ info?.currentVersion }}</a-tag>
<ArrowRightOutlined class="text-[10px] opacity-50" />
<a-tag color="green" class="!me-0">v{{ info?.version }}</a-tag>
<span v-if="releaseDate" class="ml-1 opacity-50">{{ releaseDate }}</span>
</div>
</div>
</div>
<p class="update-tip">{{ t("update.tip") }}</p>
<!-- 更新日志 -->
<div class="update-log-header">
<BulbOutlined />
<span>{{ t("update.log") }}</span>
</div>
<div class="update-log">
<MarkdownBlock v-if="hasLog" :source="info?.body" />
<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") }}
</a-button>
</div>
</div>
</template>
</a-modal>
</template>
<script setup lang="ts">
import dayjs from "dayjs"
import { ArrowRightOutlined, BulbOutlined } from "@ant-design/icons-vue"
import { useI18n } from "vue-i18n"
import MarkdownBlock from "@/components/MarkdownBlock.vue"
import { useUpdateStore } from "@/stores/update"
const { t } = useI18n()
const store = useUpdateStore()
const info = computed(() => store.info)
const modalTitle = computed(() => t("update.title", { version: info.value?.version ?? "" }))
const releaseDate = computed(() =>
info.value?.date ? dayjs(info.value.date).format("YYYY-MM-DD") : "",
)
/** 是否存在可渲染的更新日志内容 */
const hasLog = computed(() => Boolean(info.value?.body?.trim()))
function onCancel() {
store.cancel()
}
function onIgnore() {
store.ignoreVersion()
}
function onConfirm() {
store.install()
}
</script>
<style scoped lang="scss">
.update-hero {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.14), rgba(34, 211, 238, 0.14));
}
.dark .update-hero {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.22), rgba(34, 211, 238, 0.18));
}
.update-hero-emoji {
font-size: 26px;
line-height: 1;
}
.update-tip {
margin: 14px 2px 10px;
font-size: 12px;
opacity: 0.65;
}
.update-log-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
}
.update-log {
word-break: break-word;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(148, 163, 184, 0.12);
opacity: 0.9;
}
.dark .update-log {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(148, 163, 184, 0.14);
}
.update-progress {
margin-top: 14px;
}
.update-footer {
display: flex;
align-items: center;
gap: 8px;
}
</style>
+1 -1
View File
@@ -10,7 +10,7 @@
<span class="text-sm font-semibold tracking-wide">Frpilot</span> <span class="text-sm font-semibold tracking-wide">Frpilot</span>
</div> </div>
<!-- 右侧:语言 / 主题 / 设置 / 最小化 / 关闭 --> <!-- 右侧:语言 / 主题 / 设置 / 最小化 / 关闭(macOS 下隐藏最小化与关闭) -->
<TitleBarActions @settings="emit('settings')" /> <TitleBarActions @settings="emit('settings')" />
</div> </div>
</template> </template>
+1 -12
View File
@@ -1,5 +1,5 @@
<template> <template>
<a-card class="frp-card" :body-style="{ padding: '16px' }" hoverable> <a-card class="glass-card" :body-style="{ padding: '16px' }" hoverable>
<!-- 头部:版本 + 状态标签 --> <!-- 头部:版本 + 状态标签 -->
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
@@ -162,14 +162,3 @@ function formatDate(value: string) {
return value ? dayjs(value).format("YYYY-MM-DD") : "-" return value ? dayjs(value).format("YYYY-MM-DD") : "-"
} }
</script> </script>
<style scoped lang="scss">
.frp-card {
border-color: rgba(15, 23, 42, 0.08);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.dark .frp-card {
border-color: rgba(255, 255, 255, 0.1);
}
</style>
+1 -12
View File
@@ -1,5 +1,5 @@
<template> <template>
<a-card class="server-card" :body-style="{ padding: '16px' }" hoverable> <a-card class="glass-card" :body-style="{ padding: '16px' }" hoverable>
<!-- 头部:名称 + 状态标签 + 操作 --> <!-- 头部:名称 + 状态标签 + 操作 -->
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
@@ -88,14 +88,3 @@ function onEnabledChange(checked: SwitchValue) {
emit("toggleEnabled", Boolean(checked)) emit("toggleEnabled", Boolean(checked))
} }
</script> </script>
<style scoped lang="scss">
.server-card {
border-color: rgba(15, 23, 42, 0.08);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.dark .server-card {
border-color: rgba(255, 255, 255, 0.1);
}
</style>
+1 -12
View File
@@ -1,5 +1,5 @@
<template> <template>
<a-card class="tunnel-card" :body-style="{ padding: '16px' }" hoverable> <a-card class="glass-card" :body-style="{ padding: '16px' }" hoverable>
<!-- 头部:名称 + 状态标签 + 操作 --> <!-- 头部:名称 + 状态标签 + 操作 -->
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
@@ -112,14 +112,3 @@ function onEnabledChange(checked: SwitchValue) {
emit("toggleEnabled", Boolean(checked)) emit("toggleEnabled", Boolean(checked))
} }
</script> </script>
<style scoped lang="scss">
.tunnel-card {
border-color: rgba(15, 23, 42, 0.08);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.dark .tunnel-card {
border-color: rgba(255, 255, 255, 0.1);
}
</style>
+3 -3
View File
@@ -32,9 +32,9 @@ export function setI18nLanguage(language: Language) {
i18n.global.locale.value = language i18n.global.locale.value = language
} }
/** 在非组件环境(如路由守卫)中获取翻译函数 */ /** 在非组件环境(如路由守卫、store)中获取翻译函数 */
export function translate(key: string) { export function translate(key: string, params?: Record<string, unknown>) {
return i18n.global.t(key) return i18n.global.t(key, params ?? {})
} }
export default i18n export default i18n
+43 -1
View File
@@ -8,9 +8,12 @@
"theme": "Theme", "theme": "Theme",
"lightMode": "Light mode", "lightMode": "Light mode",
"darkMode": "Dark mode", "darkMode": "Dark mode",
"checkUpdate": "Check for updates",
"github": "GitHub",
"settings": "Settings", "settings": "Settings",
"minimize": "Minimize", "minimize": "Minimize",
"maximize": "Maximize", "maximize": "Maximize",
"maximizeDisabled": "Maximizing is not needed on macOS",
"restore": "Restore", "restore": "Restore",
"close": "Close" "close": "Close"
}, },
@@ -57,7 +60,8 @@
"title": "Settings", "title": "Settings",
"placeholder": "The settings page is under construction.", "placeholder": "The settings page is under construction.",
"menu": { "menu": {
"appearance": "Appearance" "appearance": "Appearance",
"about": "About"
}, },
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
@@ -210,6 +214,44 @@
"error": "Download failed" "error": "Download failed"
} }
}, },
"about": {
"title": "About Frpilot",
"subtitle": "A lightweight, friendly visual client for frp",
"introTitle": "Introduction",
"intro": "Frpilot 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.",
"step2Title": "Configure a server",
"step2Desc": "Add the frps address, port and auth token under \"Servers\", then enable one of them.",
"step3Title": "Configure tunnels",
"step3Desc": "Create and enable the tunnels you need (TCP / HTTP, etc.) under \"Tunnels\".",
"step4Title": "One-click start",
"step4Desc": "Head back to the \"Dashboard\" and start Frpc with a single click, watching live logs and status."
},
"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"
},
"update": {
"title": "New version v{version} is available ✨",
"hello": "Ta-da~ A brand new version is ready and waiting for you (๑•̀ㅂ•́)و✧",
"tip": "Here's what this little update brings — scroll down and take a peek~",
"log": "What's new",
"emptyLog": "No release notes this time — the author was too lazy, but updating is still a good idea~",
"ignore": "Ignore this version",
"confirm": "Update now",
"downloading": "Downloading the package… hang tight, almost there!",
"desktopOnly": "Update checks are unavailable in dev mode. Please use the desktop app~",
"upToDate": "You're already on the latest version, enjoy~",
"checkFailed": "Update check failed: {message}",
"ignoreSuccess": "Version ignored. We won't bother you again~"
},
"common": { "common": {
"developing": "This feature is under construction", "developing": "This feature is under construction",
"confirm": "OK", "confirm": "OK",
+43 -1
View File
@@ -8,9 +8,12 @@
"theme": "主题", "theme": "主题",
"lightMode": "浅色模式", "lightMode": "浅色模式",
"darkMode": "深色模式", "darkMode": "深色模式",
"checkUpdate": "检查更新",
"github": "GitHub",
"settings": "设置", "settings": "设置",
"minimize": "最小化", "minimize": "最小化",
"maximize": "最大化", "maximize": "最大化",
"maximizeDisabled": "macOS 下无需最大化窗口",
"restore": "还原", "restore": "还原",
"close": "关闭" "close": "关闭"
}, },
@@ -57,7 +60,8 @@
"title": "设置", "title": "设置",
"placeholder": "设置页面开发中,敬请期待。", "placeholder": "设置页面开发中,敬请期待。",
"menu": { "menu": {
"appearance": "外观设置" "appearance": "外观设置",
"about": "关于"
}, },
"appearance": { "appearance": {
"title": "外观设置", "title": "外观设置",
@@ -210,6 +214,44 @@
"error": "下载失败" "error": "下载失败"
} }
}, },
"about": {
"title": "关于 Frpilot",
"subtitle": "轻量、顺手的 frp 可视化桌面客户端",
"introTitle": "项目介绍",
"intro": "Frpilot 是一个基于 Tauri 2 + Vue 3 + TypeScript 打造的 frp 可视化客户端,帮你用图形界面管理 frpc 版本、服务器与隧道,从此告别手写 frpc.toml。",
"flowTitle": "使用流程",
"flow": {
"step1Title": "下载 frpc",
"step1Desc": "进入「Frpc 下载」,按平台与架构下载,或导入本地已有的 frpc。",
"step2Title": "配置服务器",
"step2Desc": "在「服务器」中填写 frps 地址、端口与鉴权 Token,并启用其中一台。",
"step3Title": "配置隧道",
"step3Desc": "在「隧道列表」里按需创建 TCP / HTTP 等类型的隧道并启用。",
"step4Title": "一键启动",
"step4Desc": "回到「仪表盘」一键启动 Frpc,实时查看运行日志与状态。"
},
"repoTitle": "开源地址",
"repoDesc": "欢迎 Star、提 Issue 或一起参与共建~",
"openRepo": "打开 GitHub",
"versionTitle": "版本信息",
"currentVersion": "当前版本",
"checkUpdate": "检查更新",
"newVersion": "发现新版本 v{version}"
},
"update": {
"title": "发现新版本 v{version} ✨",
"hello": "呀吼~新版本已经打包好,就等你翻牌啦 (๑•̀ㅂ•́)و✧",
"tip": "这次的小更新带来了下面这些变化,往下滑看看嘛~",
"log": "更新日志",
"emptyLog": "作者这次偷懒没写更新说明,总之先更新了就对了~",
"ignore": "忽略该版本",
"confirm": "确认更新",
"downloading": "正在下载安装包…坐好扶稳,马上就到!",
"desktopOnly": "开发模式下无法检查更新,请在桌面应用中使用~",
"upToDate": "已经是最新版本啦,安心享用~",
"checkFailed": "检查更新失败:{message}",
"ignoreSuccess": "已经帮你忽略这个版本啦,下次不再打扰~"
},
"common": { "common": {
"developing": "功能开发中,敬请期待", "developing": "功能开发中,敬请期待",
"confirm": "确定", "confirm": "确定",
+11
View File
@@ -12,17 +12,28 @@
<router-view /> <router-view />
</main> </main>
</div> </div>
<!-- 全局更新弹窗 -->
<UpdateModal />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Header from "./header/Header.vue" import Header from "./header/Header.vue"
import Aside from "./aside/Aside.vue" import Aside from "./aside/Aside.vue"
import UpdateModal from "@/components/UpdateModal.vue"
import { useUpdateStore } from "@/stores/update"
const router = useRouter() const router = useRouter()
const updateStore = useUpdateStore()
/** 点击标题栏“设置”按钮 → 跳转设置页 */ /** 点击标题栏“设置”按钮 → 跳转设置页 */
function goToSetting() { function goToSetting() {
router.push("/setting") router.push("/setting")
} }
// 应用启动后静默检查一次更新(已忽略的版本不会打扰用户)
onMounted(() => {
updateStore.checkForUpdate(true)
})
</script> </script>
+6
View File
@@ -45,6 +45,12 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/setting/Appearance.vue"), component: () => import("@/views/setting/Appearance.vue"),
meta: { title: "settings.menu.appearance" }, meta: { title: "settings.menu.appearance" },
}, },
{
path: "about",
name: "About",
component: () => import("@/views/setting/About.vue"),
meta: { title: "settings.menu.about" },
},
], ],
}, },
], ],
+173
View File
@@ -0,0 +1,173 @@
import { defineStore } from "pinia"
import { message } from "ant-design-vue"
import type { Update } from "@tauri-apps/plugin-updater"
import { checkForAppUpdate, relaunchApp } from "@/api/updater"
import { isTauri } from "@/utils/window"
import { translate } from "@/i18n"
/** 供弹窗展示的更新信息(纯数据,不含方法) */
export interface UpdateInfo {
version: string
currentVersion: string
date?: string
body?: string
}
/**
* 应用更新状态:检查 / 忽略版本 / 下载安装。
*
* 更新弹窗由 {@link UpdateModal} 全局渲染,顶部栏与「关于」页共用此处的检查逻辑。
*/
export const useUpdateStore = defineStore("update", () => {
/** 用户选择「忽略该版本」后持久化的版本号,静默检查时不再提示 */
const ignoredVersion = useStorage<string>("frpilot:ignored-update", "")
const checking = ref(false)
const installing = ref(false)
const modalOpen = ref(false)
const info = ref<UpdateInfo | null>(null)
/** 下载进度 */
const downloaded = ref(0)
const contentLength = ref<number | null>(null)
const progress = ref(0)
/** 持有原始 Update 资源(含下载 / 安装方法),不放进响应式以免代理破坏其方法 */
let pending: Update | null = null
const hasUpdate = computed(() => info.value !== null)
function errMsg(e: unknown) {
return e instanceof Error ? e.message : String(e)
}
function resetProgress() {
downloaded.value = 0
contentLength.value = null
progress.value = 0
}
/**
* 检查更新。
*
* @param silent 静默检查(启动时自动调用):无更新或已忽略该版本时都不打扰用户。
*/
async function checkForUpdate(silent = false) {
if (checking.value) return
if (!isTauri()) {
if (!silent) message.info(translate("update.desktopOnly"))
return
}
checking.value = true
try {
const update = await checkForAppUpdate()
if (!update) {
if (!silent) message.success(translate("update.upToDate"))
return
}
// 静默检查时跳过用户已忽略的版本
if (silent && ignoredVersion.value === update.version) {
await update.close()
return
}
pending = update
info.value = {
version: update.version,
currentVersion: update.currentVersion,
date: update.date,
body: update.body,
}
resetProgress()
modalOpen.value = true
} catch (e) {
if (!silent) {
message.error(translate("update.checkFailed", { message: errMsg(e) }))
}
} finally {
checking.value = false
}
}
/** 放弃当前更新:关闭弹窗并释放后端资源 */
async function discard() {
const update = pending
pending = null
info.value = null
modalOpen.value = false
resetProgress()
try {
await update?.close()
} catch {
// 资源释放失败不影响交互
}
}
/** 取消(本次不更新,下次启动仍会提示) */
function cancel() {
void discard()
}
/** 忽略该版本(持久化,静默检查不再提示) */
async function ignoreVersion() {
if (info.value) ignoredVersion.value = info.value.version
await discard()
message.success(translate("update.ignoreSuccess"))
}
/** 下载并安装更新,随后重启应用以运行新版本 */
async function install() {
if (!pending || installing.value) return
installing.value = true
resetProgress()
try {
await pending.downloadAndInstall((event) => {
switch (event.event) {
case "Started":
contentLength.value = event.data.contentLength ?? null
break
case "Progress":
downloaded.value += event.data.chunkLength
progress.value = contentLength.value
? Math.min(100, Math.round((downloaded.value / contentLength.value) * 100))
: Math.min(99, progress.value + 1)
break
case "Finished":
progress.value = 100
break
}
})
// Windows 会在安装后自动退出;macOS / Linux 需要主动重启
pending = null
info.value = null
modalOpen.value = false
await relaunchApp()
} catch (e) {
message.error(errMsg(e))
} finally {
installing.value = false
}
}
return {
ignoredVersion,
checking,
installing,
modalOpen,
info,
downloaded,
contentLength,
progress,
hasUpdate,
checkForUpdate,
cancel,
ignoreVersion,
install,
}
})
+3
View File
@@ -36,12 +36,14 @@ declare module 'vue' {
ASpace: typeof import('ant-design-vue/es')['Space'] ASpace: typeof import('ant-design-vue/es')['Space']
ASpin: typeof import('ant-design-vue/es')['Spin'] ASpin: typeof import('ant-design-vue/es')['Spin']
AStatistic: typeof import('ant-design-vue/es')['Statistic'] AStatistic: typeof import('ant-design-vue/es')['Statistic']
ASteps: typeof import('ant-design-vue/es')['Steps']
ASwitch: typeof import('ant-design-vue/es')['Switch'] ASwitch: typeof import('ant-design-vue/es')['Switch']
ATag: typeof import('ant-design-vue/es')['Tag'] ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea'] ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATooltip: typeof import('ant-design-vue/es')['Tooltip'] ATooltip: typeof import('ant-design-vue/es')['Tooltip']
FrpCard: typeof import('./../components/frp/FrpCard.vue')['default'] FrpCard: typeof import('./../components/frp/FrpCard.vue')['default']
MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default'] MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default']
MarkdownBlock: typeof import('./../components/MarkdownBlock.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
ServerCard: typeof import('./../components/server/ServerCard.vue')['default'] ServerCard: typeof import('./../components/server/ServerCard.vue')['default']
@@ -49,6 +51,7 @@ declare module 'vue' {
TitleBarActions: typeof import('./../components/TitleBarActions.vue')['default'] TitleBarActions: typeof import('./../components/TitleBarActions.vue')['default']
TunnelCard: typeof import('./../components/tunnel/TunnelCard.vue')['default'] TunnelCard: typeof import('./../components/tunnel/TunnelCard.vue')['default']
TunnelFormModal: typeof import('./../components/tunnel/TunnelFormModal.vue')['default'] TunnelFormModal: typeof import('./../components/tunnel/TunnelFormModal.vue')['default']
UpdateModal: typeof import('./../components/UpdateModal.vue')['default']
WindowsTitleBar: typeof import('./../components/WindowsTitleBar.vue')['default'] WindowsTitleBar: typeof import('./../components/WindowsTitleBar.vue')['default']
} }
} }
+31
View File
@@ -0,0 +1,31 @@
import MarkdownIt from "markdown-it"
/**
* 更新日志(Release Notes)Markdown 渲染器。
*
* 安全约束:
* - `html: false`:禁用原始 HTML,杜绝注入;
* - markdown-it 默认的 `validateLink` 会拦截 `javascript:` 等危险链接。
*/
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
})
// 外链在新窗口打开(链接点击在组件中已被接管,统一交由系统浏览器处理)
const defaultLinkOpen = md.renderer.rules.link_open
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const token = tokens[idx]
token.attrSet("target", "_blank")
token.attrSet("rel", "noopener noreferrer")
return defaultLinkOpen
? defaultLinkOpen(tokens, idx, options, env, self)
: self.renderToken(tokens, idx, options)
}
/** 将 Markdown 文本渲染为 HTML 字符串(空内容返回空串) */
export function renderMarkdown(source?: string | null): string {
if (!source || !source.trim()) return ""
return md.render(source)
}
+6 -1
View File
@@ -20,7 +20,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { h } from "vue" import { h } from "vue"
import { BgColorsOutlined } from "@ant-design/icons-vue" import { BgColorsOutlined, InfoCircleOutlined } from "@ant-design/icons-vue"
import { useI18n } from "vue-i18n" import { useI18n } from "vue-i18n"
import type { MenuProps } from "ant-design-vue" import type { MenuProps } from "ant-design-vue"
@@ -36,6 +36,11 @@ const menuItems = computed<MenuProps["items"]>(() => [
icon: () => h(BgColorsOutlined), icon: () => h(BgColorsOutlined),
label: t("settings.menu.appearance"), label: t("settings.menu.appearance"),
}, },
{
key: "/setting/about",
icon: () => h(InfoCircleOutlined),
label: t("settings.menu.about"),
},
]) ])
const onMenuClick: MenuProps["onClick"] = ({ key }) => { const onMenuClick: MenuProps["onClick"] = ({ key }) => {
+94
View File
@@ -0,0 +1,94 @@
<template>
<div class="max-w-3xl">
<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-2 text-sm font-medium">{{ t("about.introTitle") }}</div>
<p class="text-sm leading-relaxed opacity-80">{{ t("about.intro") }}</p>
<div class="mt-3 flex flex-wrap gap-2">
<a-tag v-for="tech in stack" :key="tech" color="blue" class="!me-0">{{ tech }}</a-tag>
</div>
</section>
<a-divider class="!my-5" />
<!-- 使用流程 -->
<section class="mb-6">
<div class="mb-3 text-sm font-medium">{{ t("about.flowTitle") }}</div>
<a-steps direction="vertical" size="small" :current="flowItems.length" :items="flowItems" />
</section>
<a-divider class="!my-5" />
<!-- 开源地址 -->
<section class="mb-6">
<div class="mb-3 text-sm font-medium">{{ t("about.repoTitle") }}</div>
<a-card class="glass-card" size="small" :body-style="{ padding: '14px 16px' }">
<div class="flex items-center gap-3">
<GithubOutlined class="text-2xl" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">SparksFly91 / Frpilot</div>
<div class="truncate text-xs opacity-60">{{ PROJECT_GITHUB_URL }}</div>
</div>
<a-button type="primary" size="small" :icon="h(LinkOutlined)" @click="onOpenRepo">
{{ t("about.openRepo") }}
</a-button>
</div>
</a-card>
<p class="mt-2 text-xs opacity-60">{{ t("about.repoDesc") }}</p>
</section>
<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-button size="small" :loading="updateStore.checking" @click="onCheckUpdate">
{{ t("about.checkUpdate") }}
</a-button>
<span v-if="updateStore.hasUpdate" class="text-xs text-[#6366f1]">
{{ t("about.newVersion", { version: updateStore.info?.version }) }}
</span>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { h } from "vue"
import { GithubOutlined, LinkOutlined } from "@ant-design/icons-vue"
import { useI18n } from "vue-i18n"
import { getAppVersion, openProjectRepo, PROJECT_GITHUB_URL } from "@/api/updater"
import { useUpdateStore } from "@/stores/update"
const { t } = useI18n()
const updateStore = useUpdateStore()
const version = ref("")
const stack = ["Tauri 2", "Vue 3", "TypeScript", "Rust", "Vite"]
const flowItems = computed(() => [
{ title: t("about.flow.step1Title"), description: t("about.flow.step1Desc") },
{ title: t("about.flow.step2Title"), description: t("about.flow.step2Desc") },
{ title: t("about.flow.step3Title"), description: t("about.flow.step3Desc") },
{ title: t("about.flow.step4Title"), description: t("about.flow.step4Desc") },
])
onMounted(async () => {
version.value = await getAppVersion()
})
function onOpenRepo() {
void openProjectRepo()
}
function onCheckUpdate() {
void updateStore.checkForUpdate(false)
}
</script>