feat: 新增设置页面及外观设置功能
添加了设置页面路由、外观设置子页面,支持主题模式切换和自定义窗口背景图片,新增相关 Rust 后端模块、数据库初始化、多语言文案及配置项,更新 Tauri 配置和项目描述。
This commit is contained in:
@@ -24,4 +24,15 @@
|
|||||||
- Header顶部栏,根据操作系统类型引入components里面的WindowsTitleBar.vue或MacTitleBar.vue
|
- Header顶部栏,根据操作系统类型引入components里面的WindowsTitleBar.vue或MacTitleBar.vue
|
||||||
4. Layout布局的side、content区域全部采用圆角矩形、类似于悬浮磨砂玻璃效果
|
4. Layout布局的side、content区域全部采用圆角矩形、类似于悬浮磨砂玻璃效果
|
||||||
|
|
||||||
|
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||||
|
|
||||||
|
### 2. 设置页面外观设置
|
||||||
|
|
||||||
|
#### 窗口背景设置开发
|
||||||
|
|
||||||
|
- 我新加了一个设置页面Setting.vue和一个外观设置Appearance.vue文件,帮我在路由表里面增加设置页路由,以及外观设置子路由
|
||||||
|
- 设置页面左侧为菜单栏,先增加第一项菜单-外观设置,设置项为主题模式切换(明/暗)、背景图片切换
|
||||||
|
|
||||||
|
- 背景图片使用assets/images目录中的bg-1.png、bg-2.jpg、bg-3.png作为窗口背景图片,在设置页面里面可以选择其中一个切换页面背景,注意尽可能保持原图片的比例大小不发生拉伸
|
||||||
|
|
||||||
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||||
Generated
+6224
File diff suppressed because it is too large
Load Diff
+19
-3
@@ -1,8 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "frpilot"
|
name = "frpilot"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "A Tauri App"
|
description = "一个基于 Tauri 2 + Vue 3 + TypeScript 的 [frp](https://github.com/fatedier/frp) 可视化客户端,帮助用户快速配置 frpc 版本、服务器与隧道。"
|
||||||
authors = ["you"]
|
authors = ["SparksFly"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
@@ -14,15 +14,31 @@ edition = "2024"
|
|||||||
name = "frpilot_lib"
|
name = "frpilot_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = ["."]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
tauri-helper = "0.2.1"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
tauri-plugin-store = "2.4.4"
|
||||||
|
tauri-helper = "0.2.1"
|
||||||
|
sqlx = { version = "0.9.0", features = [
|
||||||
|
"runtime-tokio",
|
||||||
|
"sqlite",
|
||||||
|
"tls-native-tls",
|
||||||
|
"chrono",
|
||||||
|
] }
|
||||||
|
chrono = { version = "0.4.45", features = ["serde"] }
|
||||||
|
tauri-plugin-dialog = "2.7.2"
|
||||||
|
tauri-plugin-updater = "2.10.1"
|
||||||
|
tauri-plugin-process = "2.3.1"
|
||||||
|
tauri-plugin-prevent-default = "6.0.1"
|
||||||
|
|
||||||
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
|
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
|
tauri_helper::generate_command_file(tauri_helper::TauriHelperOptions::default());
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod tunnel;
|
||||||
|
pub mod server;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_helper::auto_collect_command;
|
||||||
|
|
||||||
|
use crate::models::{ApiResponse};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn get_server_list(state: State<'_, SqlitePool>) -> Result<ApiResponse<()>, String> {
|
||||||
|
Ok(ApiResponse::success_empty())
|
||||||
|
}
|
||||||
+25
-6
@@ -1,14 +1,33 @@
|
|||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
mod commands;
|
||||||
#[tauri::command]
|
mod models;
|
||||||
fn greet(name: &str) -> String {
|
mod services;
|
||||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
|
||||||
}
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use crate::commands::server::get_server_list;
|
||||||
|
use crate::services::db::init_pool;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![greet])
|
.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())
|
||||||
|
.setup(|app| {
|
||||||
|
let pool = tauri::async_runtime::block_on(async {
|
||||||
|
init_pool(app.handle())
|
||||||
|
.await
|
||||||
|
.expect("sqlite数据库连接池初始化失败!")
|
||||||
|
});
|
||||||
|
app.manage(pool);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
|
// .invoke_handler(tauri::generate_handler![greet])
|
||||||
|
.invoke_handler(tauri_helper::tauri_collect_commands!())
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct PageResult<T> {
|
||||||
|
pub total: i64,
|
||||||
|
pub list: Vec<T>,
|
||||||
|
pub page: i32,
|
||||||
|
pub page_size: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ApiResponse<T> {
|
||||||
|
success: bool,
|
||||||
|
code: i32,
|
||||||
|
msg: String,
|
||||||
|
data: Option<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> ApiResponse<T> {
|
||||||
|
// 成功响应
|
||||||
|
pub fn success(data: T) -> Self {
|
||||||
|
Self {
|
||||||
|
success: true,
|
||||||
|
code: 0,
|
||||||
|
msg: "操作成功".to_string(),
|
||||||
|
data: Some(data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 错误响应
|
||||||
|
pub fn error(code: i32, message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
success: false,
|
||||||
|
code,
|
||||||
|
msg: message.to_string(),
|
||||||
|
data: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiResponse<()> {
|
||||||
|
// 成功响应,数据为空
|
||||||
|
pub fn success_empty() -> Self {
|
||||||
|
Self {
|
||||||
|
success: true,
|
||||||
|
code: 0,
|
||||||
|
msg: "操作成功".to_string(),
|
||||||
|
data: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
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("reader.db");
|
||||||
|
// 初始化数据库连接池
|
||||||
|
let options = SqliteConnectOptions::new()
|
||||||
|
.filename(&db_path)
|
||||||
|
.create_if_missing(true);
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.min_connections(1)
|
||||||
|
.connect_with(options)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod db;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "frpilot",
|
"productName": "frpilot",
|
||||||
"version": "0.1.0",
|
"version": "../package.json",
|
||||||
"identifier": "frpilot",
|
"identifier": "frpilot",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
@@ -35,5 +35,13 @@
|
|||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"active": true,
|
||||||
|
"dialog": true,
|
||||||
|
"endpoints": ["https://ghproxy.net/https://github.com/SparksFly91/Frpilot/releases/latest/download/latest.json"],
|
||||||
|
"pubkey": ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 359 KiB |
@@ -0,0 +1,30 @@
|
|||||||
|
import bg1 from "@/assets/images/bg-1.webp"
|
||||||
|
import bg2 from "@/assets/images/bg-2.webp"
|
||||||
|
import bg3 from "@/assets/images/bg-3.webp"
|
||||||
|
|
||||||
|
/** 窗口背景图片标识 */
|
||||||
|
export type BackgroundKey = "none" | "bg-1" | "bg-2" | "bg-3"
|
||||||
|
|
||||||
|
export interface BackgroundOption {
|
||||||
|
/** 唯一标识 */
|
||||||
|
key: BackgroundKey
|
||||||
|
/** i18n 文案 key */
|
||||||
|
label: string
|
||||||
|
/** 图片地址(Vite 打包后为最终资源路径) */
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 可选背景列表,新增背景图时在此追加即可 */
|
||||||
|
export const backgroundOptions: BackgroundOption[] = [
|
||||||
|
{ key: "none", label: "settings.appearance.none", url: "" },
|
||||||
|
{ key: "bg-1", label: "settings.appearance.bg1", url: bg1 },
|
||||||
|
{ key: "bg-2", label: "settings.appearance.bg2", url: bg2 },
|
||||||
|
{ key: "bg-3", label: "settings.appearance.bg3", url: bg3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEFAULT_BACKGROUND: BackgroundKey = "none"
|
||||||
|
|
||||||
|
/** 根据标识取图片地址 */
|
||||||
|
export function getBackgroundUrl(key: BackgroundKey): string {
|
||||||
|
return backgroundOptions.find((item) => item.key === key)?.url ?? ""
|
||||||
|
}
|
||||||
@@ -34,7 +34,24 @@
|
|||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"placeholder": "The settings page is under construction."
|
"placeholder": "The settings page is under construction.",
|
||||||
|
"menu": {
|
||||||
|
"appearance": "Appearance"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "Appearance",
|
||||||
|
"subtitle": "Customize the theme mode and window background",
|
||||||
|
"themeMode": "Theme mode",
|
||||||
|
"themeModeTip": "Switch between light and dark mode",
|
||||||
|
"light": "Light",
|
||||||
|
"dark": "Dark",
|
||||||
|
"background": "Background image",
|
||||||
|
"backgroundTip": "Pick an image as the window background. It keeps its original aspect ratio and is never stretched.",
|
||||||
|
"none": "Default",
|
||||||
|
"bg1": "Background 1",
|
||||||
|
"bg2": "Background 2",
|
||||||
|
"bg3": "Background 3"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"developing": "This feature is under construction"
|
"developing": "This feature is under construction"
|
||||||
|
|||||||
@@ -34,7 +34,24 @@
|
|||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "设置",
|
"title": "设置",
|
||||||
"placeholder": "设置页面开发中,敬请期待。"
|
"placeholder": "设置页面开发中,敬请期待。",
|
||||||
|
"menu": {
|
||||||
|
"appearance": "外观设置"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "外观设置",
|
||||||
|
"subtitle": "自定义窗口的主题模式与背景图片",
|
||||||
|
"themeMode": "主题模式",
|
||||||
|
"themeModeTip": "切换窗口的明暗显示模式",
|
||||||
|
"light": "浅色",
|
||||||
|
"dark": "深色",
|
||||||
|
"background": "背景图片",
|
||||||
|
"backgroundTip": "选择一张图片作为窗口背景,图片保持原始比例自适应,不会变形拉伸",
|
||||||
|
"none": "默认",
|
||||||
|
"bg1": "背景一",
|
||||||
|
"bg2": "背景二",
|
||||||
|
"bg3": "背景三"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"developing": "功能开发中,敬请期待"
|
"developing": "功能开发中,敬请期待"
|
||||||
|
|||||||
+30
-18
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 应用整体:背景渐变 + 悬浮面板 -->
|
<!-- 应用整体:背景(渐变 / 图片) + 悬浮面板 -->
|
||||||
<div class="app-shell flex h-screen w-screen flex-col overflow-hidden">
|
<div class="app-shell flex h-screen w-screen flex-col overflow-hidden" :style="shellStyle">
|
||||||
<!-- 顶部自定义 TitleBar -->
|
<!-- 顶部自定义 TitleBar -->
|
||||||
<Header @settings="settingsOpen = true" />
|
<Header @settings="goToSetting" />
|
||||||
|
|
||||||
<!-- 主体:侧边栏 + 内容区 -->
|
<!-- 主体:侧边栏 + 内容区 -->
|
||||||
<div class="flex min-h-0 flex-1 gap-3 px-3 pb-3">
|
<div class="flex min-h-0 flex-1 gap-3 px-3 pb-3">
|
||||||
@@ -12,38 +12,50 @@
|
|||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 设置(占位) -->
|
|
||||||
<a-modal
|
|
||||||
v-model:open="settingsOpen"
|
|
||||||
:title="t('settings.title')"
|
|
||||||
:footer="null"
|
|
||||||
centered
|
|
||||||
>
|
|
||||||
<a-result status="info" :sub-title="t('settings.placeholder')" />
|
|
||||||
</a-modal>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from "vue-i18n"
|
|
||||||
import Header from "./header/Header.vue"
|
import Header from "./header/Header.vue"
|
||||||
import Aside from "./aside/Aside.vue"
|
import Aside from "./aside/Aside.vue"
|
||||||
|
import { useAppStore } from "@/stores/app"
|
||||||
|
|
||||||
const { t } = useI18n()
|
const router = useRouter()
|
||||||
const settingsOpen = ref(false)
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
/** 点击标题栏“设置”按钮 → 跳转设置页 */
|
||||||
|
function goToSetting() {
|
||||||
|
router.push("/setting")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选中背景图片时覆盖默认渐变背景。
|
||||||
|
* `cover` 让图片按原始比例缩放铺满窗口,不会变形拉伸。
|
||||||
|
*/
|
||||||
|
const shellStyle = computed(() =>
|
||||||
|
appStore.backgroundUrl
|
||||||
|
? {
|
||||||
|
backgroundImage: `url('${appStore.backgroundUrl}')`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.app-shell {
|
.app-shell {
|
||||||
background:
|
background-color: #eef2ff;
|
||||||
|
background-image:
|
||||||
radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.28), transparent 60%),
|
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%),
|
radial-gradient(1000px 500px at 110% 10%, rgba(34, 211, 238, 0.28), transparent 55%),
|
||||||
linear-gradient(160deg, #eef2ff 0%, #f8fafc 45%, #eef7ff 100%);
|
linear-gradient(160deg, #eef2ff 0%, #f8fafc 45%, #eef7ff 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .app-shell {
|
.dark .app-shell {
|
||||||
background:
|
background-color: #0b1220;
|
||||||
|
background-image:
|
||||||
radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.35), transparent 60%),
|
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%),
|
radial-gradient(1000px 500px at 110% 10%, rgba(14, 165, 233, 0.28), transparent 55%),
|
||||||
linear-gradient(160deg, #0b1220 0%, #0f172a 45%, #111827 100%);
|
linear-gradient(160deg, #0b1220 0%, #0f172a 45%, #111827 100%);
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/Download.vue"),
|
component: () => import("@/views/Download.vue"),
|
||||||
meta: { title: "menu.download" },
|
meta: { title: "menu.download" },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "setting",
|
||||||
|
name: "Setting",
|
||||||
|
component: () => import("@/views/Setting.vue"),
|
||||||
|
redirect: "/setting/appearance",
|
||||||
|
meta: { title: "settings.title" },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "appearance",
|
||||||
|
name: "Appearance",
|
||||||
|
component: () => import("@/views/setting/Appearance.vue"),
|
||||||
|
meta: { title: "settings.menu.appearance" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
+17
-1
@@ -2,12 +2,17 @@ import { defineStore } from "pinia"
|
|||||||
import zhCN from "ant-design-vue/es/locale/zh_CN"
|
import zhCN from "ant-design-vue/es/locale/zh_CN"
|
||||||
import enUS from "ant-design-vue/es/locale/en_US"
|
import enUS from "ant-design-vue/es/locale/en_US"
|
||||||
import { setI18nLanguage, type Language } from "@/i18n"
|
import { setI18nLanguage, type Language } from "@/i18n"
|
||||||
|
import {
|
||||||
|
DEFAULT_BACKGROUND,
|
||||||
|
getBackgroundUrl,
|
||||||
|
type BackgroundKey,
|
||||||
|
} from "@/config/backgrounds"
|
||||||
|
|
||||||
/** Frpc 运行状态 */
|
/** Frpc 运行状态 */
|
||||||
export type RunStatus = "running" | "error" | "stopped"
|
export type RunStatus = "running" | "error" | "stopped"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局应用配置:语言、主题、Frpc 运行状态
|
* 全局应用配置:语言、主题、窗口背景、Frpc 运行状态
|
||||||
*/
|
*/
|
||||||
export const useAppStore = defineStore("app", () => {
|
export const useAppStore = defineStore("app", () => {
|
||||||
// 语言(持久化到 localStorage)
|
// 语言(持久化到 localStorage)
|
||||||
@@ -23,6 +28,10 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
})
|
})
|
||||||
const toggleDark = useToggle(isDark)
|
const toggleDark = useToggle(isDark)
|
||||||
|
|
||||||
|
// 窗口背景图片(持久化)
|
||||||
|
const background = useStorage<BackgroundKey>("frpilot:background", DEFAULT_BACKGROUND)
|
||||||
|
const backgroundUrl = computed(() => getBackgroundUrl(background.value))
|
||||||
|
|
||||||
// Frpc 运行状态(后续由后端驱动)
|
// Frpc 运行状态(后续由后端驱动)
|
||||||
const runStatus = ref<RunStatus>("stopped")
|
const runStatus = ref<RunStatus>("stopped")
|
||||||
|
|
||||||
@@ -44,13 +53,20 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
language.value = language.value === "zh-CN" ? "en-US" : "zh-CN"
|
language.value = language.value === "zh-CN" ? "en-US" : "zh-CN"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setBackground(value: BackgroundKey) {
|
||||||
|
background.value = value
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
language,
|
language,
|
||||||
isDark,
|
isDark,
|
||||||
toggleDark,
|
toggleDark,
|
||||||
|
background,
|
||||||
|
backgroundUrl,
|
||||||
runStatus,
|
runStatus,
|
||||||
antdLocale,
|
antdLocale,
|
||||||
setLanguage,
|
setLanguage,
|
||||||
toggleLanguage,
|
toggleLanguage,
|
||||||
|
setBackground,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
+3
-7
@@ -12,19 +12,15 @@ export {}
|
|||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
AButton: typeof import('ant-design-vue/es')['Button']
|
AButton: typeof import('ant-design-vue/es')['Button']
|
||||||
|
ACard: typeof import('ant-design-vue/es')['Card']
|
||||||
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
|
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
|
||||||
|
ADivider: typeof import('ant-design-vue/es')['Divider']
|
||||||
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
|
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
|
||||||
ALayout: typeof import('ant-design-vue/es')['Layout']
|
|
||||||
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
|
|
||||||
ALayoutFooter: typeof import('ant-design-vue/es')['LayoutFooter']
|
|
||||||
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
|
|
||||||
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
|
|
||||||
AMenu: typeof import('ant-design-vue/es')['Menu']
|
AMenu: typeof import('ant-design-vue/es')['Menu']
|
||||||
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
|
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
|
||||||
AModal: typeof import('ant-design-vue/es')['Modal']
|
|
||||||
AProgress: typeof import('ant-design-vue/es')['Progress']
|
AProgress: typeof import('ant-design-vue/es')['Progress']
|
||||||
AResult: typeof import('ant-design-vue/es')['Result']
|
AResult: typeof import('ant-design-vue/es')['Result']
|
||||||
ASpace: typeof import('ant-design-vue/es')['Space']
|
ASegmented: typeof import('ant-design-vue/es')['Segmented']
|
||||||
MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default']
|
MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full min-h-0">
|
||||||
|
<!-- 左侧:设置菜单 -->
|
||||||
|
<aside class="w-44 shrink-0 border-r border-slate-200/70 p-2 dark:border-white/10">
|
||||||
|
<a-menu
|
||||||
|
class="setting-menu"
|
||||||
|
mode="inline"
|
||||||
|
:selected-keys="selectedKeys"
|
||||||
|
:items="menuItems"
|
||||||
|
@click="onMenuClick"
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 右侧:设置内容 -->
|
||||||
|
<section class="min-w-0 flex-1 overflow-y-auto p-6">
|
||||||
|
<router-view />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { h } from "vue"
|
||||||
|
import { BgColorsOutlined } from "@ant-design/icons-vue"
|
||||||
|
import { useI18n } from "vue-i18n"
|
||||||
|
import type { MenuProps } from "ant-design-vue"
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const selectedKeys = computed(() => [route.path])
|
||||||
|
|
||||||
|
const menuItems = computed<MenuProps["items"]>(() => [
|
||||||
|
{
|
||||||
|
key: "/setting/appearance",
|
||||||
|
icon: () => h(BgColorsOutlined),
|
||||||
|
label: t("settings.menu.appearance"),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const onMenuClick: MenuProps["onClick"] = ({ key }) => {
|
||||||
|
router.push(String(key))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
/* 让设置菜单透明化,融入磨砂玻璃面板 */
|
||||||
|
.setting-menu {
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&.ant-menu-inline {
|
||||||
|
border-inline-end: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-item) {
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-inline: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-2xl">
|
||||||
|
<h2 class="mb-1 text-lg font-semibold">{{ t("settings.appearance.title") }}</h2>
|
||||||
|
<p class="mb-6 text-xs opacity-60">{{ t("settings.appearance.subtitle") }}</p>
|
||||||
|
|
||||||
|
<!-- 主题模式 -->
|
||||||
|
<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 />
|
||||||
|
<p class="mt-2 text-xs opacity-60">{{ t("settings.appearance.themeModeTip") }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<a-divider />
|
||||||
|
|
||||||
|
<!-- 背景图片 -->
|
||||||
|
<section>
|
||||||
|
<div class="mb-1 text-sm font-medium">{{ t("settings.appearance.background") }}</div>
|
||||||
|
<p class="mb-3 text-xs opacity-60">{{ t("settings.appearance.backgroundTip") }}</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
<a-card v-for="bg in backgroundOptions" :key="bg.key" hoverable size="small"
|
||||||
|
class="bg-card overflow-hidden" :class="{ 'bg-card--active': appStore.background === bg.key }"
|
||||||
|
:body-style="{ padding: '8px 10px' }" @click="selectBackground(bg.key)">
|
||||||
|
<template #cover>
|
||||||
|
<div v-if="bg.url" class="bg-thumb" :style="{ backgroundImage: `url('${bg.url}')` }" />
|
||||||
|
<div v-else class="bg-thumb bg-thumb--none" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-1 text-xs">
|
||||||
|
<span class="truncate">{{ t(bg.label) }}</span>
|
||||||
|
<CheckCircleFilled v-if="appStore.background === bg.key" class="shrink-0 text-[#6366f1]" />
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { h } from "vue"
|
||||||
|
import { BulbFilled, BulbOutlined, CheckCircleFilled } from "@ant-design/icons-vue"
|
||||||
|
import { useI18n } from "vue-i18n"
|
||||||
|
import { backgroundOptions, type BackgroundKey } from "@/config/backgrounds"
|
||||||
|
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",
|
||||||
|
label: h("span", { class: "inline-flex items-center gap-2" }, [
|
||||||
|
h(BulbOutlined),
|
||||||
|
t("settings.appearance.light"),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dark",
|
||||||
|
label: h("span", { class: "inline-flex items-center gap-2" }, [
|
||||||
|
h(BulbFilled),
|
||||||
|
t("settings.appearance.dark"),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
function selectBackground(key: BackgroundKey) {
|
||||||
|
appStore.setBackground(key)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.bg-card {
|
||||||
|
cursor: pointer;
|
||||||
|
border-color: rgba(15, 23, 42, 0.08);
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-card {
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-card--active {
|
||||||
|
border-color: #6366f1 !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 缩略图:保持比例,不拉伸 */
|
||||||
|
.bg-thumb {
|
||||||
|
height: 72px;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-thumb--none {
|
||||||
|
background-image: repeating-linear-gradient(45deg,
|
||||||
|
rgba(148, 163, 184, 0.25) 0 8px,
|
||||||
|
transparent 8px 16px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user