feat: 按安装目录隔离存储应用数据,实现多实例独立运行
新增 StoragePaths 模块,将数据库、设置、frp 版本等业务数据统一保存在应用实际安装位置,避免不同实例或版本间数据混淆。新增 Preferences 模块管理安装目录内的持久化设置,重构数据库初始化逻辑,修改 Frpc 运行目录、frp 版本存储路径,新增数据目录锁机制防止多进程冲突,同时更新文档说明数据目录规则与平台差异。
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
运行数据按安装位置分类保存,目录规则与平台差异见 [数据目录说明](docs/数据目录说明.md)。
|
||||
|
||||
> 📘 **新手必读**:第一次搭建内网穿透?请阅读 **[《Frp 内网穿透搭建指南》](docs/Frp搭建内网穿透指南.md)** —— 从公网服务端搭建到用 FrpcPilot 一键开启穿透,手把手带你跑通第一个隧道。
|
||||
|
||||
***
|
||||
|
||||
@@ -357,3 +357,5 @@ const toggleCollapsed = () => {
|
||||
- 我想到的方案是脚本,如果你有更好的方案的话,那就采用你的方案,如果没有,就按照我的要求来做,同时你需要考虑到我遗漏的地方
|
||||
- 数据迁移之后,可能还需要处理数据库中保存的frp软件目录所在位置
|
||||
- 迁移脚本需要考虑不同操作系统之间的差别
|
||||
- 或许可以考虑直接把之前的数据目录直接剪切到安装所在目录里面
|
||||
- 数据迁移之后需要保证功能和已配置的服务器、隧道可以正常启动
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# 数据目录说明
|
||||
|
||||
FrpcPilot 以实际安装位置保存业务数据。安装目录中没有数据库或设置文件时,直接创建空数据库并使用默认设置。旧版系统应用数据目录中的文件不会被读取、复制或删除,需要使用的服务器与隧道请在新版中重新配置。
|
||||
|
||||
## Windows 目录示例
|
||||
|
||||
安装在 `D:\software\FrpcPilot` 时:
|
||||
|
||||
```text
|
||||
D:\software\FrpcPilot\
|
||||
├── frpcpilot.exe
|
||||
├── data\
|
||||
│ ├── frpcpilot.db # 服务器、隧道、frp 记录
|
||||
│ ├── preferences.json # 语言、外观、关闭行为等设置
|
||||
│ └── storage.lock # 防止多个进程同时使用数据目录
|
||||
├── frp\versions\ # 各版本压缩包、解压文件和导入的程序
|
||||
├── run\frpc.toml # 启动穿透时根据数据库重新生成
|
||||
├── webview\ # Windows / Linux 浏览器数据与缓存
|
||||
├── cache\ # 应用缓存
|
||||
├── logs\ # 应用日志目录
|
||||
└── tmp\ # 应用与更新器使用的临时目录
|
||||
```
|
||||
|
||||
SQLite 的 journal / WAL / SHM 文件随数据库保存在 `data` 中。frpc 的工作目录是 `run`,相对路径的运行产物留在这里;实时日志仍输出到界面,不额外保存日志文件。导入的 frpc / frps 会复制到安装目录,原文件不修改。
|
||||
|
||||
## 各平台位置
|
||||
|
||||
| 平台 | 数据根目录 |
|
||||
| --- | --- |
|
||||
| Windows | `frpcpilot.exe` 所在目录 |
|
||||
| macOS `.app` | `.app` 同级的 `FrpcPilot-data` |
|
||||
| Linux AppImage | `APPIMAGE` 指向的实际文件所在目录 |
|
||||
| Linux 普通二进制 / deb | 实际可执行文件所在目录 |
|
||||
|
||||
macOS 使用应用包同级目录,避免破坏签名或在更新器替换应用包时丢失数据。AppImage 的临时只读挂载目录不会用于存储业务数据。开发构建使用可执行文件旁的 `FrpcPilot-dev`,与正式版配置隔离。
|
||||
|
||||
安装目录必须允许当前用户写入。Windows `Program Files`、macOS `/Applications`、Linux deb 默认的 `/usr/bin` 等位置可能不可写,此时应用会显示错误并停止,不回退到系统默认的应用数据目录。建议使用当前用户可写的专用安装文件夹;Linux 推荐将 AppImage 放在自己的应用目录。
|
||||
|
||||
macOS WKWebView 不支持任意指定持久化目录,其系统管理的浏览器缓存仍可能写入用户 Library;应用设置保存在 `data/preferences.json`。操作系统、WebView 运行时安装、崩溃报告以及系统安装器自身生成的文件,也不能保证完全避开系统盘。
|
||||
|
||||
## 备份与安装位置变更
|
||||
|
||||
备份当前数据时,先退出软件,再备份整个安装目录;macOS 需要备份同级的 `FrpcPilot-data`。数据库和 `run/frpc.toml` 含认证信息,请按私密文件保存。
|
||||
|
||||
程序不自动修正安装位置变更前保存的 frp 绝对路径。移动安装目录后,如原路径失效,请重新下载或导入 frpc。不同操作系统的二进制不兼容,需要使用目标平台的 frpc。
|
||||
@@ -22,7 +22,7 @@ tauri-build = { version = "2", features = [] }
|
||||
tauri-helper = "0.2.1"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri = { version = "2.12.3", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -12,6 +12,7 @@ use tokio::io::AsyncWriteExt;
|
||||
use crate::models::{
|
||||
ApiResponse, FrpDownload, FrpDownloadPayload, FrpDownloadProgress, FrpRelease,
|
||||
};
|
||||
use crate::services::storage::StoragePaths;
|
||||
|
||||
/// GitHub 镜像加速前缀(国内访问 GitHub 不稳定,拉取版本与下载统一走镜像)
|
||||
const GITHUB_MIRROR: &str = "https://gh-proxy.com/";
|
||||
@@ -133,14 +134,31 @@ async fn fetch_releases(client: &reqwest::Client) -> Result<Vec<Value>, String>
|
||||
Err(format!("获取 Frp 版本列表失败:{last_err}"))
|
||||
}
|
||||
|
||||
/// 应用数据目录下的 frp 版本存储根目录
|
||||
/// 安装目录下的 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"))
|
||||
Ok(app.state::<StoragePaths>().versions())
|
||||
}
|
||||
|
||||
fn version_directory(version: &str) -> String {
|
||||
if version != "."
|
||||
&& version != ".."
|
||||
&& !version.is_empty()
|
||||
&& version
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
|
||||
{
|
||||
version.to_string()
|
||||
} else {
|
||||
// 将任意 Unicode 文件名编码为十六进制,保证导入目录名称唯一。
|
||||
format!(
|
||||
"import-{}",
|
||||
version
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送下载进度事件
|
||||
@@ -153,7 +171,9 @@ fn emit_progress(
|
||||
message: Option<&str>,
|
||||
) {
|
||||
let percent = if total > 0 {
|
||||
((downloaded as f64 / total as f64) * 100.0).clamp(0.0, 100.0).round() as u8
|
||||
((downloaded as f64 / total as f64) * 100.0)
|
||||
.clamp(0.0, 100.0)
|
||||
.round() as u8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
@@ -219,7 +239,10 @@ pub async fn get_frp_releases(
|
||||
};
|
||||
|
||||
for asset in assets {
|
||||
let name = asset.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
let name = asset
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let Some((_v, os, arch)) = parse_asset(name) else {
|
||||
continue;
|
||||
};
|
||||
@@ -510,7 +533,9 @@ fn find_binary(dir: &Path, stem: &str) -> Option<PathBuf> {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_ascii_lowercase())
|
||||
let Some(name) = path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_ascii_lowercase())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -558,10 +583,12 @@ pub async fn delete_frp(
|
||||
};
|
||||
|
||||
if let Ok(base) = versions_dir(&app) {
|
||||
let ver_dir = base.join(&row.version);
|
||||
let ver_dir = base.join(version_directory(&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 Path::new(archive).starts_with(&base) {
|
||||
let _ = tokio::fs::remove_file(archive).await;
|
||||
}
|
||||
}
|
||||
// 版本目录已空则一并删除
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&ver_dir).await {
|
||||
@@ -570,7 +597,6 @@ pub async fn delete_frp(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match sqlx::query("DELETE FROM frp_downloads WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state)
|
||||
@@ -581,7 +607,7 @@ pub async fn delete_frp(
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入本地已下载的 frpc / frps(记录可执行文件路径,不复制文件)
|
||||
/// 导入本地 frpc / frps,将副本保存到安装目录,原始文件保持不变。
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub async fn import_frp(
|
||||
@@ -612,11 +638,33 @@ pub async fn import_frp(
|
||||
.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 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 binary_name = if is_frps { "frps" } else { "frpc" };
|
||||
// 对用户选择的文件名进行编码后再构造存储目录。
|
||||
let import_dir = versions_dir(&app)?
|
||||
.join(version_directory(&version))
|
||||
.join(&platform)
|
||||
.join("import");
|
||||
std::fs::create_dir_all(&import_dir).map_err(|e| e.to_string())?;
|
||||
let destination = import_dir.join(if cfg!(windows) {
|
||||
format!("{binary_name}.exe")
|
||||
} else {
|
||||
binary_name.to_string()
|
||||
});
|
||||
let same_file = match (std::fs::canonicalize(&destination), std::fs::canonicalize(&path)) {
|
||||
(Ok(destination), Ok(source)) => destination == source,
|
||||
_ => false,
|
||||
};
|
||||
if !same_file {
|
||||
std::fs::copy(&path, &destination).map_err(|e| format!("导入 Frp 失败:{e}"))?;
|
||||
}
|
||||
make_executable(&destination);
|
||||
let full = destination.to_string_lossy().to_string();
|
||||
let (frpc_path, frps_path) = if is_frps {
|
||||
(None, Some(full))
|
||||
} else {
|
||||
@@ -683,7 +731,10 @@ mod tests {
|
||||
|
||||
#[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("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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod frp;
|
||||
pub mod runner;
|
||||
pub mod server;
|
||||
pub mod storage;
|
||||
pub mod system;
|
||||
pub mod tunnel;
|
||||
|
||||
@@ -12,6 +12,7 @@ use tauri_helper::auto_collect_command;
|
||||
use crate::models::{ApiResponse, FrpDownload, FrpcStatus, Server, Tunnel};
|
||||
use crate::services::config::generate_frpc_config;
|
||||
use crate::services::runner::FrpcRunner;
|
||||
use crate::services::storage::StoragePaths;
|
||||
|
||||
// 统一查询字段,使用 concat! 保证结果为 'static str(sqlx 0.9 要求)
|
||||
macro_rules! server_fields {
|
||||
@@ -32,14 +33,9 @@ macro_rules! download_fields {
|
||||
};
|
||||
}
|
||||
|
||||
/// 本次运行生成的 frpc.toml 路径(应用数据目录/frp/run/frpc.toml)
|
||||
/// 本次运行生成的 frpc.toml 路径(安装目录/run/frpc.toml)
|
||||
fn config_file_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("frp")
|
||||
.join("run");
|
||||
let dir = app.state::<StoragePaths>().run();
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("创建配置目录失败:{e}"))?;
|
||||
Ok(dir.join("frpc.toml"))
|
||||
}
|
||||
@@ -77,7 +73,12 @@ pub async fn start_frpc(
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => return Ok(ApiResponse::error(404, "所选 Frpc 版本不存在,请刷新后重试")),
|
||||
Ok(None) => {
|
||||
return Ok(ApiResponse::error(
|
||||
404,
|
||||
"所选 Frpc 版本不存在,请刷新后重试",
|
||||
));
|
||||
}
|
||||
Err(e) => return Ok(ApiResponse::error(500, &e.to_string())),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::State;
|
||||
use tauri_helper::auto_collect_command;
|
||||
|
||||
use crate::services::storage::{StoragePaths, write_json};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PreferencesLock(pub Mutex<()>);
|
||||
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub fn get_preferences(
|
||||
paths: State<'_, StoragePaths>,
|
||||
lock: State<'_, PreferencesLock>,
|
||||
) -> Result<Option<BTreeMap<String, String>>, String> {
|
||||
let _guard = lock.0.lock().map_err(|e| e.to_string())?;
|
||||
let path = paths.data().join("preferences.json");
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||
.map(Some)
|
||||
.map_err(|e| e.to_string()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub fn set_preferences(
|
||||
paths: State<'_, StoragePaths>,
|
||||
lock: State<'_, PreferencesLock>,
|
||||
preferences: BTreeMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
if preferences
|
||||
.iter()
|
||||
.any(|(key, value)| !key.starts_with("frpcpilot:") || key.len() + value.len() > 65536)
|
||||
|| preferences.len() > 100
|
||||
{
|
||||
return Err("设置数据无效或过大".into());
|
||||
}
|
||||
let _guard = lock.0.lock().map_err(|e| e.to_string())?;
|
||||
write_json(&paths.data().join("preferences.json"), &preferences)
|
||||
}
|
||||
+49
-12
@@ -13,25 +13,44 @@ use crate::commands::server::{
|
||||
create_server, delete_server, get_server_list, set_server_enabled, set_server_pinned,
|
||||
update_server,
|
||||
};
|
||||
use crate::commands::storage::{PreferencesLock, get_preferences, set_preferences};
|
||||
use crate::commands::system::{get_system_details, get_system_info, is_autostart_supported};
|
||||
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::runner::FrpcRunner;
|
||||
use crate::services::storage::{self, StoragePaths};
|
||||
use crate::services::system::SystemStats;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let paths = StoragePaths::discover().expect("无法确定软件安装目录");
|
||||
// 此入口在启动 Tauri、Tokio 或任何工作线程之前调用。
|
||||
// 将更新、解压及子进程继承的临时文件目录统一指向安装目录。
|
||||
if std::fs::create_dir_all(paths.root.join("tmp")).is_ok() {
|
||||
unsafe {
|
||||
std::env::set_var("TEMP", paths.root.join("tmp"));
|
||||
std::env::set_var("TMP", paths.root.join("tmp"));
|
||||
std::env::set_var("TMPDIR", paths.root.join("tmp"));
|
||||
}
|
||||
}
|
||||
let mut context = tauri::generate_context!();
|
||||
context.config_mut().app.app_directories_override = Some(paths.overrides());
|
||||
// 在打开 WebView 前初始化数据目录,同时保留各平台的窗口配置。
|
||||
for window in &mut context.config_mut().app.windows {
|
||||
window.create = false;
|
||||
}
|
||||
let mut builder = tauri::Builder::default()
|
||||
.manage(paths)
|
||||
.manage(PreferencesLock::default())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init()) // 对话框插件
|
||||
.plugin(tauri_plugin_updater::Builder::new().build()) // 更新插件
|
||||
.plugin(tauri_plugin_process::init()) // 进程插件(更新后重启)
|
||||
.plugin(tauri_plugin_prevent_default::Builder::new().build());
|
||||
|
||||
// Development executables require Vite; debug builds also open a Windows console.
|
||||
// 开发版依赖 Vite,调试构建在 Windows 上还会打开控制台窗口。
|
||||
if is_autostart_supported() {
|
||||
builder = builder.plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
@@ -41,8 +60,26 @@ pub fn run() {
|
||||
|
||||
let app = builder
|
||||
.setup(|app| {
|
||||
// The plugin resolves the running executable dynamically. Refresh enabled
|
||||
// entries after reinstalling or moving the app, preserving disabled state.
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||
|
||||
let paths = app.state::<StoragePaths>();
|
||||
let initialize = || -> Result<_, Box<dyn std::error::Error>> {
|
||||
let (pool, lock) = tauri::async_runtime::block_on(storage::initialize(&paths))
|
||||
.map_err(std::io::Error::other)?;
|
||||
app.manage(pool);
|
||||
app.manage(lock);
|
||||
Ok(())
|
||||
};
|
||||
if let Err(error) = initialize() {
|
||||
app.dialog().message(format!(
|
||||
"无法初始化安装目录中的数据:{}\n\n{error}\n\n请确认安装目录可写、磁盘空间充足,并退出使用同一数据目录的其他实例。",
|
||||
paths.root.display()
|
||||
)).title("FrpcPilot 数据初始化失败").kind(MessageDialogKind::Error).blocking_show();
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
// 插件会动态解析当前可执行文件的位置;重新安装或移动应用后刷新
|
||||
// 已启用的自启动项,原本禁用的自启动项仍保持禁用。
|
||||
if is_autostart_supported() {
|
||||
let autostart = app.autolaunch();
|
||||
let result = autostart.is_enabled().and_then(|enabled| {
|
||||
@@ -57,13 +94,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
let pool = tauri::async_runtime::block_on(async {
|
||||
init_pool(app.handle())
|
||||
.await
|
||||
.expect("sqlite数据库连接池初始化失败!")
|
||||
});
|
||||
app.manage(pool);
|
||||
|
||||
// 系统资源占用(CPU / 内存)后台采集
|
||||
let stats = SystemStats::default();
|
||||
stats.start();
|
||||
@@ -72,12 +102,19 @@ pub fn run() {
|
||||
// Frpc 进程管理器(一键启动 / 停止与日志实时回显)
|
||||
app.manage(FrpcRunner::default());
|
||||
|
||||
for config in &app.config().app.windows {
|
||||
let window = tauri::WebviewWindowBuilder::from_config(app.handle(), config)?;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let window = window.data_directory(paths.webview());
|
||||
window.build()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
// .invoke_handler(tauri::generate_handler![greet])
|
||||
.invoke_handler(tauri_helper::tauri_collect_commands!())
|
||||
.build(tauri::generate_context!())
|
||||
.build(context)
|
||||
.expect("error while building tauri application");
|
||||
|
||||
app.run(|app_handle, event| {
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use std::path::Path;
|
||||
|
||||
pub async fn init_pool(app: &AppHandle) -> Result<SqlitePool, sqlx::Error> {
|
||||
// 数据库文件路径
|
||||
let app_dir = app.path().app_data_dir().expect("无法获取APP数据目录");
|
||||
std::fs::create_dir_all(&app_dir).unwrap();
|
||||
let db_path = app_dir.join("frpcpilot.db");
|
||||
pub async fn init_pool(db_path: &Path) -> Result<SqlitePool, sqlx::Error> {
|
||||
// 初始化数据库连接池
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(&db_path)
|
||||
.filename(db_path)
|
||||
.create_if_missing(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod runner;
|
||||
pub mod storage;
|
||||
pub mod system;
|
||||
|
||||
@@ -97,8 +97,8 @@ impl FrpcRunner {
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
// 工作目录设为 frpc 所在目录,保证其相对路径配置可正常解析
|
||||
if let Some(dir) = exe.parent() {
|
||||
// 相对路径的运行产物统一保存在生成配置所在的目录。
|
||||
if let Some(dir) = Path::new(config_path).parent() {
|
||||
command.current_dir(dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! 按安装位置分类存储应用数据。
|
||||
//! 此模块在创建任何 WebView 或打开应用数据库连接池之前执行。
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
use sqlx::SqlitePool;
|
||||
use tauri::utils::config::{AppDirectoriesOverride, AppDirectoryOverrides};
|
||||
|
||||
use super::db::init_pool;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StoragePaths {
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
impl StoragePaths {
|
||||
pub fn discover() -> Result<Self, String> {
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
// APPIMAGE 指向实际的安装文件,避免使用只读的 /tmp/.mount_* 挂载目录。
|
||||
let appimage = if cfg!(target_os = "linux") && !cfg!(debug_assertions) {
|
||||
std::env::var_os("APPIMAGE").map(PathBuf::from)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut root = installation_root(&exe, appimage.as_deref(), cfg!(target_os = "macos"))?;
|
||||
// 开发和测试使用独立的数据目录,避免影响正式版配置。
|
||||
if cfg!(debug_assertions) {
|
||||
root = root.join("FrpcPilot-dev");
|
||||
}
|
||||
Ok(Self { root })
|
||||
}
|
||||
|
||||
pub fn data(&self) -> PathBuf {
|
||||
self.root.join("data")
|
||||
}
|
||||
pub fn db(&self) -> PathBuf {
|
||||
self.data().join("frpcpilot.db")
|
||||
}
|
||||
pub fn versions(&self) -> PathBuf {
|
||||
self.root.join("frp").join("versions")
|
||||
}
|
||||
pub fn run(&self) -> PathBuf {
|
||||
self.root.join("run")
|
||||
}
|
||||
pub fn webview(&self) -> PathBuf {
|
||||
self.root.join("webview")
|
||||
}
|
||||
pub fn overrides(&self) -> AppDirectoriesOverride {
|
||||
AppDirectoriesOverride::Directories(AppDirectoryOverrides {
|
||||
config: Some(self.data()),
|
||||
data: Some(self.data()),
|
||||
local_data: Some(self.webview()),
|
||||
cache: Some(self.root.join("cache")),
|
||||
log: Some(self.root.join("logs")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn installation_root(exe: &Path, appimage: Option<&Path>, macos: bool) -> Result<PathBuf, String> {
|
||||
if macos {
|
||||
if let Some(bundle) = exe
|
||||
.ancestors()
|
||||
.find(|p| p.extension().is_some_and(|e| e == "app"))
|
||||
{
|
||||
// 数据保存在应用包同级目录,避免 macOS 更新器替换签名应用包时丢失数据。
|
||||
return Ok(bundle.with_file_name("FrpcPilot-data"));
|
||||
}
|
||||
}
|
||||
let location = appimage.unwrap_or(exe);
|
||||
if !location.is_absolute() {
|
||||
return Err(format!("安装位置必须为绝对路径:{}", location.display()));
|
||||
}
|
||||
location
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| "无法确定安装目录".into())
|
||||
}
|
||||
|
||||
/// 在应用整个生命周期内持有文件锁,进程崩溃后操作系统也会自动释放锁。
|
||||
pub struct StorageLock(File);
|
||||
|
||||
impl StorageLock {
|
||||
fn acquire(path: &Path) -> Result<Self, String> {
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(path)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"无法写入 {}:{e}。请将软件安装到当前用户可写的目录。",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
file.try_lock().map_err(|e| {
|
||||
format!(
|
||||
"数据目录正在被另一个 FrpcPilot 使用:{}({e})。请先退出其他实例。",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(Self(file))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StorageLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn initialize(paths: &StoragePaths) -> Result<(SqlitePool, StorageLock), String> {
|
||||
fs::create_dir_all(paths.data()).map_err(|e| {
|
||||
format!(
|
||||
"无法创建数据目录 {}:{e}。请使用可写的安装目录。",
|
||||
paths.data().display()
|
||||
)
|
||||
})?;
|
||||
let lock = StorageLock::acquire(&paths.data().join("storage.lock"))?;
|
||||
for dir in [
|
||||
paths.versions(),
|
||||
paths.run(),
|
||||
paths.webview(),
|
||||
paths.root.join("cache"),
|
||||
paths.root.join("logs"),
|
||||
paths.root.join("tmp"),
|
||||
] {
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("无法创建 {}:{e}", dir.display()))?;
|
||||
}
|
||||
let pool = init_pool(&paths.db())
|
||||
.await
|
||||
.map_err(|e| format!("初始化数据库失败:{e}"))?;
|
||||
Ok((pool, lock))
|
||||
}
|
||||
|
||||
pub fn write_json(path: &Path, value: &impl Serialize) -> Result<(), String> {
|
||||
let bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
|
||||
let temp = path.with_extension("json.tmp");
|
||||
let mut file = File::create(&temp).map_err(|e| e.to_string())?;
|
||||
file.write_all(&bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|e| e.to_string())?;
|
||||
drop(file);
|
||||
// std::fs::rename 在 Windows 和 Unix 上均以原子方式替换已有文件。
|
||||
fs::rename(&temp, path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "storage_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,74 @@
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("target")
|
||||
.join("storage-tests")
|
||||
.join(format!(
|
||||
"{}-{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::SeqCst)
|
||||
));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
fn paths(&self, name: &str) -> StoragePaths {
|
||||
StoragePaths {
|
||||
root: self.0.join(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_locations_cover_bundles_and_appimage() {
|
||||
let f = Fixture::new();
|
||||
assert_eq!(
|
||||
installation_root(&f.0.join("pilot/frpcpilot"), None, false).unwrap(),
|
||||
f.0.join("pilot")
|
||||
);
|
||||
assert_eq!(
|
||||
installation_root(
|
||||
&f.0.join(".mount_Pilot/usr/bin/frpcpilot"),
|
||||
Some(&f.0.join("apps/FrpcPilot.AppImage")),
|
||||
false
|
||||
)
|
||||
.unwrap(),
|
||||
f.0.join("apps")
|
||||
);
|
||||
assert_eq!(
|
||||
installation_root(
|
||||
&f.0.join("Applications/FrpcPilot.app/Contents/MacOS/frpcpilot"),
|
||||
None,
|
||||
true
|
||||
)
|
||||
.unwrap(),
|
||||
f.0.join("Applications/FrpcPilot-data")
|
||||
);
|
||||
assert!(installation_root(Path::new("relative/frpcpilot"), None, false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_install_and_exclusive_storage_lock() {
|
||||
tauri::async_runtime::block_on(async {
|
||||
let f = Fixture::new();
|
||||
let paths = f.paths("new");
|
||||
let (pool, lock) = initialize(&paths).await.unwrap();
|
||||
assert!(paths.db().is_file());
|
||||
assert!(paths.run().is_dir());
|
||||
assert!(paths.versions().is_dir());
|
||||
assert!(initialize(&paths).await.is_err());
|
||||
pool.close().await;
|
||||
drop(lock);
|
||||
let (pool, _lock) = initialize(&paths).await.unwrap();
|
||||
pool.close().await;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Update } from "@tauri-apps/plugin-updater"
|
||||
import { isTauri } from "@/utils/window"
|
||||
import { flushPreferences } from "@/utils/preferences"
|
||||
|
||||
/** 项目 GitHub 仓库地址(与 tauri.conf.json 中 updater 端点保持一致) */
|
||||
export const PROJECT_GITHUB_URL = "https://github.com/SparksFly91/FrpcPilot"
|
||||
@@ -18,6 +19,7 @@ export async function checkForAppUpdate(): Promise<Update | null> {
|
||||
/** 重启应用(macOS / Linux 安装更新后需要重启才能运行新版本) */
|
||||
export async function relaunchApp() {
|
||||
if (!isTauri()) return
|
||||
await flushPreferences()
|
||||
const { relaunch } = await import("@tauri-apps/plugin-process")
|
||||
await relaunch()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createApp } from "vue"
|
||||
import { createPinia } from "pinia"
|
||||
import App from "./App.vue"
|
||||
import "@/assets/main.css"
|
||||
// 引入 Ant Design Vue 组件库
|
||||
import Antd from "ant-design-vue"
|
||||
import "ant-design-vue/dist/reset.css"
|
||||
// 引入路由
|
||||
import router from "@/router/router"
|
||||
// 引入国际化
|
||||
import i18n from "@/i18n"
|
||||
import { detectPlatform } from "@/composables/usePlatform"
|
||||
import { nextTick } from "vue"
|
||||
import { useOnboardingStore } from "@/stores/onboarding"
|
||||
|
||||
/*
|
||||
* macOS 的无边框窗口系统不会自动裁圆角,需要配合 transparent 窗口,
|
||||
* 由 #app 自身裁出 8px 圆角(见 assets/main.css)。这里在挂载前打上平台标记,
|
||||
* 避免首帧出现方角闪烁。
|
||||
*/
|
||||
if (detectPlatform() === "macos") {
|
||||
document.documentElement.classList.add("is-macos")
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(Antd)
|
||||
app.use(i18n)
|
||||
app.use(router)
|
||||
// 路由懒加载完成后再挂载,避免启动画面移除后出现空白内容区。
|
||||
async function mountApp() {
|
||||
await router.isReady()
|
||||
app.mount("#app")
|
||||
await nextTick()
|
||||
// 让浏览器先绘制完整布局,再揭开启动画面并开始首次引导。
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById("startup-loader")?.remove()
|
||||
useOnboardingStore().ready = true
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
void mountApp()
|
||||
+12
-41
@@ -1,44 +1,15 @@
|
||||
import { createApp } from "vue"
|
||||
import { createPinia } from "pinia"
|
||||
import App from "./App.vue"
|
||||
import "@/assets/main.css"
|
||||
// 引入 Ant Design Vue 组件库
|
||||
import Antd from "ant-design-vue"
|
||||
import "ant-design-vue/dist/reset.css"
|
||||
// 引入路由
|
||||
import router from "@/router/router"
|
||||
// 引入国际化
|
||||
import i18n from "@/i18n"
|
||||
import { detectPlatform } from "@/composables/usePlatform"
|
||||
import { nextTick } from "vue"
|
||||
import { useOnboardingStore } from "@/stores/onboarding"
|
||||
import { initializePreferences } from "@/utils/preferences"
|
||||
|
||||
/*
|
||||
* macOS 的无边框窗口系统不会自动裁圆角,需要配合 transparent 窗口,
|
||||
* 由 #app 自身裁出 8px 圆角(见 assets/main.css)。这里在挂载前打上平台标记,
|
||||
* 避免首帧出现方角闪烁。
|
||||
*/
|
||||
if (detectPlatform() === "macos") {
|
||||
document.documentElement.classList.add("is-macos")
|
||||
// 状态仓库创建时会读取初始设置,因此先加载安装目录内保存的设置。
|
||||
async function start() {
|
||||
try {
|
||||
await initializePreferences()
|
||||
await import("./bootstrap")
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize application", error)
|
||||
const loader = document.getElementById("startup-loader")
|
||||
if (loader) loader.textContent = `无法读取应用设置 / Cannot load settings: ${String(error)}`
|
||||
}
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(Antd)
|
||||
app.use(i18n)
|
||||
app.use(router)
|
||||
// 路由懒加载完成后再挂载,避免启动画面移除后出现空白内容区。
|
||||
async function mountApp() {
|
||||
await router.isReady()
|
||||
app.mount("#app")
|
||||
await nextTick()
|
||||
// 让浏览器先绘制完整布局,再揭开启动画面并开始首次引导。
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById("startup-loader")?.remove()
|
||||
useOnboardingStore().ready = true
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
void mountApp()
|
||||
void start()
|
||||
|
||||
+8
-6
@@ -1,4 +1,5 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { preferences } from "@/utils/preferences"
|
||||
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"
|
||||
@@ -15,13 +16,14 @@ export type RunStatus = "running" | "error" | "stopped"
|
||||
* 全局应用配置:语言、主题、窗口背景、Frpc 运行状态
|
||||
*/
|
||||
export const useAppStore = defineStore("app", () => {
|
||||
// 语言(持久化到 localStorage)
|
||||
const language = useStorage<Language>("frpcpilot:language", "zh-CN")
|
||||
// 安装目录内的持久化设置
|
||||
const language = useStorage<Language>("frpcpilot:language", "zh-CN", preferences)
|
||||
|
||||
const sidebarCollapsed = useStorage("frpcpilot:sidebar-collapsed", false)
|
||||
const sidebarCollapsed = useStorage("frpcpilot:sidebar-collapsed", false, preferences)
|
||||
|
||||
// 明暗主题:useDark 会在 <html> 上切换 .dark 类名
|
||||
const isDark = useDark({
|
||||
storage: preferences,
|
||||
storageKey: "frpcpilot:theme",
|
||||
selector: "html",
|
||||
attribute: "class",
|
||||
@@ -31,7 +33,7 @@ export const useAppStore = defineStore("app", () => {
|
||||
const toggleDark = useToggle(isDark)
|
||||
|
||||
// 窗口背景图片(持久化)
|
||||
const background = useStorage<BackgroundKey>("frpcpilot:background", DEFAULT_BACKGROUND)
|
||||
const background = useStorage<BackgroundKey>("frpcpilot:background", DEFAULT_BACKGROUND, preferences)
|
||||
const backgroundUrl = computed(() => getBackgroundUrl(background.value))
|
||||
|
||||
// 把背景图同步到 <html>(整窗背景,避免元素高度误差造成的上下留白)
|
||||
@@ -48,8 +50,8 @@ export const useAppStore = defineStore("app", () => {
|
||||
})
|
||||
|
||||
// 磨砂玻璃透明度(0 - 100,数值越大越透明,持久化)
|
||||
const cardTransparency = useStorage<number>("frpcpilot:card-transparency", 55)
|
||||
const modalTransparency = useStorage<number>("frpcpilot:modal-transparency", 38)
|
||||
const cardTransparency = useStorage<number>("frpcpilot:card-transparency", 55, preferences)
|
||||
const modalTransparency = useStorage<number>("frpcpilot:modal-transparency", 38, preferences)
|
||||
|
||||
/** 透明度百分比 → 背景 alpha(越大越透明,alpha 越小;并做范围兜底) */
|
||||
function alphaFromTransparency(value: number) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { preferences } from "@/utils/preferences"
|
||||
|
||||
export const useOnboardingStore = defineStore("onboarding", () => {
|
||||
const dismissed = useStorage("frpcpilot:onboarding-dismissed", false)
|
||||
const dismissed = useStorage("frpcpilot:onboarding-dismissed", false, preferences)
|
||||
const ready = ref(false)
|
||||
const active = ref(false)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { preferences, flushPreferences } from "@/utils/preferences"
|
||||
import { message } from "ant-design-vue"
|
||||
import type { Update } from "@tauri-apps/plugin-updater"
|
||||
import { checkForAppUpdate, relaunchApp } from "@/api/updater"
|
||||
@@ -20,7 +21,7 @@ export interface UpdateInfo {
|
||||
*/
|
||||
export const useUpdateStore = defineStore("update", () => {
|
||||
/** 用户选择「忽略该版本」后持久化的版本号,静默检查时不再提示 */
|
||||
const ignoredVersion = useStorage<string>("frpcpilot:ignored-update", "")
|
||||
const ignoredVersion = useStorage<string>("frpcpilot:ignored-update", "", preferences)
|
||||
|
||||
const checking = ref(false)
|
||||
const installing = ref(false)
|
||||
@@ -137,6 +138,7 @@ export const useUpdateStore = defineStore("update", () => {
|
||||
installing.value = true
|
||||
resetProgress()
|
||||
try {
|
||||
await flushPreferences()
|
||||
await pending.downloadAndInstall((event) => {
|
||||
switch (event.event) {
|
||||
case "Started":
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { preferences } from "@/utils/preferences"
|
||||
import { exitApp, hideWindow, isTauri, revealWindow } from "@/utils/window"
|
||||
import { useFrpcStore } from "@/stores/frpc"
|
||||
|
||||
@@ -24,9 +25,9 @@ export const useWindowStore = defineStore("window", () => {
|
||||
/** 关闭确认对话框是否打开 */
|
||||
const closeConfirmOpen = ref(false)
|
||||
/** 记住的关闭方式(弹窗默认选中项;「保持以上选择」时直接生效,持久化) */
|
||||
const closeChoice = useStorage<CloseChoice>("frpcpilot:close-choice", "tray")
|
||||
const closeChoice = useStorage<CloseChoice>("frpcpilot:close-choice", "tray", preferences)
|
||||
/** 关闭行为:每次询问 / 保持以上选择(持久化,默认每次询问) */
|
||||
const closeBehavior = useStorage<CloseBehavior>("frpcpilot:close-behavior", "ask")
|
||||
const closeBehavior = useStorage<CloseBehavior>("frpcpilot:close-behavior", "ask", preferences)
|
||||
/** 是否正在退出(停止穿透 + 退出软件) */
|
||||
const quitting = ref(false)
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
|
||||
const desktop = "__TAURI_INTERNALS__" in window
|
||||
let values: Record<string, string> = {}
|
||||
let pending: Promise<void> = Promise.resolve()
|
||||
let saveError: unknown
|
||||
|
||||
/** 在加载 Vue 状态仓库前读取安装目录内的设置。 */
|
||||
export async function initializePreferences() {
|
||||
if (!desktop) return
|
||||
const saved = await invoke<Record<string, string> | null>("get_preferences")
|
||||
if (saved !== null) {
|
||||
values = saved
|
||||
} else {
|
||||
values = {}
|
||||
await invoke("set_preferences", { preferences: values })
|
||||
}
|
||||
}
|
||||
|
||||
function persist() {
|
||||
const snapshot = { ...values }
|
||||
pending = pending.then(async () => {
|
||||
try {
|
||||
await invoke("set_preferences", { preferences: snapshot })
|
||||
saveError = undefined
|
||||
} catch (error) {
|
||||
saveError = error
|
||||
console.error("Failed to save preferences", error)
|
||||
const { message } = await import("ant-design-vue")
|
||||
message.error(`保存设置失败 / Failed to save settings: ${String(error)}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** VueUse 存储适配器,将应用偏好设置保存在 data/preferences.json 中。 */
|
||||
export const preferences: Storage = {
|
||||
get length() { return desktop ? Object.keys(values).length : localStorage.length },
|
||||
key(index) { return desktop ? Object.keys(values)[index] ?? null : localStorage.key(index) },
|
||||
getItem(key) { return desktop ? values[key] ?? null : localStorage.getItem(key) },
|
||||
setItem(key, value) {
|
||||
if (!desktop) return localStorage.setItem(key, value)
|
||||
if (values[key] === value) return
|
||||
values[key] = value
|
||||
persist()
|
||||
},
|
||||
removeItem(key) {
|
||||
if (!desktop) return localStorage.removeItem(key)
|
||||
delete values[key]
|
||||
persist()
|
||||
},
|
||||
clear() {
|
||||
if (!desktop) return localStorage.clear()
|
||||
values = {}
|
||||
persist()
|
||||
},
|
||||
}
|
||||
|
||||
export async function flushPreferences() {
|
||||
await pending
|
||||
if (saveError) throw saveError
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
* 在纯浏览器环境(非 Tauri)下调用会被安全忽略,方便 `pnpm dev` 调试。
|
||||
*/
|
||||
|
||||
import { flushPreferences } from "@/utils/preferences"
|
||||
|
||||
export function isTauri() {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window
|
||||
}
|
||||
@@ -49,6 +51,7 @@ export async function revealWindow() {
|
||||
/** 退出整个应用(退出前由调用方负责停止穿透等收尾工作) */
|
||||
export async function exitApp() {
|
||||
if (!isTauri()) return
|
||||
await flushPreferences()
|
||||
const { exit } = await import("@tauri-apps/plugin-process")
|
||||
await exit(0)
|
||||
}
|
||||
|
||||
@@ -15,14 +15,16 @@
|
||||
<a-divider class="general-divider" />
|
||||
|
||||
<!-- 语言:与顶部栏语言切换共用同一状态 -->
|
||||
<section>
|
||||
<div class="mb-1 text-sm font-medium">{{ t("settings.general.language") }}</div>
|
||||
<p class="mb-2 text-xs opacity-60">{{ t("settings.general.languageTip") }}</p>
|
||||
<a-radio-group
|
||||
<section class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">{{ t("settings.general.language") }}</div>
|
||||
<p class="mt-1 text-xs opacity-60">{{ t("settings.general.languageTip") }}</p>
|
||||
</div>
|
||||
<a-select
|
||||
v-model:value="appStore.language"
|
||||
:options="languageRadios"
|
||||
option-type="button"
|
||||
button-style="solid"
|
||||
:options="languageSelectOptions"
|
||||
:aria-label="t('settings.general.language')"
|
||||
class="w-36 shrink-0"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -91,7 +93,7 @@ const choiceOptions = computed(() => [
|
||||
])
|
||||
|
||||
/** 语言选项与顶部栏共用 languageOptions,保证两端一致 */
|
||||
const languageRadios = languageOptions.map((item) => ({ label: item.label, value: item.key }))
|
||||
const languageSelectOptions = languageOptions.map((item) => ({ label: item.label, value: item.key }))
|
||||
|
||||
/** 无法访问系统启动项时(非桌面环境)给出说明,避免开关无故置灰 */
|
||||
const autostartTip = computed(() =>
|
||||
|
||||
Reference in New Issue
Block a user