Compare commits
2
Commits
487bde5473
...
db7b1096d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db7b1096d1 | ||
|
|
50bf5f7e36 |
@@ -67,4 +67,31 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
|
||||
- 页面展示已经配置的隧道列表,列表隧道以卡片形式展示,卡片有开关可以直接操作是否置顶和是否启用,隧道可同时启用多个,另外还需要有编辑和删除,新增和编辑使用同一个表单
|
||||
- 隧道信息在Rust端使用SQLite存储,Rust已经加入相关的sqlx依赖等,后端操作返回前端务必使用ApiResponse统一返回格式,前端也需要做好对应的类型匹配
|
||||
|
||||
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||
|
||||
#### 3.3 仪表盘页面开发
|
||||
|
||||
按照我给的图片布局开发仪表盘页面
|
||||
|
||||
- 三个卡片分别展示服务器数量、隧道数量、CPU/内存/运行状态,每个卡片都需要圆角磨砂玻璃效果
|
||||
- 三个卡片下方是快速操作,可以选择已下载的Frpc版本,右侧按钮一键启动或停止,Frpc下载功能暂未实现,先用模拟数据展示
|
||||
- 快速操作下方是Frpc运行实时日志展示,也先使用模拟数据展示
|
||||
|
||||
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||
|
||||
##### 3.3.1 仪表盘页面调整
|
||||
|
||||
- 三个卡片当中的服务器数量和隧道数量合并到一个卡片里面,这一个卡片里面上方展示服务器数量,下方展示隧道数量
|
||||
- 原本的隧道数量卡片改为展示当前运行系统主机名、系统架构、IP地址等,通过Rust端sysinfo获取
|
||||
- 其余卡片保持不动
|
||||
|
||||
#### 3.4 Frpc下载功能开发
|
||||
|
||||
- frp的GitHub仓库地址是https://github.com/fatedier/frp
|
||||
- 初始化一张表,用来保存已下载的Frpc版本和保存路径等信息,已下载的Frpc在获取到的列表里面展示为已下载等标记
|
||||
- 考虑到国内网络环境访问GitHub不稳定,所以拉取Frpc版本和下载的时候使用GitHub 镜像加速,拉取Frpc版本和下载的时候,需要根据软件运行系统架构展示对应的Frpc版本(默认情况);
|
||||
- 页面上方卡片栏里面增加一个开关选项,用于开启是否展示所有架构的Frpc;再增加一个开关选项,用于开启是否展示所有架构的Frps,可以将该软件作为frp下载器使用;再有一个刷新按钮,用于刷新信息;再加一个按钮用于导入本地已下载的Frpc
|
||||
- 点击卡片上的下载按钮,卡片下方显示一个下载进度的进度条
|
||||
- 展示获取到的frp数据使用卡片展示,卡片上有对应按钮可以点击进行下载,卡片展示的信息包含frp版本、大小、下载次数、发布日期。在有一个按钮,点击复制下载链接,便于去浏览器等环境下载。再加一个按钮删除已下载的版本
|
||||
|
||||
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||
Generated
+200
-3
@@ -603,6 +603,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core_detect"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1036,6 +1042,20 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"core_detect",
|
||||
"multiversion_no_op",
|
||||
"rustversion",
|
||||
"scopeguard",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1251,9 +1271,12 @@ name = "frpilot"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-helper",
|
||||
@@ -1263,6 +1286,7 @@ dependencies = [
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1653,6 +1677,25 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.14.2",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1793,7 +1836,9 @@ checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -1819,6 +1864,22 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.21"
|
||||
@@ -2451,6 +2512,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
@@ -2504,6 +2571,15 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -2670,6 +2746,16 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
@@ -2681,6 +2767,17 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-open-directory"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
@@ -3268,6 +3365,49 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.5"
|
||||
@@ -3303,7 +3443,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.5.0",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -3452,6 +3592,12 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -3674,6 +3820,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.24.0"
|
||||
@@ -4210,6 +4368,21 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.39.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-kit",
|
||||
"objc2-open-directory",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
@@ -4342,7 +4515,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -4577,7 +4750,7 @@ dependencies = [
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"reqwest 0.13.5",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -4855,6 +5028,16 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.6"
|
||||
@@ -4885,6 +5068,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
@@ -5347,6 +5531,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
|
||||
@@ -35,10 +35,14 @@ sqlx = { version = "0.9.0", features = [
|
||||
"chrono",
|
||||
] }
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
sysinfo = "0.39"
|
||||
tauri-plugin-dialog = "2.7.2"
|
||||
tauri-plugin-updater = "2.10.1"
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-prevent-default = "6.0.1"
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
futures-util = "0.3"
|
||||
tokio = { version = "1", features = ["fs", "io-util"] }
|
||||
|
||||
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
|
||||
[profile.release]
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use serde_json::Value;
|
||||
use sqlx::SqlitePool;
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
use tauri_helper::auto_collect_command;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::models::{
|
||||
ApiResponse, FrpDownload, FrpDownloadPayload, FrpDownloadProgress, FrpRelease,
|
||||
};
|
||||
|
||||
/// GitHub 镜像加速前缀(国内访问 GitHub 不稳定,拉取版本与下载统一走镜像)
|
||||
const GITHUB_MIRROR: &str = "https://gh-proxy.com/";
|
||||
/// frp 仓库 releases API
|
||||
const RELEASES_API: &str = "https://api.github.com/repos/fatedier/frp/releases?per_page=30";
|
||||
/// 下载进度事件名
|
||||
const PROGRESS_EVENT: &str = "frp-download-progress";
|
||||
|
||||
/// frp_downloads 表统一查询字段
|
||||
macro_rules! frp_fields {
|
||||
() => {
|
||||
"id, version, os, arch, platform, file_name, archive_path, frpc_path, frps_path, file_size, source, created_at, updated_at"
|
||||
};
|
||||
}
|
||||
|
||||
fn http_client() -> Result<reqwest::Client, reqwest::Error> {
|
||||
reqwest::Client::builder()
|
||||
.user_agent("Frpilot/0.1 (+https://github.com/fatedier/frp)")
|
||||
.connect_timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
}
|
||||
|
||||
/// 当前运行平台(os + arch),并映射为 frp 发行包的命名
|
||||
fn current_platform() -> (String, String) {
|
||||
let os = match std::env::consts::OS {
|
||||
"macos" => "darwin",
|
||||
other => other,
|
||||
}
|
||||
.to_string();
|
||||
let arch = match std::env::consts::ARCH {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
"x86" => "386",
|
||||
other => other,
|
||||
}
|
||||
.to_string();
|
||||
(os, arch)
|
||||
}
|
||||
|
||||
/// 解析发行包文件名,返回 (版本, os, arch)
|
||||
///
|
||||
/// 形如 `frp_0.71.0_windows_amd64.zip` / `frp_0.71.0_linux_arm_hf.tar.gz`,
|
||||
/// arch 部分可能包含下划线(如 arm_hf、mips_softfloat),故剩余部分整体拼接。
|
||||
fn parse_asset(name: &str) -> Option<(String, String, String)> {
|
||||
let rest = name.strip_prefix("frp_")?;
|
||||
let rest = rest
|
||||
.strip_suffix(".tar.gz")
|
||||
.or_else(|| rest.strip_suffix(".zip"))?;
|
||||
let mut parts = rest.split('_');
|
||||
let version = parts.next()?.to_string();
|
||||
let os = parts.next()?.to_string();
|
||||
let arch = parts.collect::<Vec<_>>().join("_");
|
||||
if version.is_empty() || os.is_empty() || arch.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((version, os, arch))
|
||||
}
|
||||
|
||||
/// 从文件名中解析版本号(形如 0.71.0),返回带 v 前缀的版本号
|
||||
fn parse_version(name: &str) -> Option<String> {
|
||||
for raw in name.split(|c: char| !(c.is_ascii_digit() || c == '.' || c == 'v' || c == 'V')) {
|
||||
let token = raw.trim_start_matches(|c| c == 'v' || c == 'V');
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
let valid = parts.len() >= 3
|
||||
&& parts[..3]
|
||||
.iter()
|
||||
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
|
||||
if valid {
|
||||
return Some(format!("v{}.{}.{}", parts[0], parts[1], parts[2]));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn fetch_locals(pool: &SqlitePool) -> Result<Vec<FrpDownload>, sqlx::Error> {
|
||||
sqlx::query_as::<_, FrpDownload>(concat!(
|
||||
"SELECT ",
|
||||
frp_fields!(),
|
||||
" FROM frp_downloads ORDER BY id ASC"
|
||||
))
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_local(
|
||||
pool: &SqlitePool,
|
||||
version: &str,
|
||||
platform: &str,
|
||||
) -> Result<FrpDownload, sqlx::Error> {
|
||||
sqlx::query_as::<_, FrpDownload>(concat!(
|
||||
"SELECT ",
|
||||
frp_fields!(),
|
||||
" FROM frp_downloads WHERE version = ? AND platform = ?"
|
||||
))
|
||||
.bind(version)
|
||||
.bind(platform)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 拉取 frp releases(镜像优先,失败回退直连)
|
||||
async fn fetch_releases(client: &reqwest::Client) -> Result<Vec<Value>, String> {
|
||||
let urls = [
|
||||
format!("{GITHUB_MIRROR}{RELEASES_API}"),
|
||||
RELEASES_API.to_string(),
|
||||
];
|
||||
let mut last_err = String::new();
|
||||
for url in urls {
|
||||
match client.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => match resp.json::<Value>().await {
|
||||
Ok(Value::Array(list)) => return Ok(list),
|
||||
Ok(_) => last_err = "返回数据格式异常".to_string(),
|
||||
Err(e) => last_err = e.to_string(),
|
||||
},
|
||||
Ok(resp) => last_err = format!("HTTP {}", resp.status()),
|
||||
Err(e) => last_err = e.to_string(),
|
||||
}
|
||||
}
|
||||
Err(format!("获取 Frp 版本列表失败:{last_err}"))
|
||||
}
|
||||
|
||||
/// 应用数据目录下的 frp 版本存储根目录
|
||||
fn versions_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("frp")
|
||||
.join("versions"))
|
||||
}
|
||||
|
||||
/// 发送下载进度事件
|
||||
fn emit_progress(
|
||||
app: &AppHandle,
|
||||
payload: &FrpDownloadPayload,
|
||||
downloaded: u64,
|
||||
total: u64,
|
||||
status: &str,
|
||||
message: Option<&str>,
|
||||
) {
|
||||
let percent = if total > 0 {
|
||||
((downloaded as f64 / total as f64) * 100.0).clamp(0.0, 100.0).round() as u8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let _ = app.emit(
|
||||
PROGRESS_EVENT,
|
||||
FrpDownloadProgress {
|
||||
key: payload.file_name.clone(),
|
||||
version: payload.version.clone(),
|
||||
platform: payload.platform.clone(),
|
||||
downloaded,
|
||||
total,
|
||||
percent,
|
||||
status: status.to_string(),
|
||||
message: message.map(|m| m.to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取 Frp 版本列表(返回所有架构的发行包,并合并本机已下载 / 已导入记录)
|
||||
///
|
||||
/// 前端根据「展示所有架构的 Frpc / Frps」开关在本地过滤,避免频繁请求
|
||||
/// GitHub API 触发限流;默认仅展示当前运行平台由前端完成。
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub async fn get_frp_releases(
|
||||
state: State<'_, SqlitePool>,
|
||||
) -> Result<ApiResponse<Vec<FrpRelease>>, String> {
|
||||
let client = match http_client() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Ok(ApiResponse::error(500, &e.to_string())),
|
||||
};
|
||||
let releases = match fetch_releases(&client).await {
|
||||
Ok(list) => list,
|
||||
Err(e) => return Ok(ApiResponse::error(500, &e)),
|
||||
};
|
||||
let locals = match fetch_locals(&state).await {
|
||||
Ok(list) => list,
|
||||
Err(e) => return Ok(ApiResponse::error(500, &e.to_string())),
|
||||
};
|
||||
|
||||
let (cur_os, cur_arch) = current_platform();
|
||||
let cur_platform = format!("{cur_os}_{cur_arch}");
|
||||
|
||||
let mut list: Vec<FrpRelease> = Vec::new();
|
||||
let mut matched: HashSet<String> = HashSet::new();
|
||||
|
||||
for rel in &releases {
|
||||
let version = rel
|
||||
.get("tag_name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if version.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let published_at = rel
|
||||
.get("published_at")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let Some(assets) = rel.get("assets").and_then(Value::as_array) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for asset in assets {
|
||||
let name = asset.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
let Some((_v, os, arch)) = parse_asset(name) else {
|
||||
continue;
|
||||
};
|
||||
let platform = format!("{os}_{arch}");
|
||||
let matches = os == cur_os && arch == cur_arch;
|
||||
|
||||
let size = asset.get("size").and_then(Value::as_i64).unwrap_or(0);
|
||||
let download_count = asset
|
||||
.get("download_count")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0);
|
||||
let url = asset
|
||||
.get("browser_download_url")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let local = locals
|
||||
.iter()
|
||||
.find(|l| l.version == version && l.platform == platform);
|
||||
if let Some(l) = local {
|
||||
matched.insert(format!("{}|{}", l.version, l.platform));
|
||||
}
|
||||
|
||||
list.push(FrpRelease {
|
||||
version: version.clone(),
|
||||
os,
|
||||
arch,
|
||||
platform,
|
||||
file_name: name.to_string(),
|
||||
size,
|
||||
download_count,
|
||||
published_at: published_at.clone(),
|
||||
mirror_url: format!("{GITHUB_MIRROR}{url}"),
|
||||
download_url: url,
|
||||
downloaded: local.is_some(),
|
||||
is_current: matches,
|
||||
local_only: false,
|
||||
local_id: local.map(|l| l.id),
|
||||
frpc_path: local.and_then(|l| l.frpc_path.clone()),
|
||||
frps_path: local.and_then(|l| l.frps_path.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 本机已下载 / 导入但未匹配到发行包的记录,单独作为本地卡片展示
|
||||
for l in &locals {
|
||||
if matched.contains(&format!("{}|{}", l.version, l.platform)) {
|
||||
continue;
|
||||
}
|
||||
list.push(FrpRelease {
|
||||
version: l.version.clone(),
|
||||
os: l.os.clone(),
|
||||
arch: l.arch.clone(),
|
||||
platform: l.platform.clone(),
|
||||
file_name: l.file_name.clone(),
|
||||
size: l.file_size,
|
||||
download_count: 0,
|
||||
published_at: String::new(),
|
||||
download_url: String::new(),
|
||||
mirror_url: String::new(),
|
||||
downloaded: true,
|
||||
is_current: l.platform == cur_platform,
|
||||
local_only: true,
|
||||
local_id: Some(l.id),
|
||||
frpc_path: l.frpc_path.clone(),
|
||||
frps_path: l.frps_path.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// 当前平台优先,其次版本新到旧
|
||||
list.sort_by(|a, b| {
|
||||
let ap = a.platform == cur_platform;
|
||||
let bp = b.platform == cur_platform;
|
||||
bp.cmp(&ap)
|
||||
.then_with(|| b.published_at.cmp(&a.published_at))
|
||||
.then_with(|| b.version.cmp(&a.version))
|
||||
});
|
||||
|
||||
Ok(ApiResponse::success(list))
|
||||
}
|
||||
|
||||
/// 获取本机已下载 / 已导入的 Frp 版本列表(仪表盘选择 frpc 版本用)
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub async fn get_frp_downloads(
|
||||
state: State<'_, SqlitePool>,
|
||||
) -> Result<ApiResponse<Vec<FrpDownload>>, String> {
|
||||
let list = sqlx::query_as::<_, FrpDownload>(concat!(
|
||||
"SELECT ",
|
||||
frp_fields!(),
|
||||
" FROM frp_downloads ORDER BY created_at DESC, id DESC"
|
||||
))
|
||||
.fetch_all(&*state)
|
||||
.await;
|
||||
|
||||
Ok(match list {
|
||||
Ok(list) => ApiResponse::success(list),
|
||||
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// 下载并解压指定 Frp 发行包
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub async fn download_frp(
|
||||
app: AppHandle,
|
||||
state: State<'_, SqlitePool>,
|
||||
payload: FrpDownloadPayload,
|
||||
) -> Result<ApiResponse<FrpDownload>, String> {
|
||||
match do_download(&app, &state, &payload).await {
|
||||
Ok(row) => Ok(ApiResponse::success(row)),
|
||||
Err(e) => {
|
||||
emit_progress(&app, &payload, 0, 0, "error", Some(&e));
|
||||
Ok(ApiResponse::error(500, &e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_download(
|
||||
app: &AppHandle,
|
||||
pool: &SqlitePool,
|
||||
payload: &FrpDownloadPayload,
|
||||
) -> Result<FrpDownload, String> {
|
||||
let client = http_client().map_err(|e| e.to_string())?;
|
||||
|
||||
let ver_dir = versions_dir(app)?.join(&payload.version);
|
||||
let extract_dir = ver_dir.join(&payload.platform);
|
||||
tokio::fs::create_dir_all(&extract_dir)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let archive_path = ver_dir.join(&payload.file_name);
|
||||
|
||||
// 压缩包不存在(或为空)时才重新下载
|
||||
let need_download = match tokio::fs::metadata(&archive_path).await {
|
||||
Ok(meta) => meta.len() == 0,
|
||||
Err(_) => true,
|
||||
};
|
||||
if need_download {
|
||||
download_file(app, &client, payload, &archive_path).await?;
|
||||
}
|
||||
|
||||
// 已解压过则跳过解压
|
||||
if find_binary(&extract_dir, "frpc").is_none() && find_binary(&extract_dir, "frps").is_none() {
|
||||
emit_progress(app, payload, 0, 0, "extracting", None);
|
||||
extract_archive(&archive_path, &extract_dir).await?;
|
||||
}
|
||||
|
||||
let frpc = find_binary(&extract_dir, "frpc");
|
||||
let frps = find_binary(&extract_dir, "frps");
|
||||
if frpc.is_none() && frps.is_none() {
|
||||
return Err("解压完成,但未找到 frpc / frps 可执行文件".to_string());
|
||||
}
|
||||
if let Some(path) = &frpc {
|
||||
make_executable(path);
|
||||
}
|
||||
if let Some(path) = &frps {
|
||||
make_executable(path);
|
||||
}
|
||||
|
||||
let size = tokio::fs::metadata(&archive_path)
|
||||
.await
|
||||
.map(|m| m.len() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO frp_downloads (version, os, arch, platform, file_name, archive_path, frpc_path, frps_path, file_size, source) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'download') \
|
||||
ON CONFLICT(version, platform) DO UPDATE SET \
|
||||
file_name = excluded.file_name, archive_path = excluded.archive_path, \
|
||||
frpc_path = excluded.frpc_path, frps_path = excluded.frps_path, \
|
||||
file_size = excluded.file_size, source = 'download', \
|
||||
updated_at = datetime('now', 'localtime')",
|
||||
)
|
||||
.bind(&payload.version)
|
||||
.bind(&payload.os)
|
||||
.bind(&payload.arch)
|
||||
.bind(&payload.platform)
|
||||
.bind(&payload.file_name)
|
||||
.bind(archive_path.to_string_lossy().to_string())
|
||||
.bind(frpc.as_ref().map(|p| p.to_string_lossy().to_string()))
|
||||
.bind(frps.as_ref().map(|p| p.to_string_lossy().to_string()))
|
||||
.bind(size)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
emit_progress(app, payload, 1, 1, "done", None);
|
||||
fetch_local(pool, &payload.version, &payload.platform)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 下载压缩包(镜像优先,失败回退直连),并持续上报进度
|
||||
async fn download_file(
|
||||
app: &AppHandle,
|
||||
client: &reqwest::Client,
|
||||
payload: &FrpDownloadPayload,
|
||||
archive_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
let urls = [
|
||||
format!("{GITHUB_MIRROR}{}", payload.download_url),
|
||||
payload.download_url.clone(),
|
||||
];
|
||||
let mut last_err = String::new();
|
||||
|
||||
for url in urls {
|
||||
match client.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let total = resp.content_length().unwrap_or(0);
|
||||
let mut file = tokio::fs::File::create(archive_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut stream_err: Option<String> = None;
|
||||
emit_progress(app, payload, 0, total, "downloading", None);
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
file.write_all(&bytes).await.map_err(|e| e.to_string())?;
|
||||
downloaded += bytes.len() as u64;
|
||||
emit_progress(app, payload, downloaded, total, "downloading", None);
|
||||
}
|
||||
Err(e) => {
|
||||
stream_err = Some(e.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(err) = stream_err {
|
||||
// 本次下载中断,清理残缺文件后尝试下一个源
|
||||
let _ = file.shutdown().await;
|
||||
let _ = tokio::fs::remove_file(archive_path).await;
|
||||
last_err = err;
|
||||
continue;
|
||||
}
|
||||
|
||||
let _ = file.flush().await;
|
||||
return Ok(());
|
||||
}
|
||||
Ok(resp) => last_err = format!("HTTP {}", resp.status()),
|
||||
Err(e) => last_err = e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("下载失败:{last_err}"))
|
||||
}
|
||||
|
||||
/// 解压发行包。Windows 为 zip、类 Unix 为 tar.gz,统一使用系统 tar 命令
|
||||
/// (Windows 10+ 内置 bsdtar,可同时处理 zip 与 tar.gz)
|
||||
async fn extract_archive(archive: &Path, dest: &Path) -> Result<(), String> {
|
||||
let archive = archive.to_path_buf();
|
||||
let dest = dest.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let output = std::process::Command::new("tar")
|
||||
.arg("-xf")
|
||||
.arg(&archive)
|
||||
.arg("-C")
|
||||
.arg(&dest)
|
||||
.output()
|
||||
.map_err(|e| format!("调用 tar 解压失败:{e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"解压失败:{}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
/// 在目录中递归查找 frpc / frps 可执行文件
|
||||
fn find_binary(dir: &Path, stem: &str) -> Option<PathBuf> {
|
||||
let stem_lower = stem.to_ascii_lowercase();
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(current) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(¤t) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_ascii_lowercase())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if name == stem_lower || name == format!("{stem_lower}.exe") {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn make_executable(path: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(meta) = std::fs::metadata(path) {
|
||||
let mut perms = meta.permissions();
|
||||
perms.set_mode(0o755);
|
||||
let _ = std::fs::set_permissions(path, perms);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn make_executable(_path: &Path) {}
|
||||
|
||||
/// 删除已下载 / 已导入的版本(同时清理本地文件)
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub async fn delete_frp(
|
||||
app: AppHandle,
|
||||
state: State<'_, SqlitePool>,
|
||||
id: i64,
|
||||
) -> Result<ApiResponse<()>, String> {
|
||||
let row = match sqlx::query_as::<_, FrpDownload>(concat!(
|
||||
"SELECT ",
|
||||
frp_fields!(),
|
||||
" FROM frp_downloads WHERE id = ?"
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_optional(&*state)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => return Ok(ApiResponse::error(404, "记录不存在")),
|
||||
Err(e) => return Ok(ApiResponse::error(500, &e.to_string())),
|
||||
};
|
||||
|
||||
if let Ok(base) = versions_dir(&app) {
|
||||
let ver_dir = base.join(&row.version);
|
||||
let _ = tokio::fs::remove_dir_all(ver_dir.join(&row.platform)).await;
|
||||
if let Some(archive) = &row.archive_path {
|
||||
let _ = tokio::fs::remove_file(archive).await;
|
||||
}
|
||||
// 版本目录已空则一并删除
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&ver_dir).await {
|
||||
if matches!(entries.next_entry().await, Ok(None)) {
|
||||
let _ = tokio::fs::remove_dir(&ver_dir).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match sqlx::query("DELETE FROM frp_downloads WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(ApiResponse::success_empty()),
|
||||
Err(e) => Ok(ApiResponse::error(500, &e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入本地已下载的 frpc / frps(记录可执行文件路径,不复制文件)
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub async fn import_frp(
|
||||
app: AppHandle,
|
||||
state: State<'_, SqlitePool>,
|
||||
) -> Result<ApiResponse<Vec<FrpDownload>>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let picked = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("选择本地已下载的 frpc / frps")
|
||||
.blocking_pick_files();
|
||||
let Some(files) = picked else {
|
||||
return Ok(ApiResponse::success(Vec::new()));
|
||||
};
|
||||
|
||||
let (os, arch) = current_platform();
|
||||
let platform = format!("{os}_{arch}");
|
||||
let mut result = Vec::new();
|
||||
|
||||
for file in files {
|
||||
let path = match file.into_path() {
|
||||
Ok(path) => path,
|
||||
Err(e) => return Ok(ApiResponse::error(500, &e.to_string())),
|
||||
};
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let size = std::fs::metadata(&path).map(|m| m.len() as i64).unwrap_or(0);
|
||||
let lower = file_name.to_ascii_lowercase();
|
||||
let is_frps = lower.contains("frps");
|
||||
let version = parse_version(&file_name).unwrap_or_else(|| file_name.clone());
|
||||
let full = path.to_string_lossy().to_string();
|
||||
let (frpc_path, frps_path) = if is_frps {
|
||||
(None, Some(full))
|
||||
} else {
|
||||
(Some(full), None)
|
||||
};
|
||||
|
||||
let exec = sqlx::query(
|
||||
"INSERT INTO frp_downloads (version, os, arch, platform, file_name, frpc_path, frps_path, file_size, source) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'import') \
|
||||
ON CONFLICT(version, platform) DO UPDATE SET \
|
||||
frpc_path = COALESCE(excluded.frpc_path, frp_downloads.frpc_path), \
|
||||
frps_path = COALESCE(excluded.frps_path, frp_downloads.frps_path), \
|
||||
file_size = CASE WHEN excluded.file_size > 0 THEN excluded.file_size ELSE frp_downloads.file_size END, \
|
||||
source = 'import', updated_at = datetime('now', 'localtime')",
|
||||
)
|
||||
.bind(&version)
|
||||
.bind(&os)
|
||||
.bind(&arch)
|
||||
.bind(&platform)
|
||||
.bind(&file_name)
|
||||
.bind(frpc_path)
|
||||
.bind(frps_path)
|
||||
.bind(size)
|
||||
.execute(&*state)
|
||||
.await;
|
||||
|
||||
match exec {
|
||||
Ok(_) => {
|
||||
if let Ok(row) = fetch_local(&state, &version, &platform).await {
|
||||
result.push(row);
|
||||
}
|
||||
}
|
||||
Err(e) => return Ok(ApiResponse::error(500, &e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ApiResponse::success(result))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_asset_handles_common_names() {
|
||||
assert_eq!(
|
||||
parse_asset("frp_0.71.0_windows_amd64.zip"),
|
||||
Some(("0.71.0".into(), "windows".into(), "amd64".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_asset("frp_0.71.0_linux_arm64.tar.gz"),
|
||||
Some(("0.71.0".into(), "linux".into(), "arm64".into()))
|
||||
);
|
||||
// arch 含下划线的情况
|
||||
assert_eq!(
|
||||
parse_asset("frp_0.71.0_linux_arm_hf.tar.gz"),
|
||||
Some(("0.71.0".into(), "linux".into(), "arm_hf".into()))
|
||||
);
|
||||
assert_eq!(parse_asset("frp_0.71.0_linux_mips_softfloat.tar.gz"), {
|
||||
Some(("0.71.0".into(), "linux".into(), "mips_softfloat".into()))
|
||||
});
|
||||
assert_eq!(parse_asset("readme.md"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_version_extracts_semver() {
|
||||
assert_eq!(parse_version("frpc_0.71.0_windows_amd64.exe"), Some("v0.71.0".into()));
|
||||
assert_eq!(parse_version("frps-v0.60.0"), Some("v0.60.0".into()));
|
||||
assert_eq!(parse_version("frpc.exe"), None);
|
||||
assert_eq!(parse_version("frpc_0.71.exe"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_platform_is_known() {
|
||||
let (os, arch) = current_platform();
|
||||
assert!(!os.is_empty());
|
||||
assert!(!arch.is_empty());
|
||||
println!("current platform: {os}_{arch}");
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod tunnel;
|
||||
pub mod server;
|
||||
pub mod server;
|
||||
pub mod system;
|
||||
pub mod frp;
|
||||
@@ -0,0 +1,72 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use sysinfo::{Networks, System};
|
||||
use tauri::State;
|
||||
use tauri_helper::auto_collect_command;
|
||||
|
||||
use crate::models::{ApiResponse, SystemDetails, SystemInfo};
|
||||
use crate::services::system::SystemStats;
|
||||
|
||||
/// 获取系统资源占用(CPU / 内存百分比)
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub async fn get_system_info(
|
||||
state: State<'_, SystemStats>,
|
||||
) -> Result<ApiResponse<SystemInfo>, String> {
|
||||
let (cpu, memory) = state.snapshot();
|
||||
Ok(ApiResponse::success(SystemInfo { cpu, memory }))
|
||||
}
|
||||
|
||||
/// 获取系统信息(主机名、系统版本、架构、IP 地址)
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub async fn get_system_details() -> Result<ApiResponse<SystemDetails>, String> {
|
||||
Ok(ApiResponse::success(collect_details()))
|
||||
}
|
||||
|
||||
fn collect_details() -> SystemDetails {
|
||||
let hostname = System::host_name().unwrap_or_else(|| "-".to_string());
|
||||
let os = System::long_os_version()
|
||||
.or_else(System::os_version)
|
||||
.or_else(System::name)
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let arch = System::cpu_arch();
|
||||
|
||||
// 采集本机 IPv4 地址(过滤回环地址),并去重
|
||||
let networks = Networks::new_with_refreshed_list();
|
||||
let mut ips: Vec<String> = Vec::new();
|
||||
for data in networks.list().values() {
|
||||
for ip_net in data.ip_networks() {
|
||||
if let IpAddr::V4(ipv4) = ip_net.addr {
|
||||
if !ipv4.is_loopback() {
|
||||
ips.push(ipv4.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ips.sort();
|
||||
ips.dedup();
|
||||
|
||||
SystemDetails {
|
||||
hostname,
|
||||
os,
|
||||
arch,
|
||||
ips,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn details_are_collected() {
|
||||
let d = collect_details();
|
||||
println!(
|
||||
"hostname={}, os={}, arch={}, ips={:?}",
|
||||
d.hostname, d.os, d.arch, d.ips
|
||||
);
|
||||
assert!(!d.hostname.is_empty());
|
||||
assert!(!d.arch.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,20 @@ mod services;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::commands::frp::{
|
||||
delete_frp, download_frp, get_frp_downloads, get_frp_releases, import_frp,
|
||||
};
|
||||
use crate::commands::server::{
|
||||
create_server, delete_server, get_server_list, set_server_enabled, set_server_pinned,
|
||||
update_server,
|
||||
};
|
||||
use crate::commands::system::{get_system_details, get_system_info};
|
||||
use crate::commands::tunnel::{
|
||||
create_tunnel, delete_tunnel, get_tunnel_list, set_tunnel_enabled, set_tunnel_pinned,
|
||||
update_tunnel,
|
||||
};
|
||||
use crate::services::db::init_pool;
|
||||
use crate::services::system::SystemStats;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -30,6 +35,11 @@ pub fn run() {
|
||||
});
|
||||
app.manage(pool);
|
||||
|
||||
// 系统资源占用(CPU / 内存)后台采集
|
||||
let stats = SystemStats::default();
|
||||
stats.start();
|
||||
app.manage(stats);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
|
||||
+122
-2
@@ -157,8 +157,7 @@ pub struct Tunnel {
|
||||
|
||||
/// 新增 / 编辑隧道的提交数据
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TunnelPayload {
|
||||
/// 编辑时携带,新增时为空
|
||||
pub struct TunnelPayload { /// 编辑时携带,新增时为空
|
||||
pub id: Option<i64>,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
@@ -187,3 +186,124 @@ pub struct TunnelPayload {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// 已下载 / 已导入的 Frp 版本(对应 SQLite 中的 frp_downloads 表)
|
||||
///
|
||||
/// 一个发行包(version + platform)同时包含 frpc 与 frps 两个可执行文件,
|
||||
/// 故 `frpc_path` / `frps_path` 分别记录解压(或导入)后的可执行文件路径。
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct FrpDownload {
|
||||
pub id: i64,
|
||||
/// 版本号,如 v0.71.0
|
||||
pub version: String,
|
||||
/// 操作系统,如 windows / darwin / linux
|
||||
pub os: String,
|
||||
/// CPU 架构,如 amd64 / arm64
|
||||
pub arch: String,
|
||||
/// 平台标识(os_arch),如 windows_amd64
|
||||
pub platform: String,
|
||||
/// 发行包文件名
|
||||
pub file_name: String,
|
||||
/// 压缩包保存路径(导入的记录可能为空)
|
||||
pub archive_path: Option<String>,
|
||||
/// 解压 / 导入后的 frpc 可执行文件路径
|
||||
pub frpc_path: Option<String>,
|
||||
/// 解压 / 导入后的 frps 可执行文件路径
|
||||
pub frps_path: Option<String>,
|
||||
/// 文件大小(字节)
|
||||
pub file_size: i64,
|
||||
/// 来源:download(应用内下载)/ import(本地导入)
|
||||
pub source: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// GitHub 上的 Frp 发行包(前端卡片展示用)
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct FrpRelease {
|
||||
/// 版本号,如 v0.71.0
|
||||
pub version: String,
|
||||
/// 操作系统
|
||||
pub os: String,
|
||||
/// CPU 架构
|
||||
pub arch: String,
|
||||
/// 平台标识(os_arch)
|
||||
pub platform: String,
|
||||
/// 发行包文件名
|
||||
pub file_name: String,
|
||||
/// 文件大小(字节)
|
||||
pub size: i64,
|
||||
/// GitHub 下载次数
|
||||
pub download_count: i64,
|
||||
/// 发布日期(ISO 8601)
|
||||
pub published_at: String,
|
||||
/// GitHub 原始下载地址
|
||||
pub download_url: String,
|
||||
/// 镜像加速后的下载地址
|
||||
pub mirror_url: String,
|
||||
/// 是否已下载 / 已导入
|
||||
pub downloaded: bool,
|
||||
/// 是否匹配当前运行平台(用于前端默认过滤)
|
||||
pub is_current: bool,
|
||||
/// 是否为仅本机存在的记录(未匹配到发行包)
|
||||
pub local_only: bool,
|
||||
/// 本机记录 ID(用于删除)
|
||||
pub local_id: Option<i64>,
|
||||
/// 本机 frpc 可执行文件路径
|
||||
pub frpc_path: Option<String>,
|
||||
/// 本机 frps 可执行文件路径
|
||||
pub frps_path: Option<String>,
|
||||
}
|
||||
|
||||
/// 下载 / 导入请求
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct FrpDownloadPayload {
|
||||
pub version: String,
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub platform: String,
|
||||
pub file_name: String,
|
||||
/// GitHub 原始下载地址(后端会拼接镜像前缀)
|
||||
pub download_url: String,
|
||||
}
|
||||
|
||||
/// Frp 下载进度事件(通过 `frp-download-progress` 事件推送)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FrpDownloadProgress {
|
||||
/// 事件键,与发行包文件名一致,用于前端定位卡片
|
||||
pub key: String,
|
||||
pub version: String,
|
||||
pub platform: String,
|
||||
/// 已下载字节数
|
||||
pub downloaded: u64,
|
||||
/// 总字节数(0 表示未知)
|
||||
pub total: u64,
|
||||
/// 进度百分比(0 - 100)
|
||||
pub percent: u8,
|
||||
/// 状态:downloading / extracting / done / error
|
||||
pub status: String,
|
||||
/// 附加信息(错误原因等)
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// 系统资源占用(百分比,0 - 100)
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SystemInfo {
|
||||
/// CPU 占用百分比
|
||||
pub cpu: u32,
|
||||
/// 内存占用百分比
|
||||
pub memory: u32,
|
||||
}
|
||||
|
||||
/// 系统信息(主机名、系统版本、架构、IP 地址)
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SystemDetails {
|
||||
/// 主机名
|
||||
pub hostname: String,
|
||||
/// 操作系统版本
|
||||
pub os: String,
|
||||
/// 系统架构
|
||||
pub arch: String,
|
||||
/// 本机 IPv4 地址列表(不含回环地址)
|
||||
pub ips: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -76,5 +76,30 @@ pub async fn init_pool(app: &AppHandle) -> Result<SqlitePool, sqlx::Error> {
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
// 已下载 / 已导入的 Frp 版本表
|
||||
// 同一版本 + 平台唯一,一个发行包内同时含 frpc 与 frps 两个可执行文件
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS frp_downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
version TEXT NOT NULL,
|
||||
os TEXT NOT NULL,
|
||||
arch TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
archive_path TEXT,
|
||||
frpc_path TEXT,
|
||||
frps_path TEXT,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'download',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
UNIQUE(version, platform)
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod db;
|
||||
pub mod db;
|
||||
pub mod system;
|
||||
@@ -0,0 +1,82 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind, System};
|
||||
|
||||
/// 系统资源占用(CPU / 内存百分比)。
|
||||
///
|
||||
/// 通过后台线程定时刷新并缓存,命令调用时直接读取缓存值,
|
||||
/// 避免每次请求都阻塞等待 CPU 采样间隔。
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SystemStats {
|
||||
cpu: Arc<AtomicU32>,
|
||||
memory: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl SystemStats {
|
||||
/// 启动后台刷新线程(每秒刷新一次 CPU / 内存占用)
|
||||
pub fn start(&self) {
|
||||
let cpu = self.cpu.clone();
|
||||
let memory = self.memory.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
// 仅刷新 CPU 使用率与内存,避免枚举进程导致首次采样过慢
|
||||
let mut sys = System::new_with_specifics(
|
||||
RefreshKind::nothing()
|
||||
.with_cpu(CpuRefreshKind::nothing().with_cpu_usage())
|
||||
.with_memory(MemoryRefreshKind::nothing().with_ram()),
|
||||
);
|
||||
// 首次刷新后间隔一小段时间再采样,保证 CPU 使用率准确
|
||||
sys.refresh_cpu_usage();
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
|
||||
loop {
|
||||
sys.refresh_cpu_usage();
|
||||
sys.refresh_memory();
|
||||
|
||||
cpu.store(sys.global_cpu_usage().round() as u32, Ordering::Relaxed);
|
||||
memory.store(
|
||||
usage_percent(sys.used_memory(), sys.total_memory()),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 读取当前缓存的占用百分比
|
||||
pub fn snapshot(&self) -> (u32, u32) {
|
||||
(
|
||||
self.cpu.load(Ordering::Relaxed),
|
||||
self.memory.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算百分比(0 - 100)
|
||||
fn usage_percent(used: u64, total: u64) -> u32 {
|
||||
if total == 0 {
|
||||
return 0;
|
||||
}
|
||||
((used as f64 / total as f64) * 100.0).round() as u32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn snapshot_returns_real_percentages() {
|
||||
let stats = SystemStats::default();
|
||||
stats.start();
|
||||
thread::sleep(Duration::from_millis(1500));
|
||||
let (cpu, memory) = stats.snapshot();
|
||||
println!("real cpu = {cpu}%, memory = {memory}%");
|
||||
assert!(cpu <= 100, "cpu out of range: {cpu}");
|
||||
assert!(memory <= 100, "memory out of range: {memory}");
|
||||
assert!(memory > 0, "memory should be greater than 0");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import type { ApiResponse } from "@/types/api"
|
||||
import type { FrpDownload, FrpDownloadPayload, FrpRelease } from "@/types/frp"
|
||||
|
||||
/**
|
||||
* 调用后端命令并统一解包 ApiResponse。
|
||||
*
|
||||
* 后端所有 Frp 下载相关命令均返回 `ApiResponse<T>`,业务失败时
|
||||
* `success === false`,此处统一抛出异常,交由调用方提示。
|
||||
*/
|
||||
async function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const res = await invoke<ApiResponse<T>>(command, args)
|
||||
if (!res?.success) {
|
||||
throw new Error(res?.msg || "操作失败")
|
||||
}
|
||||
return res.data as T
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Frp 版本列表(返回所有架构,前端按开关过滤)
|
||||
*/
|
||||
export function getFrpReleases() {
|
||||
return call<FrpRelease[]>("get_frp_releases")
|
||||
}
|
||||
|
||||
/** 下载并解压指定 Frp 发行包 */
|
||||
export function downloadFrp(payload: FrpDownloadPayload) {
|
||||
return call<FrpDownload>("download_frp", { payload })
|
||||
}
|
||||
|
||||
/** 获取本机已下载 / 已导入的 Frp 版本列表(仪表盘选择 frpc 版本用) */
|
||||
export function getFrpDownloads() {
|
||||
return call<FrpDownload[]>("get_frp_downloads")
|
||||
}
|
||||
|
||||
/** 删除已下载 / 已导入的版本 */
|
||||
export function deleteFrp(id: number) {
|
||||
return call<null>("delete_frp", { id })
|
||||
}
|
||||
|
||||
/** 导入本地已下载的 frpc / frps(返回导入后的记录) */
|
||||
export function importFrp() {
|
||||
return call<FrpDownload[]>("import_frp")
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import type { ApiResponse } from "@/types/api"
|
||||
import type { SystemDetails, SystemInfo } from "@/types/system"
|
||||
|
||||
/**
|
||||
* 调用后端命令并统一解包 ApiResponse。
|
||||
*
|
||||
* 后端系统相关命令均返回 `ApiResponse<T>`,业务失败时
|
||||
* `success === false`,此处统一抛出异常,交由调用方处理。
|
||||
*/
|
||||
async function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const res = await invoke<ApiResponse<T>>(command, args)
|
||||
if (!res?.success) {
|
||||
throw new Error(res?.msg || "操作失败")
|
||||
}
|
||||
return res.data as T
|
||||
}
|
||||
|
||||
/** 获取系统资源占用(CPU / 内存百分比) */
|
||||
export function getSystemInfo() {
|
||||
return call<SystemInfo>("get_system_info")
|
||||
}
|
||||
|
||||
/** 获取系统信息(主机名、系统版本、架构、IP 地址) */
|
||||
export function getSystemDetails() {
|
||||
return call<SystemDetails>("get_system_details")
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<a-card class="frp-card" :body-style="{ padding: '16px' }" hoverable>
|
||||
<!-- 头部:版本 + 状态标签 -->
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="truncate text-base font-semibold">{{ release.version }}</span>
|
||||
<a-tag v-if="release.downloaded" color="green" class="!me-0">{{ t("frp.downloaded") }}</a-tag>
|
||||
<a-tag v-if="release.local_only" color="purple" class="!me-0">{{ t("frp.local") }}</a-tag>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<a-tag color="blue" class="!me-0">{{ release.platform }}</a-tag>
|
||||
<a-tag class="!me-0">frpc</a-tag>
|
||||
<a-tag v-if="showFrps" color="cyan" class="!me-0">frps</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 元信息:大小 / 下载次数 / 发布日期 -->
|
||||
<div class="mt-3 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="opacity-60">{{ t("frp.size") }}</span>
|
||||
<span class="font-medium tabular-nums">{{ formatSize(release.size) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="opacity-60">{{ t("frp.downloadCount") }}</span>
|
||||
<span class="font-medium tabular-nums">{{ formatCount(release.download_count) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="opacity-60">{{ t("frp.publishedAt") }}</span>
|
||||
<span class="font-medium">{{ formatDate(release.published_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider class="!my-3" />
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a-button
|
||||
v-if="!release.local_only"
|
||||
type="primary"
|
||||
size="small"
|
||||
:icon="h(DownloadOutlined)"
|
||||
:loading="downloading"
|
||||
:disabled="release.downloaded"
|
||||
@click="emit('download')"
|
||||
>
|
||||
{{ release.downloaded ? t("frp.downloaded") : t("frp.download") }}
|
||||
</a-button>
|
||||
|
||||
<a-tooltip v-if="!release.local_only" :title="t('frp.copyLink')">
|
||||
<a-button size="small" :icon="h(LinkOutlined)" @click="emit('copy')" />
|
||||
</a-tooltip>
|
||||
|
||||
<a-popconfirm
|
||||
v-if="release.downloaded"
|
||||
:title="t('frp.deleteConfirm', { version: release.version, platform: release.platform })"
|
||||
:ok-text="t('common.confirm')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
@confirm="emit('delete')"
|
||||
>
|
||||
<a-button size="small" danger :icon="h(DeleteOutlined)" />
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
|
||||
<!-- 下载进度(卡片下方) -->
|
||||
<div v-if="progress" class="mt-3">
|
||||
<div class="mb-1 flex items-center justify-between text-xs">
|
||||
<span :class="statusClass">{{ statusText }}</span>
|
||||
<span class="tabular-nums opacity-70">{{ progress.percent }}%</span>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="progress.percent"
|
||||
:status="progressStatus"
|
||||
:stroke-color="progressColor"
|
||||
:show-info="false"
|
||||
size="small"
|
||||
/>
|
||||
<p v-if="progress.status === 'error' && progress.message" class="mt-1 line-clamp-2 text-xs text-red-500">
|
||||
{{ progress.message }}
|
||||
</p>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from "dayjs"
|
||||
import { h } from "vue"
|
||||
import { DeleteOutlined, DownloadOutlined, LinkOutlined } from "@ant-design/icons-vue"
|
||||
import { useI18n } from "vue-i18n"
|
||||
import type { FrpDownloadProgress, FrpRelease } from "@/types/frp"
|
||||
|
||||
const props = defineProps<{
|
||||
release: FrpRelease
|
||||
progress?: FrpDownloadProgress | null
|
||||
showFrps?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
download: []
|
||||
copy: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const downloading = computed(
|
||||
() => props.progress?.status === "downloading" || props.progress?.status === "extracting",
|
||||
)
|
||||
|
||||
const progressStatus = computed(() => {
|
||||
switch (props.progress?.status) {
|
||||
case "error":
|
||||
return "exception"
|
||||
case "done":
|
||||
return "success"
|
||||
default:
|
||||
return "normal"
|
||||
}
|
||||
})
|
||||
|
||||
const progressColor = computed(() => {
|
||||
switch (props.progress?.status) {
|
||||
case "error":
|
||||
return "#ef4444"
|
||||
case "done":
|
||||
return "#22c55e"
|
||||
default:
|
||||
return "#6366f1"
|
||||
}
|
||||
})
|
||||
|
||||
const statusClass = computed(() =>
|
||||
props.progress?.status === "error" ? "text-red-500" : "opacity-70",
|
||||
)
|
||||
|
||||
const statusText = computed(() =>
|
||||
props.progress ? t(`frp.status.${props.progress.status}`) : "",
|
||||
)
|
||||
|
||||
/** 字节数格式化 */
|
||||
function formatSize(bytes: number) {
|
||||
if (!bytes) return "-"
|
||||
const units = ["B", "KB", "MB", "GB"]
|
||||
let value = bytes
|
||||
let index = 0
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024
|
||||
index += 1
|
||||
}
|
||||
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
||||
}
|
||||
|
||||
/** 下载次数格式化(过万显示为 w) */
|
||||
function formatCount(count: number) {
|
||||
if (!count) return "-"
|
||||
return count >= 10000 ? `${(count / 10000).toFixed(1)}w` : String(count)
|
||||
}
|
||||
|
||||
/** 发布日期格式化 */
|
||||
function formatDate(value: string) {
|
||||
return value ? dayjs(value).format("YYYY-MM-DD") : "-"
|
||||
}
|
||||
</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,16 +1,11 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { getSystemInfo } from "@/api/system"
|
||||
import { isTauri } from "@/utils/window"
|
||||
|
||||
interface SystemInfo {
|
||||
cpu: number
|
||||
memory: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统资源占用(CPU / 内存 百分比)。
|
||||
*
|
||||
* 优先调用后端命令 `get_system_info`(返回 { cpu, memory }),
|
||||
* 后端尚未实现时回退到模拟数据,保证界面可用。
|
||||
* 调用后端命令 `get_system_info`(返回 `ApiResponse<{ cpu, memory }>`),
|
||||
* 在非 Tauri 环境或后端不可用时回退到模拟数据,保证界面可用。
|
||||
*/
|
||||
export function useSystemStats(interval = 2000) {
|
||||
const cpu = ref(0)
|
||||
@@ -31,15 +26,13 @@ export function useSystemStats(interval = 2000) {
|
||||
async function refresh() {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const res = await invoke<SystemInfo>("get_system_info")
|
||||
if (res) {
|
||||
cpu.value = Math.round(res.cpu)
|
||||
memory.value = Math.round(res.memory)
|
||||
isMock.value = false
|
||||
return
|
||||
}
|
||||
const info = await getSystemInfo()
|
||||
cpu.value = Math.round(info.cpu)
|
||||
memory.value = Math.round(info.memory)
|
||||
isMock.value = false
|
||||
return
|
||||
} catch {
|
||||
// 后端命令未实现,回退到模拟数据
|
||||
// 后端命令不可用,回退到模拟数据
|
||||
}
|
||||
}
|
||||
mock()
|
||||
|
||||
@@ -33,6 +33,25 @@
|
||||
"statusError": "Error",
|
||||
"statusStopped": "Stopped"
|
||||
},
|
||||
"dashboard": {
|
||||
"serverCount": "Servers",
|
||||
"tunnelCount": "Tunnels",
|
||||
"hostname": "Hostname",
|
||||
"os": "OS",
|
||||
"arch": "Architecture",
|
||||
"ip": "IP address",
|
||||
"frpcVersion": "Frpc version",
|
||||
"frpcVersionEmpty": "No downloaded version yet. Download one from the Frpc download page first.",
|
||||
"start": "One-click start",
|
||||
"stop": "Stop",
|
||||
"logs": "Live logs",
|
||||
"clear": "Clear",
|
||||
"mock": "Mock data",
|
||||
"logEmpty": "No logs yet. Live logs will appear here once Frpc starts.",
|
||||
"logReady": "Frpilot is ready. Waiting for Frpc to start.",
|
||||
"logStarted": "Frpc {version} started",
|
||||
"logStopped": "Frpc stopped"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"placeholder": "The settings page is under construction.",
|
||||
@@ -162,6 +181,34 @@
|
||||
"portRange": "Port must be between 1 and 65535"
|
||||
}
|
||||
},
|
||||
"frp": {
|
||||
"refresh": "Refresh",
|
||||
"import": "Import local",
|
||||
"showAllArchFrpc": "All-arch Frpc",
|
||||
"showAllArchFrps": "All-arch Frps",
|
||||
"empty": "No Frp release data. Click Refresh to retry.",
|
||||
"downloaded": "Downloaded",
|
||||
"local": "Local",
|
||||
"download": "Download",
|
||||
"copyLink": "Copy download link",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Are you sure you want to delete {version} ({platform})?",
|
||||
"size": "Size",
|
||||
"downloadCount": "Downloads",
|
||||
"publishedAt": "Published",
|
||||
"downloadSuccess": "Download completed",
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"copySuccess": "Download link copied",
|
||||
"copyFailed": "Copy failed, please copy manually",
|
||||
"importSuccess": "Imported {count} file(s)",
|
||||
"importEmpty": "No file selected",
|
||||
"status": {
|
||||
"downloading": "Downloading",
|
||||
"extracting": "Extracting",
|
||||
"done": "Done",
|
||||
"error": "Download failed"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"developing": "This feature is under construction",
|
||||
"confirm": "OK",
|
||||
|
||||
@@ -33,6 +33,25 @@
|
||||
"statusError": "运行异常",
|
||||
"statusStopped": "未启动"
|
||||
},
|
||||
"dashboard": {
|
||||
"serverCount": "服务器数量",
|
||||
"tunnelCount": "隧道数量",
|
||||
"hostname": "主机名",
|
||||
"os": "系统",
|
||||
"arch": "系统架构",
|
||||
"ip": "IP 地址",
|
||||
"frpcVersion": "Frpc 版本",
|
||||
"frpcVersionEmpty": "暂无已下载版本,请先前往 Frpc 下载页面下载",
|
||||
"start": "一键启动",
|
||||
"stop": "停止",
|
||||
"logs": "实时日志",
|
||||
"clear": "清空",
|
||||
"mock": "模拟数据",
|
||||
"logEmpty": "暂无日志,启动 Frpc 后将在此显示实时日志",
|
||||
"logReady": "Frpilot 已就绪,等待启动 Frpc",
|
||||
"logStarted": "已启动 Frpc {version}",
|
||||
"logStopped": "已停止 Frpc"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"placeholder": "设置页面开发中,敬请期待。",
|
||||
@@ -162,6 +181,34 @@
|
||||
"portRange": "端口号必须在 1 - 65535 之间"
|
||||
}
|
||||
},
|
||||
"frp": {
|
||||
"refresh": "刷新",
|
||||
"import": "导入本地",
|
||||
"showAllArchFrpc": "所有架构 Frpc",
|
||||
"showAllArchFrps": "所有架构 Frps",
|
||||
"empty": "暂无 Frp 版本数据,点击刷新重试",
|
||||
"downloaded": "已下载",
|
||||
"local": "本地",
|
||||
"download": "下载",
|
||||
"copyLink": "复制下载链接",
|
||||
"delete": "删除",
|
||||
"deleteConfirm": "确定要删除已下载的 {version}({platform})吗?",
|
||||
"size": "大小",
|
||||
"downloadCount": "下载次数",
|
||||
"publishedAt": "发布日期",
|
||||
"downloadSuccess": "下载完成",
|
||||
"deleteSuccess": "删除成功",
|
||||
"copySuccess": "下载链接已复制",
|
||||
"copyFailed": "复制失败,请手动复制",
|
||||
"importSuccess": "已导入 {count} 个文件",
|
||||
"importEmpty": "未选择任何文件",
|
||||
"status": {
|
||||
"downloading": "下载中",
|
||||
"extracting": "解压中",
|
||||
"done": "已完成",
|
||||
"error": "下载失败"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"developing": "功能开发中,敬请期待",
|
||||
"confirm": "确定",
|
||||
|
||||
Vendored
+2
-1
@@ -30,16 +30,17 @@ 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']
|
||||
AResult: typeof import('ant-design-vue/es')['Result']
|
||||
ARow: typeof import('ant-design-vue/es')['Row']
|
||||
ASegmented: typeof import('ant-design-vue/es')['Segmented']
|
||||
ASelect: typeof import('ant-design-vue/es')['Select']
|
||||
ASpace: typeof import('ant-design-vue/es')['Space']
|
||||
ASpin: typeof import('ant-design-vue/es')['Spin']
|
||||
AStatistic: typeof import('ant-design-vue/es')['Statistic']
|
||||
ASwitch: typeof import('ant-design-vue/es')['Switch']
|
||||
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||
FrpCard: typeof import('./../components/frp/FrpCard.vue')['default']
|
||||
MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Frp 下载相关的类型定义,字段与 Rust 端 `models::FrpRelease` / `models::FrpDownload`
|
||||
* 等一一对应。
|
||||
*/
|
||||
|
||||
/** GitHub 上的 Frp 发行包(卡片展示用) */
|
||||
export interface FrpRelease {
|
||||
/** 版本号,如 v0.71.0 */
|
||||
version: string
|
||||
/** 操作系统,如 windows / darwin / linux */
|
||||
os: string
|
||||
/** CPU 架构,如 amd64 / arm64 */
|
||||
arch: string
|
||||
/** 平台标识(os_arch),如 windows_amd64 */
|
||||
platform: string
|
||||
/** 发行包文件名 */
|
||||
file_name: string
|
||||
/** 文件大小(字节) */
|
||||
size: number
|
||||
/** GitHub 下载次数 */
|
||||
download_count: number
|
||||
/** 发布日期(ISO 8601) */
|
||||
published_at: string
|
||||
/** GitHub 原始下载地址 */
|
||||
download_url: string
|
||||
/** 镜像加速后的下载地址 */
|
||||
mirror_url: string
|
||||
/** 是否已下载 / 已导入 */
|
||||
downloaded: boolean
|
||||
/** 是否匹配当前运行平台(用于前端默认过滤) */
|
||||
is_current: boolean
|
||||
/** 是否为仅本机存在的记录(未匹配到发行包) */
|
||||
local_only: boolean
|
||||
/** 本机记录 ID(用于删除) */
|
||||
local_id: number | null
|
||||
/** 本机 frpc 可执行文件路径 */
|
||||
frpc_path: string | null
|
||||
/** 本机 frps 可执行文件路径 */
|
||||
frps_path: string | null
|
||||
}
|
||||
|
||||
/** 已下载 / 已导入的 Frp 版本 */
|
||||
export interface FrpDownload {
|
||||
id: number
|
||||
version: string
|
||||
os: string
|
||||
arch: string
|
||||
platform: string
|
||||
file_name: string
|
||||
archive_path: string | null
|
||||
frpc_path: string | null
|
||||
frps_path: string | null
|
||||
file_size: number
|
||||
/** 来源:download(应用内下载)/ import(本地导入) */
|
||||
source: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 下载请求 */
|
||||
export interface FrpDownloadPayload {
|
||||
version: string
|
||||
os: string
|
||||
arch: string
|
||||
platform: string
|
||||
file_name: string
|
||||
/** GitHub 原始下载地址(后端会拼接镜像前缀) */
|
||||
download_url: string
|
||||
}
|
||||
|
||||
/** 下载进度状态 */
|
||||
export type FrpDownloadStatus = "downloading" | "extracting" | "done" | "error"
|
||||
|
||||
/** Frp 下载进度事件 */
|
||||
export interface FrpDownloadProgress {
|
||||
/** 事件键,与发行包文件名一致 */
|
||||
key: string
|
||||
version: string
|
||||
platform: string
|
||||
/** 已下载字节数 */
|
||||
downloaded: number
|
||||
/** 总字节数(0 表示未知) */
|
||||
total: number
|
||||
/** 进度百分比(0 - 100) */
|
||||
percent: number
|
||||
status: FrpDownloadStatus
|
||||
/** 附加信息(错误原因等) */
|
||||
message: string | null
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 系统相关的类型定义,字段与 Rust 端 `models::SystemInfo` / `models::SystemDetails` 一一对应。
|
||||
*/
|
||||
|
||||
/** 系统资源占用(百分比) */
|
||||
export interface SystemInfo {
|
||||
/** CPU 占用百分比 */
|
||||
cpu: number
|
||||
/** 内存占用百分比 */
|
||||
memory: number
|
||||
}
|
||||
|
||||
/** 系统信息(主机名、系统版本、架构、IP 地址) */
|
||||
export interface SystemDetails {
|
||||
/** 主机名 */
|
||||
hostname: string
|
||||
/** 操作系统版本 */
|
||||
os: string
|
||||
/** 系统架构 */
|
||||
arch: string
|
||||
/** 本机 IPv4 地址列表(不含回环地址) */
|
||||
ips: string[]
|
||||
}
|
||||
+353
-6
@@ -1,19 +1,366 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-3">
|
||||
<!-- 顶部操作栏(独立磨砂卡片) -->
|
||||
<div class="glass-panel glass-panel-flat flex shrink-0 items-center justify-between gap-3 px-5 py-3">
|
||||
<h2 class="text-lg font-semibold">{{ t("menu.dashboard") }}</h2>
|
||||
<!-- 顶部统计卡片 -->
|
||||
<div class="grid shrink-0 grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<!-- 服务器 / 隧道数量 -->
|
||||
<div class="glass-panel flex flex-col justify-center gap-2 px-5 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-500/10 text-base text-indigo-500"
|
||||
>
|
||||
<CloudServerOutlined />
|
||||
</span>
|
||||
<a-statistic
|
||||
:title="t('dashboard.serverCount')"
|
||||
:value="serverCount"
|
||||
:value-style="statValueStyle"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-sky-500/10 text-base text-sky-500"
|
||||
>
|
||||
<ApiOutlined />
|
||||
</span>
|
||||
<a-statistic
|
||||
:title="t('dashboard.tunnelCount')"
|
||||
:value="tunnelCount"
|
||||
:value-style="statValueStyle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统信息 -->
|
||||
<div class="glass-panel flex items-center gap-4 px-5 py-4">
|
||||
<span
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-emerald-500/10 text-xl text-emerald-500"
|
||||
>
|
||||
<DesktopOutlined />
|
||||
</span>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5 text-xs">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="shrink-0 opacity-70">{{ t("dashboard.hostname") }}</span>
|
||||
<span class="truncate font-medium" :title="details?.hostname">
|
||||
{{ details?.hostname || "-" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="shrink-0 opacity-70">{{ t("dashboard.os") }}</span>
|
||||
<span class="truncate font-medium" :title="details?.os">{{ details?.os || "-" }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="shrink-0 opacity-70">{{ t("dashboard.arch") }}</span>
|
||||
<span class="truncate font-medium">{{ details?.arch || "-" }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="shrink-0 opacity-70">{{ t("dashboard.ip") }}</span>
|
||||
<span class="truncate font-medium" :title="ipFull">{{ ipText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CPU / 内存 / 运行状态 -->
|
||||
<div class="glass-panel flex flex-col justify-center gap-2 px-5 py-4">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="opacity-70">{{ t("system.cpu") }}</span>
|
||||
<span class="font-medium tabular-nums">{{ cpu }}%</span>
|
||||
</div>
|
||||
<a-progress :percent="cpu" :show-info="false" :stroke-color="barColor(cpu)" size="small" />
|
||||
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="opacity-70">{{ t("system.memory") }}</span>
|
||||
<span class="font-medium tabular-nums">{{ memory }}%</span>
|
||||
</div>
|
||||
<a-progress :percent="memory" :show-info="false" :stroke-color="barColor(memory)" size="small" />
|
||||
|
||||
<div class="mt-1 flex items-center gap-2 text-xs">
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" :class="statusDotClass" />
|
||||
<span class="opacity-70">{{ t("system.status") }}</span>
|
||||
<span class="font-medium">{{ statusText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区(独立磨砂卡片) -->
|
||||
<div class="glass-panel glass-panel-flat min-h-0 flex-1 overflow-y-auto p-5">
|
||||
<a-result status="info" :sub-title="t('common.developing')" />
|
||||
<!-- 快速操作 -->
|
||||
<div
|
||||
class="glass-panel glass-panel-flat flex shrink-0 flex-wrap items-center justify-between gap-3 px-5 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="shrink-0 text-sm opacity-70">{{ t("dashboard.frpcVersion") }}</span>
|
||||
<a-select
|
||||
v-model:value="selectedId"
|
||||
:options="versionOptions"
|
||||
:loading="versionsLoading"
|
||||
:placeholder="t('dashboard.frpcVersionEmpty')"
|
||||
class="w-64"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a-space>
|
||||
<a-button
|
||||
type="primary"
|
||||
:icon="h(PlayCircleOutlined)"
|
||||
:disabled="isRunning || !selectedId"
|
||||
@click="start"
|
||||
>
|
||||
{{ t("dashboard.start") }}
|
||||
</a-button>
|
||||
<a-button danger :icon="h(PoweroffOutlined)" :disabled="!isRunning" @click="stop">
|
||||
{{ t("dashboard.stop") }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 实时日志 -->
|
||||
<div class="glass-panel glass-panel-flat flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-between gap-3 border-b border-black/5 px-5 py-3 dark:border-white/10"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-semibold">{{ t("dashboard.logs") }}</h3>
|
||||
<a-tag class="!me-0">{{ t("dashboard.mock") }}</a-tag>
|
||||
<a-tag :color="statusTagColor" class="!me-0">{{ statusText }}</a-tag>
|
||||
</div>
|
||||
|
||||
<a-tooltip :title="t('dashboard.clear')">
|
||||
<a-button size="small" :icon="h(ClearOutlined)" @click="clearLogs" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="logBox"
|
||||
class="min-h-0 flex-1 overflow-y-auto px-5 py-3 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
<p v-if="!logs.length" class="opacity-50">{{ t("dashboard.logEmpty") }}</p>
|
||||
<div v-for="(line, index) in logs" :key="index" class="flex gap-2">
|
||||
<span class="shrink-0 opacity-50">{{ line.time }}</span>
|
||||
<span class="shrink-0" :class="levelClass(line.level)">[{{ line.level }}]</span>
|
||||
<span class="break-all">{{ line.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import dayjs from "dayjs"
|
||||
import {
|
||||
ApiOutlined,
|
||||
ClearOutlined,
|
||||
CloudServerOutlined,
|
||||
DesktopOutlined,
|
||||
PlayCircleOutlined,
|
||||
PoweroffOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import { useI18n } from "vue-i18n"
|
||||
import { getFrpDownloads } from "@/api/frp"
|
||||
import { getServerList } from "@/api/server"
|
||||
import { getSystemDetails } from "@/api/system"
|
||||
import { getTunnelList } from "@/api/tunnel"
|
||||
import { useAppStore } from "@/stores/app"
|
||||
import { useSystemStats } from "@/composables/useSystemStats"
|
||||
import type { FrpDownload } from "@/types/frp"
|
||||
import type { SystemDetails } from "@/types/system"
|
||||
import { isTauri } from "@/utils/window"
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const { cpu, memory } = useSystemStats()
|
||||
|
||||
/** 服务器 / 隧道数量(来自后端,未连接 Tauri 时保持 0) */
|
||||
const serverCount = ref(0)
|
||||
const tunnelCount = ref(0)
|
||||
|
||||
/** 系统信息(主机名 / 系统 / 架构 / IP) */
|
||||
const details = ref<SystemDetails | null>(null)
|
||||
|
||||
/** 卡片内展示的主 IP(多个时追加数量,完整列表在 tooltip 中显示) */
|
||||
const ipText = computed(() => {
|
||||
const ips = details.value?.ips ?? []
|
||||
if (!ips.length) return "-"
|
||||
return ips.length > 1 ? `${ips[0]} +${ips.length - 1}` : ips[0]
|
||||
})
|
||||
const ipFull = computed(() => (details.value?.ips?.length ? details.value.ips.join(", ") : "-"))
|
||||
|
||||
/** 合并卡片中两个统计数值的样式(更紧凑) */
|
||||
const statValueStyle = { fontSize: "20px", fontWeight: 600, lineHeight: "1.2" }
|
||||
|
||||
/** 已下载 / 已导入的 Frp 版本(来自 frp_downloads) */
|
||||
const frpDownloads = ref<FrpDownload[]>([])
|
||||
const versionsLoading = ref(false)
|
||||
/** 当前选中的版本记录 ID */
|
||||
const selectedId = ref<string | undefined>(undefined)
|
||||
|
||||
/** 仅展示具备 frpc 可执行文件的记录 */
|
||||
const versionOptions = computed(() =>
|
||||
frpDownloads.value
|
||||
.filter((item) => item.frpc_path)
|
||||
.map((item) => ({ label: `${item.version} · ${item.platform}`, value: String(item.id) })),
|
||||
)
|
||||
|
||||
const selectedDownload = computed(
|
||||
() => frpDownloads.value.find((item) => String(item.id) === selectedId.value) ?? null,
|
||||
)
|
||||
|
||||
/** 选中的版本号(用于日志展示) */
|
||||
const selectedVersion = computed(() => selectedDownload.value?.version ?? "-")
|
||||
|
||||
const isRunning = computed(() => appStore.runStatus === "running")
|
||||
|
||||
type LogLevel = "INFO" | "WARN" | "ERROR" | "DEBUG"
|
||||
|
||||
interface LogLine {
|
||||
time: string
|
||||
level: LogLevel
|
||||
message: string
|
||||
}
|
||||
|
||||
const logs = ref<LogLine[]>([])
|
||||
const logBox = ref<HTMLElement | null>(null)
|
||||
|
||||
/** 追加一行日志并自动滚动到底部(最多保留 500 行) */
|
||||
function pushLog(level: LogLevel, message: string) {
|
||||
logs.value.push({ time: dayjs().format("HH:mm:ss"), level, message })
|
||||
if (logs.value.length > 500) {
|
||||
logs.value.splice(0, logs.value.length - 500)
|
||||
}
|
||||
nextTick(() => {
|
||||
const el = logBox.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs.value = []
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (isRunning.value) return
|
||||
appStore.runStatus = "running"
|
||||
pushLog("INFO", t("dashboard.logStarted", { version: selectedVersion.value }))
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (!isRunning.value) return
|
||||
appStore.runStatus = "stopped"
|
||||
pushLog("WARN", t("dashboard.logStopped"))
|
||||
}
|
||||
|
||||
/** 模拟实时日志:运行中定时追加 */
|
||||
const MOCK_LOGS: { level: LogLevel; message: string }[] = [
|
||||
{ level: "INFO", message: "login to server success" },
|
||||
{ level: "INFO", message: "start proxy success" },
|
||||
{ level: "DEBUG", message: "heartbeat sent to server" },
|
||||
{ level: "INFO", message: "new connection established" },
|
||||
{ level: "WARN", message: "connection timeout, retrying..." },
|
||||
{ level: "DEBUG", message: "connection closed by remote" },
|
||||
]
|
||||
|
||||
useIntervalFn(() => {
|
||||
if (!isRunning.value) return
|
||||
const item = MOCK_LOGS[Math.floor(Math.random() * MOCK_LOGS.length)]
|
||||
pushLog(item.level, item.message)
|
||||
}, 1500)
|
||||
|
||||
/** 拉取服务器 / 隧道数量 */
|
||||
async function loadCounts() {
|
||||
if (!isTauri()) return
|
||||
try {
|
||||
const [servers, tunnels] = await Promise.all([getServerList(), getTunnelList()])
|
||||
serverCount.value = servers?.length ?? 0
|
||||
tunnelCount.value = tunnels?.length ?? 0
|
||||
} catch {
|
||||
// 读取失败时保持 0,不影响仪表盘其它区域展示
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉取系统信息(主机名 / 系统 / 架构 / IP) */
|
||||
async function loadSystemDetails() {
|
||||
if (!isTauri()) return
|
||||
try {
|
||||
details.value = await getSystemDetails()
|
||||
} catch {
|
||||
// 读取失败时保持占位符
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉取已下载 / 已导入的 Frp 版本,供版本下拉选择 */
|
||||
async function loadFrpDownloads() {
|
||||
if (!isTauri()) return
|
||||
versionsLoading.value = true
|
||||
try {
|
||||
frpDownloads.value = (await getFrpDownloads()) ?? []
|
||||
// 默认选中最新一个可用版本
|
||||
if (!selectedId.value && versionOptions.value.length) {
|
||||
selectedId.value = versionOptions.value[0].value
|
||||
}
|
||||
} catch {
|
||||
// 读取失败时保持空列表
|
||||
} finally {
|
||||
versionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
pushLog("INFO", t("dashboard.logReady"))
|
||||
loadCounts()
|
||||
loadSystemDetails()
|
||||
loadFrpDownloads()
|
||||
})
|
||||
|
||||
/** 进度条颜色:低=蓝,中=橙,高=红 */
|
||||
function barColor(value: number) {
|
||||
if (value >= 85) return "#ef4444"
|
||||
if (value >= 60) return "#f59e0b"
|
||||
return "#6366f1"
|
||||
}
|
||||
|
||||
const statusDotClass = computed(() => {
|
||||
switch (appStore.runStatus) {
|
||||
case "running":
|
||||
return "bg-green-500 shadow-[0_0_6px_#22c55e]"
|
||||
case "error":
|
||||
return "bg-red-500 shadow-[0_0_6px_#ef4444]"
|
||||
default:
|
||||
return "bg-yellow-400"
|
||||
}
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (appStore.runStatus) {
|
||||
case "running":
|
||||
return t("system.statusRunning")
|
||||
case "error":
|
||||
return t("system.statusError")
|
||||
default:
|
||||
return t("system.statusStopped")
|
||||
}
|
||||
})
|
||||
|
||||
const statusTagColor = computed(() => {
|
||||
switch (appStore.runStatus) {
|
||||
case "running":
|
||||
return "success"
|
||||
case "error":
|
||||
return "error"
|
||||
default:
|
||||
return "default"
|
||||
}
|
||||
})
|
||||
|
||||
function levelClass(level: LogLevel) {
|
||||
switch (level) {
|
||||
case "WARN":
|
||||
return "text-amber-500"
|
||||
case "ERROR":
|
||||
return "text-red-500"
|
||||
case "DEBUG":
|
||||
return "text-slate-400"
|
||||
default:
|
||||
return "text-indigo-500"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
+195
-4
@@ -1,19 +1,210 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-3">
|
||||
<!-- 顶部操作栏(独立磨砂卡片) -->
|
||||
<div class="glass-panel glass-panel-flat flex shrink-0 items-center justify-between gap-3 px-5 py-3">
|
||||
<h2 class="text-lg font-semibold">{{ t("menu.download") }}</h2>
|
||||
<div
|
||||
class="glass-panel glass-panel-flat flex shrink-0 flex-wrap items-center justify-between gap-3 px-5 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-lg font-semibold">{{ t("menu.download") }}</h2>
|
||||
<a-tag v-if="releases.length" class="!me-0">{{ releases.length }}</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-xs">
|
||||
<a-switch v-model:checked="showAllArchFrpc" size="small" />
|
||||
<span>{{ t("frp.showAllArchFrpc") }}</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-2 text-xs">
|
||||
<a-switch v-model:checked="showAllArchFrps" size="small" />
|
||||
<span>{{ t("frp.showAllArchFrps") }}</span>
|
||||
</label>
|
||||
|
||||
<a-space>
|
||||
<a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()">
|
||||
{{ t("frp.refresh") }}
|
||||
</a-button>
|
||||
<a-button :loading="importing" :icon="h(ImportOutlined)" @click="onImport">
|
||||
{{ t("frp.import") }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区(独立磨砂卡片) -->
|
||||
<!-- 内容区(独立磨砂卡片,内部滚动) -->
|
||||
<div class="glass-panel glass-panel-flat min-h-0 flex-1 overflow-y-auto p-5">
|
||||
<a-result status="info" :sub-title="t('common.developing')" />
|
||||
<a-spin :spinning="loading">
|
||||
<a-empty v-if="!releases.length" class="mt-20" :description="t('frp.empty')" />
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<FrpCard
|
||||
v-for="item in releases"
|
||||
:key="`${item.version}-${item.platform}-${item.file_name}`"
|
||||
:release="item"
|
||||
:progress="progressMap[item.file_name]"
|
||||
:show-frps="showAllArchFrps"
|
||||
@download="onDownload(item)"
|
||||
@copy="onCopy(item)"
|
||||
@delete="onDelete(item)"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import { ImportOutlined, ReloadOutlined } from "@ant-design/icons-vue"
|
||||
import { message } from "ant-design-vue"
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
||||
import { useI18n } from "vue-i18n"
|
||||
import { deleteFrp, downloadFrp, getFrpReleases, importFrp } from "@/api/frp"
|
||||
import FrpCard from "@/components/frp/FrpCard.vue"
|
||||
import type { FrpDownloadProgress, FrpRelease } from "@/types/frp"
|
||||
import { isTauri } from "@/utils/window"
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
const importing = ref(false)
|
||||
|
||||
/** 是否展示所有架构的 Frpc */
|
||||
const showAllArchFrpc = useStorage("frpilot:frp:allArchFrpc", false)
|
||||
/** 是否作为 frp 下载器展示所有架构的 Frps */
|
||||
const showAllArchFrps = useStorage("frpilot:frp:allArchFrps", false)
|
||||
|
||||
/** 后端返回的全部发行包(含所有架构) */
|
||||
const allReleases = ref<FrpRelease[]>([])
|
||||
|
||||
/**
|
||||
* 按开关过滤后的展示列表:
|
||||
* 默认仅展示当前运行平台(以及已下载的记录);开启任一开关后展示所有架构。
|
||||
*/
|
||||
const releases = computed(() => {
|
||||
if (showAllArchFrpc.value || showAllArchFrps.value) return allReleases.value
|
||||
return allReleases.value.filter((item) => item.is_current || item.downloaded)
|
||||
})
|
||||
|
||||
/** 各发行包的下载进度(以文件名为键) */
|
||||
const progressMap = ref<Record<string, FrpDownloadProgress>>({})
|
||||
|
||||
function errMsg(e: unknown) {
|
||||
return e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
|
||||
/** 拉取 Frp 版本列表,silent 为 true 时静默刷新(不显示 loading) */
|
||||
async function refresh(silent = false) {
|
||||
if (!isTauri()) return
|
||||
if (!silent) loading.value = true
|
||||
try {
|
||||
allReleases.value = (await getFrpReleases()) ?? []
|
||||
} catch (e) {
|
||||
message.error(errMsg(e))
|
||||
} finally {
|
||||
if (!silent) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDownload(item: FrpRelease) {
|
||||
if (!item.download_url) return
|
||||
progressMap.value[item.file_name] = {
|
||||
key: item.file_name,
|
||||
version: item.version,
|
||||
platform: item.platform,
|
||||
downloaded: 0,
|
||||
total: item.size,
|
||||
percent: 0,
|
||||
status: "downloading",
|
||||
message: null,
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await downloadFrp({
|
||||
version: item.version,
|
||||
os: item.os,
|
||||
arch: item.arch,
|
||||
platform: item.platform,
|
||||
file_name: item.file_name,
|
||||
download_url: item.download_url,
|
||||
})
|
||||
// 直接更新本地状态,避免重复请求 GitHub API
|
||||
item.downloaded = true
|
||||
item.local_id = row.id
|
||||
item.frpc_path = row.frpc_path
|
||||
item.frps_path = row.frps_path
|
||||
message.success(t("frp.downloadSuccess"))
|
||||
} catch (e) {
|
||||
message.error(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopy(item: FrpRelease) {
|
||||
const link = item.mirror_url || item.download_url
|
||||
if (!link) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(link)
|
||||
message.success(t("frp.copySuccess"))
|
||||
} catch {
|
||||
message.error(t("frp.copyFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(item: FrpRelease) {
|
||||
if (item.local_id == null) return
|
||||
try {
|
||||
await deleteFrp(item.local_id)
|
||||
message.success(t("frp.deleteSuccess"))
|
||||
if (item.local_only) {
|
||||
// 仅本机的记录直接移除
|
||||
allReleases.value = allReleases.value.filter((it) => it !== item)
|
||||
} else {
|
||||
item.downloaded = false
|
||||
item.local_id = null
|
||||
item.frpc_path = null
|
||||
item.frps_path = null
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function onImport() {
|
||||
importing.value = true
|
||||
try {
|
||||
const rows = await importFrp()
|
||||
if (rows?.length) {
|
||||
message.success(t("frp.importSuccess", { count: rows.length }))
|
||||
} else {
|
||||
message.info(t("frp.importEmpty"))
|
||||
}
|
||||
await refresh(true)
|
||||
} catch (e) {
|
||||
message.error(errMsg(e))
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载进度事件:更新对应卡片的进度条 */
|
||||
function onProgress(p: FrpDownloadProgress) {
|
||||
progressMap.value[p.key] = p
|
||||
if (p.status === "done") {
|
||||
setTimeout(() => {
|
||||
delete progressMap.value[p.key]
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
let unlisten: UnlistenFn | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
refresh()
|
||||
if (isTauri()) {
|
||||
unlisten = await listen<FrpDownloadProgress>("frp-download-progress", (event) =>
|
||||
onProgress(event.payload),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => unlisten?.())
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user