feat: 新增自定义窗口背景功能

This commit is contained in:
Yuhang Wu
2026-10-28 11:17:28 +08:00
parent 135e465b16
commit 74f7b7d30f
15 changed files with 345 additions and 5 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)
### 🎨 优化
+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;
+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;
+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")
}
+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 ?? ""
}
+3
View File
@@ -127,6 +127,9 @@
"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",
+3
View File
@@ -127,6 +127,9 @@
"system": "跟随系统",
"background": "背景图片",
"backgroundTip": "选择一张图片作为窗口背景,图片保持原始比例自适应,不会变形拉伸",
"customBackground": "自定义",
"chooseCustomBackground": "选择图片",
"changeCustomBackground": "更换自定义背景",
"backgroundOverlay": "窗口背景遮罩",
"backgroundOverlayTip": "为整个窗口叠加随主题变化的遮罩,改善背景与顶部栏图标的对比度",
"overlayTransparency": "遮罩透明度",
+34 -1
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,6 +10,7 @@ import {
getBackgroundUrl,
type BackgroundKey,
} from "@/config/backgrounds"
import { isTauri } from "@/utils/window"
/** Frpc 运行状态 */
export type RunStatus = "running" | "error" | "stopped"
@@ -46,7 +49,35 @@ export const useAppStore = defineStore("app", () => {
// 窗口背景图片(持久化)
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(() => {
@@ -118,6 +149,8 @@ export const useAppStore = defineStore("app", () => {
toggleDark,
background,
backgroundUrl,
customBackgroundUrl,
chooseCustomBackground,
cardTransparency,
modalTransparency,
backgroundOverlayTransparency,
+67 -1
View File
@@ -47,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>
@@ -85,7 +123,8 @@
<script setup lang="ts">
import { h } from "vue"
import { BulbFilled, BulbOutlined, CheckCircleFilled, DesktopOutlined } 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"
@@ -120,6 +159,22 @@ const themeOptions = computed(() => [
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">
@@ -150,6 +205,17 @@ function selectBackground(key: BackgroundKey) {
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;
}