feat: 新增系统主题跟随、窗口遮罩和运行日志持久化

- 新增系统主题模式,支持跟随操作系统明暗主题
- 添加窗口背景遮罩组件,可调节透明度改善对比度
- 实现 Frpc 运行日志按日期持久化到 logs 目录
- 优化侧边栏收起时的系统资源显示样式
- 新增中英文界面的相关翻译文本
- 实现系统资源统计的共享复用,避免重复请求
This commit is contained in:
Yuhang Wu
2026-10-28 10:55:01 +08:00
parent 538b502c52
commit 135e465b16
13 changed files with 419 additions and 97 deletions
+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",
+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)),
}
+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;
+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)
}
+6 -1
View File
@@ -121,11 +121,16 @@
"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.",
"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",
+6 -1
View File
@@ -121,11 +121,16 @@
"title": "外观设置",
"subtitle": "自定义窗口的主题模式与背景图片",
"themeMode": "主题模式",
"themeModeTip": "切换窗口的明暗显示模式",
"themeModeTip": "选择浅色、深色或跟随操作系统主题",
"light": "浅色",
"dark": "深色",
"system": "跟随系统",
"background": "背景图片",
"backgroundTip": "选择一张图片作为窗口背景,图片保持原始比例自适应,不会变形拉伸",
"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);
}
+26 -9
View File
@@ -11,6 +11,7 @@ import {
/** Frpc 运行状态 */
export type RunStatus = "running" | "error" | "stopped"
export type ThemeMode = "light" | "dark" | "system"
/**
* 全局应用配置:语言、主题、窗口背景、Frpc 运行状态
@@ -21,16 +22,27 @@ 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)
@@ -52,6 +64,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 +74,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 +82,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 +114,13 @@ export const useAppStore = defineStore("app", () => {
language,
sidebarCollapsed,
isDark,
themeMode,
toggleDark,
background,
backgroundUrl,
cardTransparency,
modalTransparency,
backgroundOverlayTransparency,
runStatus,
antdLocale,
setLanguage,
+83 -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>
@@ -69,7 +85,7 @@
<script setup lang="ts">
import { h } from "vue"
import { BulbFilled, BulbOutlined, CheckCircleFilled } from "@ant-design/icons-vue"
import { BulbFilled, BulbOutlined, CheckCircleFilled, DesktopOutlined } from "@ant-design/icons-vue"
import { useI18n } from "vue-i18n"
import { backgroundOptions, type BackgroundKey } from "@/config/backgrounds"
import { useAppStore } from "@/stores/app"
@@ -77,14 +93,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,6 +108,13 @@ 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) {
@@ -132,23 +147,75 @@ 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;
}
/* 透明度实时预览:纯色底图(暖调中性色,不用渐变)+ 卡片 / 弹窗两种磨砂效果 */
.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,