Compare commits

...
2 Commits
Author SHA1 Message Date
Yuhang Wu 74f7b7d30f feat: 新增自定义窗口背景功能 2026-10-28 11:17:28 +08:00
Yuhang Wu 135e465b16 feat: 新增系统主题跟随、窗口遮罩和运行日志持久化
- 新增系统主题模式,支持跟随操作系统明暗主题
- 添加窗口背景遮罩组件,可调节透明度改善对比度
- 实现 Frpc 运行日志按日期持久化到 logs 目录
- 优化侧边栏收起时的系统资源显示样式
- 新增中英文界面的相关翻译文本
- 实现系统资源统计的共享复用,避免重复请求
2026-10-28 10:55:01 +08:00
24 changed files with 763 additions and 101 deletions
+22
View File
@@ -4,6 +4,28 @@
---
## v1.5.0(2026-10-28)
### ✨ 新增功能
- **跟随系统主题**:「设置 - 外观」新增「跟随系统」,可随操作系统的明暗主题自动切换;也可继续手动选择浅色或深色主题。
- **自定义窗口背景**:可在「设置 - 外观」中选择或更换自己的背景图片,支持 PNG、JPG 和 WebP,单张图片不超过 15 MB。图片会保存到应用数据目录,重新打开软件后仍可使用。
- **窗口背景遮罩**:在窗口背景上叠加随明暗主题变化的遮罩,并可在外观设置中单独调整透明度,改善背景与顶部栏操作图标之间的对比度。
### 🎨 优化
- **主题背景**:默认背景颜色改为适配明暗主题的纯色,并调整内置背景图片与明暗主题下的整体观感。
- **折叠侧栏资源占用**:菜单栏折叠后直接显示 CPU、内存百分比和运行状态,无需悬停查看。
- **系统资源数据复用**:仪表盘与侧栏共享 CPU、内存占用数据缓存,减少进入仪表盘后等待数据加载的时间。
### 📁 数据与日志
- **Frpc 运行日志持久化**:运行日志按日期追加保存到安装目录的 `logs/frpc-YYYY-MM-DD.log`,包含 Frpc 输出及启动、停止和退出等状态记录。
### 🧱 工程
- 应用版本升级至 **v1.5.0**。
## v1.4.1(2026-10-26)
### 🎨 优化
+13
View File
@@ -324,6 +324,10 @@ const toggleCollapsed = () => {
- 不需要做持久化缓存,只需要在应用运行时生效即可
- 应用运行时只有在第一次进入Frpc下载页面的时候采取请求GitHub,后面再切换回Frpc下载页面的时候,因为缓存了组件,所以不必再次获取GitHub,需要刷新的时候由用户手动点击刷新按钮
#### 3.11 自定义背景
外面的背景图片,增加一个自定义,由用户自己选择图片并设置为背景图片,自定义的图片需要保证下次打开软件仍然是这个背景,可以使用Antdv里面的上传组件,或者你有更好的方案可以替换也可以
### 4. 修改
#### 4.1 项目名称修改
@@ -359,3 +363,12 @@ const toggleCollapsed = () => {
- 迁移脚本需要考虑不同操作系统之间的差别
- 或许可以考虑直接把之前的数据目录直接剪切到安装所在目录里面
- 数据迁移之后需要保证功能和已配置的服务器、隧道可以正常启动
#### 4.4 页面UI修改
- 左侧导航栏里面的CPU和内存占用,在菜单栏折叠起来之后,要显示;目前是鼠标放上去之后才显示,需要改成在折叠之后的菜单栏里面显示,上面显示CPU占用,下面显示内存占用。具体表现为:CPU:换行显示百分比,换行,内存:换行显示百分比,换行,状态。也可以不要CPU:和内存:,只显示百分比,字体可以小一点,但是主要的是占用百分比
- CPU和内存占用增加一个缓存,现在存在一个问题是仪表盘页面每次进入都会重新去获取CPU和内存占用,会有1-2s的等待仪表盘页面才显示CPU和内存占用,现在可以增加一个缓存,保持跟左侧菜单栏里面同步
- 外观设置里面的背景图片,默认的背景颜色,使用ui-ux-pro-max重新设计一个,不要AI常用的渐变色,同时需要主题明暗主题下的不同
- 整体窗口增加一个适应明暗主题变化的一个遮罩,大小是整个窗口的大小,因为目前存在一点问题是有些背景图片的一部分区域的颜色跟顶部栏的操作图标过于接近,导致视觉效果不佳,我想增加一个遮罩来减少一部分影响,遮罩要随明暗主题有反差,确保顶部栏图标的视觉效果,而且遮罩的透明度需要在外观设置里面可以单独调节
- 明暗主题切换增加一个跟随系统
- 运行日志持久化写入运行目录所在的logs目录里面
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frpcpilot",
"private": true,
"version": "1.4.1",
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "vite",
+7
View File
@@ -1843,6 +1843,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -4595,6 +4601,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"http-range",
"jni 0.21.1",
"libc",
"log",
+1 -1
View File
@@ -22,7 +22,7 @@ tauri-build = { version = "2", features = [] }
tauri-helper = "0.2.1"
[dependencies]
tauri = { version = "2.12.3", features = ["tray-icon"] }
tauri = { version = "2.12.3", features = ["protocol-asset", "tray-icon"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+41
View File
@@ -0,0 +1,41 @@
use tauri::{AppHandle, State};
use tauri_helper::auto_collect_command;
use tauri_plugin_dialog::DialogExt;
use crate::models::ApiResponse;
use crate::services::background::{find_custom_background, import_custom_background};
use crate::services::storage::StoragePaths;
/// 打开原生文件选择器并将图片复制到应用数据目录。
#[tauri::command]
#[auto_collect_command]
pub async fn choose_custom_background(
app: AppHandle,
paths: State<'_, StoragePaths>,
) -> Result<ApiResponse<Option<String>>, String> {
let picked = app
.dialog()
.file()
.add_filter("图片", &["png", "jpg", "jpeg", "webp"])
.set_title("选择背景图片")
.blocking_pick_file();
let Some(file) = picked else {
return Ok(ApiResponse::success(None));
};
let source = file.into_path().map_err(|e| e.to_string())?;
let saved = import_custom_background(&paths, &source)?;
Ok(ApiResponse::success(Some(
saved.to_string_lossy().into_owned(),
)))
}
/// 获取已保存的自定义背景路径,图片内容通过受限的 asset 协议读取。
#[tauri::command]
#[auto_collect_command]
pub async fn get_custom_background(
paths: State<'_, StoragePaths>,
) -> Result<ApiResponse<Option<String>>, String> {
Ok(ApiResponse::success(
find_custom_background(&paths).map(|path| path.to_string_lossy().into_owned()),
))
}
+1
View File
@@ -1,3 +1,4 @@
pub mod background;
pub mod frp;
pub mod runner;
pub mod server;
+12 -1
View File
@@ -5,6 +5,7 @@
use std::path::PathBuf;
use chrono::Local;
use sqlx::SqlitePool;
use tauri::{AppHandle, Manager, State};
use tauri_helper::auto_collect_command;
@@ -152,7 +153,17 @@ pub async fn start_frpc(
// 5. 启动 frpc 进程
let config_path_str = config_path.to_string_lossy().to_string();
match runner.spawn(&app, &frpc_path, &config_path_str, &download.version) {
let paths = app.state::<StoragePaths>();
let log_path = paths
.logs()
.join(format!("frpc-{}.log", Local::now().format("%Y-%m-%d")));
match runner.spawn(
&app,
&frpc_path,
&config_path_str,
&download.version,
&log_path,
) {
Ok(status) => Ok(ApiResponse::success(status)),
Err(e) => Ok(ApiResponse::error(500, &e)),
}
+1
View File
@@ -5,6 +5,7 @@ mod services;
use tauri::{Manager, RunEvent};
use tauri_plugin_autostart::ManagerExt as _;
use crate::commands::background::{choose_custom_background, get_custom_background};
use crate::commands::frp::{
delete_frp, download_frp, get_frp_downloads, get_frp_releases, import_frp,
};
+137
View File
@@ -0,0 +1,137 @@
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::services::storage::StoragePaths;
const MAX_BACKGROUND_SIZE: u64 = 15 * 1024 * 1024;
pub fn import_custom_background(paths: &StoragePaths, source: &Path) -> Result<PathBuf, String> {
let size = fs::metadata(source)
.map_err(|e| format!("读取图片信息失败:{e}"))?
.len();
if size == 0 || size > MAX_BACKGROUND_SIZE {
return Err("图片大小必须在 1 字节到 15 MB 之间".into());
}
let bytes = fs::read(source).map_err(|e| format!("读取背景图片失败:{e}"))?;
if bytes.is_empty() || bytes.len() as u64 > MAX_BACKGROUND_SIZE {
return Err("图片大小必须在 1 字节到 15 MB 之间".into());
}
let extension = detect_extension(&bytes).ok_or("仅支持 PNG、JPG 和 WebP 图片")?;
let directory = paths.data().join("backgrounds");
fs::create_dir_all(&directory).map_err(|e| format!("创建背景图片目录失败:{e}"))?;
let destination = directory.join(format!("custom.{extension}"));
let temporary = directory.join(format!("custom.{extension}.tmp"));
let mut file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&temporary)
.map_err(|e| format!("创建背景图片文件失败:{e}"))?;
if let Err(error) = file.write_all(&bytes).and_then(|()| file.sync_all()) {
drop(file);
let _ = fs::remove_file(&temporary);
return Err(format!("保存背景图片失败:{error}"));
}
drop(file);
fs::rename(&temporary, &destination).map_err(|e| {
let _ = fs::remove_file(&temporary);
format!("更新背景图片失败:{e}")
})?;
for stale_extension in ["png", "jpg", "webp"] {
if stale_extension != extension {
let stale = directory.join(format!("custom.{stale_extension}"));
let _ = fs::remove_file(stale);
}
}
Ok(destination)
}
pub fn find_custom_background(paths: &StoragePaths) -> Option<PathBuf> {
["png", "jpg", "webp"]
.into_iter()
.map(|extension| {
paths
.data()
.join("backgrounds")
.join(format!("custom.{extension}"))
})
.filter(|path| path.is_file())
.max_by_key(|path| {
fs::metadata(path)
.and_then(|metadata| metadata.modified())
.unwrap_or(std::time::UNIX_EPOCH)
})
}
fn detect_extension(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Some("png")
} else if bytes.starts_with(b"\xff\xd8\xff") {
Some("jpg")
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
Some("webp")
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn imports_supported_image_and_replaces_previous_format() {
let root = std::env::temp_dir().join(format!(
"frpcpilot-background-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&root).unwrap();
let paths = StoragePaths { root: root.clone() };
let source = root.join("source-image.bin");
let png = b"\x89PNG\r\n\x1a\nimage-data";
fs::write(&source, png).unwrap();
let saved = import_custom_background(&paths, &source).unwrap();
assert_eq!(saved, paths.data().join("backgrounds/custom.png"));
assert_eq!(fs::read(&saved).unwrap(), png);
assert_eq!(find_custom_background(&paths), Some(saved.clone()));
let jpeg = b"\xff\xd8\xffimage-data";
fs::write(&source, jpeg).unwrap();
let replaced = import_custom_background(&paths, &source).unwrap();
assert_eq!(replaced, paths.data().join("backgrounds/custom.jpg"));
assert!(!saved.exists());
assert_eq!(fs::read(&replaced).unwrap(), jpeg);
assert_eq!(find_custom_background(&paths), Some(replaced));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn rejects_non_image_files() {
let root = std::env::temp_dir().join(format!(
"frpcpilot-background-invalid-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&root).unwrap();
let paths = StoragePaths { root: root.clone() };
let source = root.join("not-an-image");
fs::write(&source, b"not an image").unwrap();
assert!(import_custom_background(&paths, &source).is_err());
fs::remove_dir_all(root).unwrap();
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod background;
pub mod config;
pub mod db;
pub mod runner;
+159 -48
View File
@@ -6,7 +6,8 @@
//! - stdout / stderr 通过管道读取,按行解析后以 `frpc-log` 事件实时推送给前端;
//! - 后台守护线程轮询子进程状态,退出时推送 `frpc-exit` 事件。
use std::io::{BufRead, BufReader, Read};
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Write};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
@@ -14,6 +15,7 @@ use std::sync::{Arc, Mutex, MutexGuard};
use std::thread;
use std::time::Duration;
use chrono::Local;
use tauri::{AppHandle, Emitter};
use crate::models::{FrpcExitEvent, FrpcLogEvent, FrpcStatus};
@@ -23,6 +25,8 @@ pub const LOG_EVENT: &str = "frpc-log";
/// 进程退出事件名
pub const EXIT_EVENT: &str = "frpc-exit";
type SharedLogFile = Arc<Mutex<File>>;
/// 守护线程轮询间隔
const POLL_INTERVAL: Duration = Duration::from_millis(250);
@@ -34,6 +38,7 @@ struct Running {
version: String,
frpc_path: String,
config_path: String,
log_file: SharedLogFile,
}
/// Frpc 进程管理器(应用级单例)
@@ -78,6 +83,7 @@ impl FrpcRunner {
frpc_path: &str,
config_path: &str,
version: &str,
log_path: &Path,
) -> Result<FrpcStatus, String> {
let mut guard = self.lock();
if guard.is_some() {
@@ -89,6 +95,14 @@ impl FrpcRunner {
return Err(format!("未找到 frpc 可执行文件:{frpc_path}"));
}
let log_file = Arc::new(Mutex::new(
OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
.map_err(|e| format!("无法打开运行日志 {}:{e}", log_path.display()))?,
));
let mut command = Command::new(exe);
command
.arg("-c")
@@ -126,21 +140,22 @@ impl FrpcRunner {
version: version.to_string(),
frpc_path: frpc_path.to_string(),
config_path: config_path.to_string(),
log_file: log_file.clone(),
});
drop(guard);
// 采集 stdout / stderr
if let Some(stdout) = stdout {
spawn_reader(app.clone(), stdout, "stdout");
spawn_reader(app.clone(), stdout, "stdout", log_file.clone());
}
if let Some(stderr) = stderr {
spawn_reader(app.clone(), stderr, "stderr");
spawn_reader(app.clone(), stderr, "stderr", log_file.clone());
}
// 守护线程:轮询子进程是否退出
spawn_waiter(app.clone(), self.slot.clone(), token);
emit_log(
emit_persisted_log(
app,
FrpcLogEvent {
time: None,
@@ -148,6 +163,7 @@ impl FrpcRunner {
source: "system".to_string(),
message: format!("Frpc {version} 已启动(PID {pid})"),
},
Some(&log_file),
);
Ok(self.status())
@@ -160,7 +176,9 @@ impl FrpcRunner {
return Ok(FrpcStatus::default());
}
emit_log(
let log_file = guard.as_ref().map(|running| running.log_file.clone());
emit_persisted_log(
app,
FrpcLogEvent {
time: None,
@@ -168,6 +186,7 @@ impl FrpcRunner {
source: "system".to_string(),
message: "正在停止 Frpc…".to_string(),
},
log_file.as_ref(),
);
let version = {
@@ -181,7 +200,7 @@ impl FrpcRunner {
*guard = None;
drop(guard);
emit_log(
emit_persisted_log(
app,
FrpcLogEvent {
time: None,
@@ -189,6 +208,7 @@ impl FrpcRunner {
source: "system".to_string(),
message: "Frpc 已停止".to_string(),
},
log_file.as_ref(),
);
emit_exit(
app,
@@ -208,13 +228,27 @@ impl FrpcRunner {
if let Some(running) = guard.as_mut() {
let _ = running.child.kill();
let _ = running.child.wait();
persist_log(
&running.log_file,
&FrpcLogEvent {
time: None,
level: "WARN".to_string(),
source: "system".to_string(),
message: "FrpcPilot 正在退出,Frpc 已停止".to_string(),
},
);
}
*guard = None;
}
}
/// 持续读取子进程输出并推送日志事件
fn spawn_reader<R: Read + Send + 'static>(app: AppHandle, pipe: R, source: &'static str) {
fn spawn_reader<R: Read + Send + 'static>(
app: AppHandle,
pipe: R,
source: &'static str,
log_file: SharedLogFile,
) {
thread::spawn(move || {
let mut reader = BufReader::new(pipe);
let mut buf: Vec<u8> = Vec::new();
@@ -228,7 +262,7 @@ fn spawn_reader<R: Read + Send + 'static>(app: AppHandle, pipe: R, source: &'sta
if line.trim().is_empty() {
continue;
}
emit_log(&app, parse_line(source, &line));
emit_persisted_log(&app, parse_line(source, &line), Some(&log_file));
}
Err(_) => break,
}
@@ -238,48 +272,58 @@ fn spawn_reader<R: Read + Send + 'static>(app: AppHandle, pipe: R, source: &'sta
/// 守护线程:轮询子进程状态,退出时清理并推送事件
fn spawn_waiter(app: AppHandle, slot: Arc<Mutex<Option<Running>>>, token: u64) {
thread::spawn(move || loop {
thread::sleep(POLL_INTERVAL);
thread::spawn(move || {
loop {
thread::sleep(POLL_INTERVAL);
let mut guard = slot.lock().unwrap_or_else(|e| e.into_inner());
let probe = match guard.as_mut() {
Some(running) if running.token == token => {
Some((running.child.try_wait(), running.version.clone()))
}
// 已被停止 / 已被新进程替换
_ => None,
};
let mut guard = slot.lock().unwrap_or_else(|e| e.into_inner());
let probe = match guard.as_mut() {
Some(running) if running.token == token => Some((
running.child.try_wait(),
running.version.clone(),
running.log_file.clone(),
)),
// 已被停止 / 已被新进程替换
_ => None,
};
match probe {
None => return,
Some((Ok(Some(status)), version)) => {
*guard = None;
drop(guard);
let code = status.code();
emit_log(
&app,
FrpcLogEvent {
time: None,
level: if code.unwrap_or(1) == 0 { "WARN" } else { "ERROR" }.to_string(),
source: "system".to_string(),
message: match code {
Some(code) => format!("Frpc 进程已退出(退出码 {code})"),
None => "Frpc 进程已退出".to_string(),
match probe {
None => return,
Some((Ok(Some(status)), version, log_file)) => {
*guard = None;
drop(guard);
let code = status.code();
emit_persisted_log(
&app,
FrpcLogEvent {
time: None,
level: if code.unwrap_or(1) == 0 {
"WARN"
} else {
"ERROR"
}
.to_string(),
source: "system".to_string(),
message: match code {
Some(code) => format!("Frpc 进程已退出(退出码 {code})"),
None => "Frpc 进程已退出".to_string(),
},
},
},
);
emit_exit(
&app,
FrpcExitEvent {
reason: "exited".to_string(),
code,
version: Some(version),
},
);
return;
Some(&log_file),
);
emit_exit(
&app,
FrpcExitEvent {
reason: "exited".to_string(),
code,
version: Some(version),
},
);
return;
}
Some((Ok(None), _, _)) => {} // 仍在运行
Some((Err(_), _, _)) => return,
}
Some((Ok(None), _)) => {} // 仍在运行
Some((Err(_), _)) => return,
}
});
}
@@ -393,6 +437,27 @@ fn emit_log(app: &AppHandle, event: FrpcLogEvent) {
let _ = app.emit(LOG_EVENT, event);
}
fn emit_persisted_log(app: &AppHandle, event: FrpcLogEvent, log_file: Option<&SharedLogFile>) {
if let Some(log_file) = log_file {
persist_log(log_file, &event);
}
emit_log(app, event);
}
fn persist_log(log_file: &SharedLogFile, event: &FrpcLogEvent) {
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
let mut file = log_file.lock().unwrap_or_else(|error| error.into_inner());
if let Err(error) = writeln!(
file,
"{timestamp} [{}] [{}] {}",
event.level, event.source, event.message
)
.and_then(|()| file.flush())
{
eprintln!("Failed to persist Frpc runtime log: {error}");
}
}
/// 发送退出事件
fn emit_exit(app: &AppHandle, event: FrpcExitEvent) {
let _ = app.emit(EXIT_EVENT, event);
@@ -404,8 +469,14 @@ mod tests {
#[test]
fn detects_log_level() {
assert_eq!(detect_level("2026/06/18 22:02:03 [I] [root.go:200] ok"), "INFO");
assert_eq!(detect_level("2026/06/18 22:02:03 [E] [root.go:200] bad"), "ERROR");
assert_eq!(
detect_level("2026/06/18 22:02:03 [I] [root.go:200] ok"),
"INFO"
);
assert_eq!(
detect_level("2026/06/18 22:02:03 [E] [root.go:200] bad"),
"ERROR"
);
assert_eq!(detect_level("[W] warn only"), "WARN");
assert_eq!(detect_level("plain message"), "INFO");
}
@@ -419,6 +490,46 @@ mod tests {
assert_eq!(extract_time("no time here"), None);
}
#[test]
fn persists_runtime_log_lines() {
let path = std::env::temp_dir().join(format!(
"frpcpilot-runtime-log-{}-{}.log",
std::process::id(),
Local::now().timestamp_millis()
));
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.unwrap();
let log_file = Arc::new(Mutex::new(file));
persist_log(
&log_file,
&FrpcLogEvent {
time: None,
level: "INFO".to_string(),
source: "system".to_string(),
message: "started".to_string(),
},
);
persist_log(
&log_file,
&FrpcLogEvent {
time: None,
level: "ERROR".to_string(),
source: "stderr".to_string(),
message: "connection failed".to_string(),
},
);
drop(log_file);
let contents = std::fs::read_to_string(&path).unwrap();
std::fs::remove_file(path).unwrap();
assert!(contents.contains("[INFO] [system] started"));
assert!(contents.contains("[ERROR] [stderr] connection failed"));
}
#[test]
fn strips_ansi_escape_codes() {
let input = "\u{1b}[1;34m2026-10-13 17:19:48.171 [I] hello\u{1b}[0m";
+4 -1
View File
@@ -45,6 +45,9 @@ impl StoragePaths {
pub fn run(&self) -> PathBuf {
self.root.join("run")
}
pub fn logs(&self) -> PathBuf {
self.root.join("logs")
}
pub fn webview(&self) -> PathBuf {
self.root.join("webview")
}
@@ -125,7 +128,7 @@ pub async fn initialize(paths: &StoragePaths) -> Result<(SqlitePool, StorageLock
paths.run(),
paths.webview(),
paths.root.join("cache"),
paths.root.join("logs"),
paths.logs(),
paths.root.join("tmp"),
] {
fs::create_dir_all(&dir).map_err(|e| format!("无法创建 {}:{e}", dir.display()))?;
+1
View File
@@ -64,6 +64,7 @@ fn fresh_install_and_exclusive_storage_lock() {
let (pool, lock) = initialize(&paths).await.unwrap();
assert!(paths.db().is_file());
assert!(paths.run().is_dir());
assert!(paths.logs().is_dir());
assert!(paths.versions().is_dir());
assert!(initialize(&paths).await.is_err());
pool.close().await;
+5 -1
View File
@@ -22,7 +22,11 @@
}
],
"security": {
"csp": null
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["$APPDATA/backgrounds/**"]
}
}
},
"bundle": {
+20
View File
@@ -0,0 +1,20 @@
import { invoke } from "@tauri-apps/api/core"
import type { ApiResponse } from "@/types/api"
async function call<T>(command: string): Promise<T> {
const response = await invoke<ApiResponse<T>>(command)
if (!response?.success) {
throw new Error(response?.msg || "操作失败")
}
return response.data as T
}
/** 让用户选择并持久化一张自定义背景图片,取消时返回 null。 */
export function chooseCustomBackground() {
return call<string | null>("choose_custom_background")
}
/** 获取已保存的自定义背景图片路径。 */
export function getCustomBackground() {
return call<string | null>("get_custom_background")
}
+27 -14
View File
@@ -1,6 +1,6 @@
@import 'tailwindcss';
/* 使用 class 方式控制暗色模式(配合 @vueuse/core 的 useDark) */
/* 使用 class 方式控制暗色模式 */
@custom-variant dark (&:where(.dark, .dark *));
html,
@@ -11,7 +11,7 @@ body,
padding: 0;
}
/* 窗口背景色与渐变(供 html / #app 复用,避免重复定义) */
/* 窗口纯色背景与磨砂透明度 */
:root {
/* 全局文字颜色:普通 HTML 文本继承使用(Antd 组件由 CSS-in-JS 自行着色,不受影响) */
--app-text-color: rgba(15, 23, 42, 0.88);
@@ -19,20 +19,13 @@ body,
--app-card-alpha: 0.45;
--app-card-alpha-hover: 0.62;
--app-modal-alpha: 0.62;
--app-bg-color: #eef2ff;
--app-bg-gradient:
radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.28), transparent 60%),
radial-gradient(1000px 500px at 110% 10%, rgba(34, 211, 238, 0.28), transparent 55%),
linear-gradient(160deg, #eef2ff 0%, #f8fafc 45%, #eef7ff 100%);
--app-background-overlay-alpha: 0.18;
--app-bg-color: #e9edef;
}
html.dark {
--app-text-color: rgba(226, 232, 240, 0.92);
--app-bg-color: #0b1220;
--app-bg-gradient:
radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.35), transparent 60%),
radial-gradient(1000px 500px at 110% 10%, rgba(14, 165, 233, 0.28), transparent 55%),
linear-gradient(160deg, #0b1220 0%, #0f172a 45%, #111827 100%);
--app-bg-color: #151c1d;
}
/*
@@ -43,7 +36,6 @@ html.dark {
*/
html {
background-color: var(--app-bg-color);
background-image: var(--app-bg-gradient);
background-repeat: no-repeat;
}
@@ -71,8 +63,9 @@ html.is-macos #app {
inset: 0;
border-radius: 8px;
overflow: hidden;
position: relative;
isolation: isolate;
background-color: var(--app-bg-color);
background-image: var(--app-bg-gradient);
background-repeat: no-repeat;
}
@@ -113,10 +106,30 @@ body {
/* 窗口圆角固定 8px,与 Windows 11 系统默认圆角保持一致 */
#app {
position: relative;
isolation: isolate;
border-radius: 8px;
overflow: hidden;
}
#app::before {
position: fixed;
z-index: 0;
inset: 0;
background: rgba(255, 255, 255, var(--app-background-overlay-alpha));
content: "";
pointer-events: none;
}
.dark #app::before {
background: rgba(0, 0, 0, var(--app-background-overlay-alpha));
}
#app > * {
position: relative;
z-index: 1;
}
/* 悬浮磨砂玻璃面板 */
.glass-panel {
border-radius: 16px;
+7 -1
View File
@@ -1,5 +1,6 @@
import { getSystemInfo } from "@/api/system"
import { isTauri } from "@/utils/window"
import { createSharedComposable } from "@vueuse/core"
/**
* 系统资源占用(CPU / 内存 百分比)。
@@ -7,7 +8,7 @@ import { isTauri } from "@/utils/window"
* 调用后端命令 `get_system_info`(返回 `ApiResponse<{ cpu, memory }>`),
* 在非 Tauri 环境或后端不可用时回退到模拟数据,保证界面可用。
*/
export function useSystemStats(interval = 2000) {
const useSharedSystemStats = createSharedComposable((interval: number) => {
const cpu = ref(0)
const memory = ref(0)
const isMock = ref(true)
@@ -41,4 +42,9 @@ export function useSystemStats(interval = 2000) {
const { pause, resume } = useIntervalFn(refresh, interval, { immediate: true })
return { cpu, memory, isMock, refresh, pause, resume }
})
/** 所有页面共享同一份指标缓存与轮询,路由切换时不会重新请求。 */
export function useSystemStats(interval = 2000) {
return useSharedSystemStats(interval)
}
+2 -1
View File
@@ -9,7 +9,7 @@ import bg8 from "@/assets/images/bg-8.webp"
import bg9 from "@/assets/images/bg-9.webp"
/** 窗口背景图片标识 */
export type BackgroundKey = "none" | "bg-1" | "bg-2" | "bg-3" | "bg-4" | "bg-5" | "bg-6" | "bg-7" | "bg-8" | "bg-9"
export type BackgroundKey = "none" | "custom" | "bg-1" | "bg-2" | "bg-3" | "bg-4" | "bg-5" | "bg-6" | "bg-7" | "bg-8" | "bg-9"
export interface BackgroundOption {
/** 唯一标识 */
@@ -38,5 +38,6 @@ export const DEFAULT_BACKGROUND: BackgroundKey = "none"
/** 根据标识取图片地址 */
export function getBackgroundUrl(key: BackgroundKey): string {
if (key === "custom") return ""
return backgroundOptions.find((item) => item.key === key)?.url ?? ""
}
+9 -1
View File
@@ -121,11 +121,19 @@
"title": "Appearance",
"subtitle": "Customize the theme mode and window background",
"themeMode": "Theme mode",
"themeModeTip": "Switch between light and dark mode",
"themeModeTip": "Choose light, dark, or follow your operating system",
"light": "Light",
"dark": "Dark",
"system": "System",
"background": "Background image",
"backgroundTip": "Pick an image as the window background. It keeps its original aspect ratio and is never stretched.",
"customBackground": "Custom",
"chooseCustomBackground": "Choose image",
"changeCustomBackground": "Change custom background",
"backgroundOverlay": "Window background overlay",
"backgroundOverlayTip": "Apply a theme-aware overlay across the window to improve contrast with title bar icons.",
"overlayTransparency": "Overlay transparency",
"backgroundOverlayPreview": "Title bar icon preview",
"none": "Default",
"bg1": "Background 1",
"bg2": "Background 2",
+9 -1
View File
@@ -121,11 +121,19 @@
"title": "外观设置",
"subtitle": "自定义窗口的主题模式与背景图片",
"themeMode": "主题模式",
"themeModeTip": "切换窗口的明暗显示模式",
"themeModeTip": "选择浅色、深色或跟随操作系统主题",
"light": "浅色",
"dark": "深色",
"system": "跟随系统",
"background": "背景图片",
"backgroundTip": "选择一张图片作为窗口背景,图片保持原始比例自适应,不会变形拉伸",
"customBackground": "自定义",
"chooseCustomBackground": "选择图片",
"changeCustomBackground": "更换自定义背景",
"backgroundOverlay": "窗口背景遮罩",
"backgroundOverlayTip": "为整个窗口叠加随主题变化的遮罩,改善背景与顶部栏图标的对比度",
"overlayTransparency": "遮罩透明度",
"backgroundOverlayPreview": "顶部栏图标预览",
"none": "默认",
"bg1": "背景一",
"bg2": "背景二",
+74 -4
View File
@@ -40,11 +40,27 @@
</div>
</div>
<a-tooltip v-else placement="right" :title="`${t('system.cpu')}: ${cpu}% · ${t('system.memory')}: ${memory}% · ${statusText}`">
<div class="flex h-10 items-center justify-center" tabindex="0" :aria-label="`${t('system.cpu')}: ${cpu}% · ${t('system.memory')}: ${memory}% · ${statusText}`">
<span class="h-2 w-2 rounded-full" :class="statusDotClass" />
<div
v-else
class="collapsed-system-stats"
tabindex="0"
:aria-label="`${t('system.cpu')}: ${cpu}% · ${t('system.memory')}: ${memory}% · ${statusText}`"
>
<div class="collapsed-system-metric">
<span class="collapsed-system-label">CPU</span>
<span class="collapsed-system-value">{{ cpu }}%</span>
<span class="collapsed-system-track"><span :style="{ width: `${cpu}%`, backgroundColor: barColor(cpu) }" /></span>
</div>
</a-tooltip>
<div class="collapsed-system-metric">
<span class="collapsed-system-label">MEM</span>
<span class="collapsed-system-value">{{ memory }}%</span>
<span class="collapsed-system-track"><span :style="{ width: `${memory}%`, backgroundColor: barColor(memory) }" /></span>
</div>
<div class="collapsed-system-status">
<span class="h-2 w-2 shrink-0 rounded-full" :class="statusDotClass" />
<span>{{ statusText }}</span>
</div>
</div>
<!-- 设置入口(位于运行状态下方) -->
<div class="system-divider mt-3 border-t pt-2">
@@ -228,6 +244,60 @@ const statusText = computed(() => {
border-color: rgba(15, 23, 42, 0.08);
}
.collapsed-system-stats {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 2px 0 4px;
outline: none;
}
.collapsed-system-metric {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 1px;
line-height: 1.2;
}
.collapsed-system-label {
font-size: 9px;
opacity: 0.62;
}
.collapsed-system-value {
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.collapsed-system-track {
width: 38px;
height: 2px;
overflow: hidden;
border-radius: 999px;
background: rgba(100, 116, 139, 0.2);
> span {
display: block;
height: 100%;
transition: width 200ms ease;
}
}
.collapsed-system-status {
display: flex;
max-width: 100%;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 9px;
line-height: 1.2;
opacity: 0.8;
}
.dark .system-panel {
border-color: rgba(255, 255, 255, 0.08);
}
+60 -10
View File
@@ -1,5 +1,7 @@
import { defineStore } from "pinia"
import { convertFileSrc } from "@tauri-apps/api/core"
import { preferences } from "@/utils/preferences"
import { chooseCustomBackground as chooseCustomBackgroundFile, getCustomBackground } from "@/api/background"
import zhCN from "ant-design-vue/es/locale/zh_CN"
import enUS from "ant-design-vue/es/locale/en_US"
import { setI18nLanguage, type Language } from "@/i18n"
@@ -8,9 +10,11 @@ import {
getBackgroundUrl,
type BackgroundKey,
} from "@/config/backgrounds"
import { isTauri } from "@/utils/window"
/** Frpc 运行状态 */
export type RunStatus = "running" | "error" | "stopped"
export type ThemeMode = "light" | "dark" | "system"
/**
* 全局应用配置:语言、主题、窗口背景、Frpc 运行状态
@@ -21,20 +25,59 @@ export const useAppStore = defineStore("app", () => {
const sidebarCollapsed = useStorage("frpcpilot:sidebar-collapsed", false, preferences)
// 明暗主题:useDark 会在 <html> 上切换 .dark 类名
const isDark = useDark({
storage: preferences,
storageKey: "frpcpilot:theme",
selector: "html",
attribute: "class",
valueDark: "dark",
valueLight: "",
// 兼容旧版保存的 "dark" / 空浅色值,并支持跟随操作系统主题。
const themePreference = useStorage<ThemeMode | "">("frpcpilot:theme", "system", preferences)
const preferredDark = usePreferredDark()
const themeMode = computed<ThemeMode>({
get: () => {
if (themePreference.value === "dark" || themePreference.value === "system") return themePreference.value
return "light"
},
set: (value) => { themePreference.value = value },
})
const isDark = computed({
get: () => themeMode.value === "dark" || (themeMode.value === "system" && preferredDark.value),
set: (value: boolean) => { themeMode.value = value ? "dark" : "light" },
})
function toggleDark() {
themeMode.value = isDark.value ? "light" : "dark"
}
watchEffect(() => {
document.documentElement.classList.toggle("dark", isDark.value)
})
const toggleDark = useToggle(isDark)
// 窗口背景图片(持久化)
const background = useStorage<BackgroundKey>("frpcpilot:background", DEFAULT_BACKGROUND, preferences)
const backgroundUrl = computed(() => getBackgroundUrl(background.value))
const customBackgroundPath = ref("")
const customBackgroundRevision = ref(0)
const customBackgroundUrl = computed(() => {
if (!customBackgroundPath.value) return ""
return `${convertFileSrc(customBackgroundPath.value)}?v=${customBackgroundRevision.value}`
})
const backgroundUrl = computed(() => background.value === "custom" ? customBackgroundUrl.value : getBackgroundUrl(background.value))
async function loadCustomBackground() {
if (!isTauri()) return
try {
customBackgroundPath.value = (await getCustomBackground()) ?? ""
customBackgroundRevision.value = Date.now()
} catch (error) {
console.error("Failed to load custom background", error)
}
}
async function chooseCustomBackground() {
if (!isTauri()) throw new Error("自定义背景图片仅在桌面应用中可用")
const path = await chooseCustomBackgroundFile()
if (!path) return false
customBackgroundPath.value = path
customBackgroundRevision.value = Date.now()
background.value = "custom"
return true
}
void loadCustomBackground()
// 把背景图同步到 <html>(整窗背景,避免元素高度误差造成的上下留白)
watchEffect(() => {
@@ -52,6 +95,7 @@ export const useAppStore = defineStore("app", () => {
// 磨砂玻璃透明度(0 - 100,数值越大越透明,持久化)
const cardTransparency = useStorage<number>("frpcpilot:card-transparency", 55, preferences)
const modalTransparency = useStorage<number>("frpcpilot:modal-transparency", 38, preferences)
const backgroundOverlayTransparency = useStorage<number>("frpcpilot:background-overlay-transparency", 82, preferences)
/** 透明度百分比 → 背景 alpha(越大越透明,alpha 越小;并做范围兜底) */
function alphaFromTransparency(value: number) {
@@ -61,6 +105,7 @@ export const useAppStore = defineStore("app", () => {
const cardAlpha = computed(() => alphaFromTransparency(cardTransparency.value))
const modalAlpha = computed(() => alphaFromTransparency(modalTransparency.value))
const backgroundOverlayAlpha = computed(() => alphaFromTransparency(backgroundOverlayTransparency.value))
// 把透明度实时写入 <html> 的 CSS 变量,供磨砂玻璃样式(卡片 / 弹窗)即时生效
watchEffect(() => {
@@ -68,6 +113,7 @@ export const useAppStore = defineStore("app", () => {
root.style.setProperty("--app-card-alpha", String(cardAlpha.value))
root.style.setProperty("--app-card-alpha-hover", String(Math.min(cardAlpha.value + 0.15, 0.95)))
root.style.setProperty("--app-modal-alpha", String(modalAlpha.value))
root.style.setProperty("--app-background-overlay-alpha", String(backgroundOverlayAlpha.value))
})
// Frpc 运行状态(后续由后端驱动)
@@ -99,11 +145,15 @@ export const useAppStore = defineStore("app", () => {
language,
sidebarCollapsed,
isDark,
themeMode,
toggleDark,
background,
backgroundUrl,
customBackgroundUrl,
chooseCustomBackground,
cardTransparency,
modalTransparency,
backgroundOverlayTransparency,
runStatus,
antdLocale,
setLanguage,
+149 -16
View File
@@ -6,12 +6,28 @@
<!-- 主题模式 -->
<section class="mb-6">
<div class="mb-2 text-sm font-medium">{{ t("settings.appearance.themeMode") }}</div>
<a-segmented v-model:value="themeMode" :options="themeOptions" block />
<a-segmented v-model:value="appStore.themeMode" :options="themeOptions" block />
<p class="mt-2 text-xs opacity-60">{{ t("settings.appearance.themeModeTip") }}</p>
</section>
<a-divider />
<section class="mb-6">
<div class="mb-1 text-sm font-medium">{{ t("settings.appearance.backgroundOverlay") }}</div>
<p class="mb-3 text-xs opacity-60">{{ t("settings.appearance.backgroundOverlayTip") }}</p>
<div class="mb-1 flex items-center justify-between text-xs">
<span>{{ t("settings.appearance.overlayTransparency") }}</span>
<span class="font-medium tabular-nums">{{ appStore.backgroundOverlayTransparency }}%</span>
</div>
<a-slider v-model:value="appStore.backgroundOverlayTransparency" :min="0" :max="100" />
<div class="overlay-preview" :style="{ '--preview-overlay-alpha': `${(100 - appStore.backgroundOverlayTransparency) / 100}` }" aria-hidden="true">
<span>{{ t("settings.appearance.backgroundOverlayPreview") }}</span>
<div class="overlay-preview-toolbar"><i /><i /><i /></div>
</div>
</section>
<a-divider />
<!-- 背景图片 -->
<section>
<div class="mb-1 text-sm font-medium">{{ t("settings.appearance.background") }}</div>
@@ -31,6 +47,44 @@
<CheckCircleFilled v-if="appStore.background === bg.key" class="shrink-0 text-[#6366f1]" />
</div>
</a-card>
<a-card
hoverable
size="small"
class="bg-card overflow-hidden"
:class="{ 'bg-card--active': appStore.background === 'custom' }"
:body-style="{ padding: '8px 10px' }"
@click="selectCustomBackground"
>
<template #cover>
<div
v-if="appStore.customBackgroundUrl"
class="bg-thumb"
:style="{ backgroundImage: `url('${appStore.customBackgroundUrl}')` }"
/>
<div v-else class="bg-thumb bg-thumb--custom-empty">
<a-button type="link" :icon="h(PictureOutlined)" @click.stop="chooseCustomBackground">
{{ t("settings.appearance.chooseCustomBackground") }}
</a-button>
</div>
</template>
<div class="flex items-center justify-between gap-1 text-xs">
<span class="truncate">{{ t("settings.appearance.customBackground") }}</span>
<a-space :size="2">
<a-tooltip :title="t('settings.appearance.changeCustomBackground')">
<a-button
type="text"
size="small"
:icon="h(UploadOutlined)"
:aria-label="t('settings.appearance.changeCustomBackground')"
@click.stop="chooseCustomBackground"
/>
</a-tooltip>
<CheckCircleFilled v-if="appStore.background === 'custom'" class="shrink-0 text-[#6366f1]" />
</a-space>
</div>
</a-card>
</div>
</section>
@@ -69,7 +123,8 @@
<script setup lang="ts">
import { h } from "vue"
import { BulbFilled, BulbOutlined, CheckCircleFilled } from "@ant-design/icons-vue"
import { BulbFilled, BulbOutlined, CheckCircleFilled, DesktopOutlined, PictureOutlined, UploadOutlined } from "@ant-design/icons-vue"
import { message } from "ant-design-vue"
import { useI18n } from "vue-i18n"
import { backgroundOptions, type BackgroundKey } from "@/config/backgrounds"
import { useAppStore } from "@/stores/app"
@@ -77,14 +132,6 @@ import { useAppStore } from "@/stores/app"
const { t } = useI18n()
const appStore = useAppStore()
/** 主题模式:与 store 中的 isDark 双向绑定 */
const themeMode = computed({
get: () => (appStore.isDark ? "dark" : "light"),
set: (value: string | number) => {
appStore.isDark = value === "dark"
},
})
const themeOptions = computed(() => [
{
value: "light",
@@ -100,11 +147,34 @@ const themeOptions = computed(() => [
t("settings.appearance.dark"),
]),
},
{
value: "system",
label: h("span", { class: "inline-flex items-center gap-2" }, [
h(DesktopOutlined),
t("settings.appearance.system"),
]),
},
])
function selectBackground(key: BackgroundKey) {
appStore.setBackground(key)
}
async function selectCustomBackground() {
if (appStore.customBackgroundUrl) {
appStore.setBackground("custom")
return
}
await chooseCustomBackground()
}
async function chooseCustomBackground() {
try {
await appStore.chooseCustomBackground()
} catch (error) {
message.error(error instanceof Error ? error.message : String(error))
}
}
</script>
<style scoped lang="scss">
@@ -132,23 +202,86 @@ function selectBackground(key: BackgroundKey) {
}
.bg-thumb--none {
background-image: repeating-linear-gradient(45deg,
rgba(148, 163, 184, 0.25) 0 8px,
transparent 8px 16px);
background-color: #e9edef;
}
/* 透明度实时预览:纯色底图(暖调中性色,不用渐变)+ 卡片 / 弹窗两种磨砂效果 */
.bg-thumb--custom-empty {
display: flex;
align-items: center;
justify-content: center;
background: #dfe5e4;
}
.dark .bg-thumb--custom-empty {
background: #252f30;
}
.dark .bg-thumb--none {
background-color: #151c1d;
}
.overlay-preview {
position: relative;
display: flex;
height: 88px;
align-items: flex-end;
justify-content: space-between;
overflow: hidden;
padding: 12px;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 8px;
background-color: #71817b;
color: #14201d;
font-size: 11px;
}
.overlay-preview::before {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, var(--preview-overlay-alpha));
content: "";
pointer-events: none;
}
.overlay-preview > * {
position: relative;
}
.dark .overlay-preview {
border-color: rgba(255, 255, 255, 0.12);
background-color: #71817b;
color: #f1f5f3;
}
.dark .overlay-preview::before {
background: rgba(0, 0, 0, var(--preview-overlay-alpha));
}
.overlay-preview-toolbar {
display: flex;
gap: 5px;
}
.overlay-preview-toolbar i {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
opacity: 0.7;
}
/* 透明度实时预览:中性纯色底图 + 卡片 / 弹窗两种磨砂效果 */
.glass-preview {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border-radius: 12px;
background-color: #a09adf;
background-color: #778880;
}
.dark .glass-preview {
background-color: #a09adf;
background-color: #778880;
}
.glass-preview-card,