Compare commits
6
Commits
d4257481f4
...
487bde5473
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
487bde5473 | ||
|
|
3b153e32c9 | ||
|
|
30ddf583e5 | ||
|
|
5fea53aff1 | ||
|
|
5e48a0bbdd | ||
|
|
fd47adea1e |
@@ -25,3 +25,46 @@
|
|||||||
4. Layout布局的side、content区域全部采用圆角矩形、类似于悬浮磨砂玻璃效果
|
4. Layout布局的side、content区域全部采用圆角矩形、类似于悬浮磨砂玻璃效果
|
||||||
|
|
||||||
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||||
|
|
||||||
|
### 2. 设置页面外观设置
|
||||||
|
|
||||||
|
#### 2.1 窗口背景设置开发
|
||||||
|
|
||||||
|
- 我新加了一个设置页面Setting.vue和一个外观设置Appearance.vue文件,帮我在路由表里面增加设置页路由,以及外观设置子路由
|
||||||
|
- 设置页面左侧为菜单栏,先增加第一项菜单-外观设置,设置项为主题模式切换(明/暗)、背景图片切换
|
||||||
|
|
||||||
|
- 背景图片使用assets/images目录中的bg-1.png、bg-2.jpg、bg-3.png作为窗口背景图片,在设置页面里面可以选择其中一个切换页面背景,注意尽可能保持原图片的比例大小不发生拉伸
|
||||||
|
|
||||||
|
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||||
|
|
||||||
|
#### 2.2 UI修改
|
||||||
|
|
||||||
|
Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
|
||||||
|
|
||||||
|
- 不是在整个main区域设置成glass-panel样式
|
||||||
|
- 而是在每一个页面单独设置,现在先以服务器配置页面为例,按照我图片中红色框选部分作为区域分割
|
||||||
|
- 左侧菜单栏已经是glass-panel样式,现在需要在main区域设置成圆角卡片磨砂玻璃效果
|
||||||
|
|
||||||
|
#### 2.3 弹窗修改
|
||||||
|
|
||||||
|
现在的弹窗里面内容比较多的时候会出现弹窗高度超出窗口高度,然后整个弹窗可以滚动,我想要改成弹窗高度不能超过窗口高度的80%,然后弹窗内容较多的时候,滚动仅限于弹窗里面可以进行滚动,按照我的要求修改一下
|
||||||
|
|
||||||
|
### 3. 功能开发
|
||||||
|
|
||||||
|
#### 3.1 开发服务器配置功能
|
||||||
|
|
||||||
|
- Servers.vue页面右上方一个增加和刷新按钮,点击增加,弹窗填写服务器表单信息
|
||||||
|
- 服务器信息有名称、IP地址、端口号(默认7000)、备注、排序、是否置顶、是否启用等,这些是我能想到的字段,你需要按照Frpc配置的需要,决定是否增加新的字段,表单需要做必填检验
|
||||||
|
- 页面展示已经配置的服务器列表,列表服务器以卡片形式展示,卡片有开关可以直接操作是否置顶和是否启用,是否启用选项只能启用一个,另外还需要有编辑和删除,新增和编辑使用同一个表单
|
||||||
|
- 服务器信息在Rust端使用SQLite存储,Rust已经加入相关的sqlx依赖等,后端操作返回前端务必使用ApiResponse统一返回格式,前端也需要做好对应的类型匹配
|
||||||
|
|
||||||
|
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||||
|
|
||||||
|
#### 3.2 开发隧道列表功能
|
||||||
|
|
||||||
|
- Tunnels.vue页面右上方一个增加和刷新按钮,点击增加,弹窗填写隧道表单信息
|
||||||
|
- 隧道信息有名称、代理类型、本地IP、本地端口、远程端口、是否置顶、是否启用等,这些是我之前使用Frpc的时候常用的配置字段,你需要按照Frpc配置的需要,决定是否增加新的字段,表单需要做必填检验,如果你增加的是不常用的字段或者是更高级别的设置,可以用折叠面板作为高级设置,将这些更高级的字段放入
|
||||||
|
- 页面展示已经配置的隧道列表,列表隧道以卡片形式展示,卡片有开关可以直接操作是否置顶和是否启用,隧道可同时启用多个,另外还需要有编辑和删除,新增和编辑使用同一个表单
|
||||||
|
- 隧道信息在Rust端使用SQLite存储,Rust已经加入相关的sqlx依赖等,后端操作返回前端务必使用ApiResponse统一返回格式,前端也需要做好对应的类型匹配
|
||||||
|
|
||||||
|
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下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,293 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_helper::auto_collect_command;
|
||||||
|
|
||||||
|
use crate::models::{ApiResponse, Server, ServerPayload};
|
||||||
|
|
||||||
|
/// 允许的传输协议(对应 transport.protocol)
|
||||||
|
const PROTOCOLS: [&str; 4] = ["tcp", "kcp", "quic", "websocket"];
|
||||||
|
|
||||||
|
/// 统一查询字段,使用 concat! 保证结果为 'static str(sqlx 0.9 要求)
|
||||||
|
macro_rules! select_fields {
|
||||||
|
() => {
|
||||||
|
"id, name, addr, port, token, protocol, tls_enable, remark, sort, pinned, enabled, created_at, updated_at"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验提交数据,返回错误信息(None 表示通过)
|
||||||
|
fn validate(payload: &ServerPayload) -> Option<String> {
|
||||||
|
if payload.name.trim().is_empty() {
|
||||||
|
return Some("服务器名称不能为空".to_string());
|
||||||
|
}
|
||||||
|
if payload.addr.trim().is_empty() {
|
||||||
|
return Some("服务器地址不能为空".to_string());
|
||||||
|
}
|
||||||
|
if payload.port < 1 || payload.port > 65535 {
|
||||||
|
return Some("端口号必须在 1 - 65535 之间".to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 归一化协议,非法值回退为 tcp
|
||||||
|
fn normalize_protocol(raw: &str) -> String {
|
||||||
|
let value = raw.trim().to_ascii_lowercase();
|
||||||
|
if PROTOCOLS.contains(&value.as_str()) {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
"tcp".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空字符串转 None,避免写入无意义的空串
|
||||||
|
fn clean(value: &Option<String>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.as_ref()
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_all(pool: &SqlitePool) -> Result<Vec<Server>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Server>(concat!(
|
||||||
|
"SELECT ",
|
||||||
|
select_fields!(),
|
||||||
|
" FROM servers ORDER BY pinned DESC, sort ASC, id ASC"
|
||||||
|
))
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_by_id(pool: &SqlitePool, id: i64) -> Result<Server, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Server>(concat!(
|
||||||
|
"SELECT ",
|
||||||
|
select_fields!(),
|
||||||
|
" FROM servers WHERE id = ?"
|
||||||
|
))
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消其它服务器的启用状态(启用互斥)
|
||||||
|
async fn disable_others<'e, E>(executor: E, id: i64) -> Result<(), sqlx::Error>
|
||||||
|
where
|
||||||
|
E: sqlx::Executor<'e, Database = sqlx::Sqlite>,
|
||||||
|
{
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE servers SET enabled = 0, updated_at = datetime('now', 'localtime') WHERE enabled = 1 AND id <> ?",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.execute(executor)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取服务器列表
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn get_server_list(
|
||||||
|
state: State<'_, SqlitePool>,
|
||||||
|
) -> Result<ApiResponse<Vec<Server>>, String> {
|
||||||
|
Ok(match fetch_all(&state).await {
|
||||||
|
Ok(list) => ApiResponse::success(list),
|
||||||
|
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新增服务器
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn create_server(
|
||||||
|
state: State<'_, SqlitePool>,
|
||||||
|
payload: ServerPayload,
|
||||||
|
) -> Result<ApiResponse<Server>, String> {
|
||||||
|
if let Some(msg) = validate(&payload) {
|
||||||
|
return Ok(ApiResponse::error(400, &msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(match insert_server(&state, &payload).await {
|
||||||
|
Ok(server) => ApiResponse::success(server),
|
||||||
|
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_server(pool: &SqlitePool, payload: &ServerPayload) -> Result<Server, sqlx::Error> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
"INSERT INTO servers (name, addr, port, token, protocol, tls_enable, remark, sort, pinned, enabled) \
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(payload.name.trim())
|
||||||
|
.bind(payload.addr.trim())
|
||||||
|
.bind(payload.port)
|
||||||
|
.bind(clean(&payload.token))
|
||||||
|
.bind(normalize_protocol(&payload.protocol))
|
||||||
|
.bind(payload.tls_enable)
|
||||||
|
.bind(clean(&payload.remark))
|
||||||
|
.bind(payload.sort)
|
||||||
|
.bind(payload.pinned)
|
||||||
|
.bind(payload.enabled)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let id = result.last_insert_rowid();
|
||||||
|
|
||||||
|
// 若新增时启用了该服务器,则关闭其它服务器的启用状态
|
||||||
|
if payload.enabled {
|
||||||
|
disable_others(&mut *tx, id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
fetch_by_id(pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编辑服务器
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn update_server(
|
||||||
|
state: State<'_, SqlitePool>,
|
||||||
|
payload: ServerPayload,
|
||||||
|
) -> Result<ApiResponse<Server>, String> {
|
||||||
|
let Some(id) = payload.id else {
|
||||||
|
return Ok(ApiResponse::error(400, "缺少服务器 ID"));
|
||||||
|
};
|
||||||
|
if let Some(msg) = validate(&payload) {
|
||||||
|
return Ok(ApiResponse::error(400, &msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(match update_server_row(&state, id, &payload).await {
|
||||||
|
Ok(Some(server)) => ApiResponse::success(server),
|
||||||
|
Ok(None) => ApiResponse::error(404, "服务器不存在"),
|
||||||
|
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_server_row(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: i64,
|
||||||
|
payload: &ServerPayload,
|
||||||
|
) -> Result<Option<Server>, sqlx::Error> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
"UPDATE servers SET name = ?, addr = ?, port = ?, token = ?, protocol = ?, tls_enable = ?, \
|
||||||
|
remark = ?, sort = ?, pinned = ?, enabled = ?, updated_at = datetime('now', 'localtime') \
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(payload.name.trim())
|
||||||
|
.bind(payload.addr.trim())
|
||||||
|
.bind(payload.port)
|
||||||
|
.bind(clean(&payload.token))
|
||||||
|
.bind(normalize_protocol(&payload.protocol))
|
||||||
|
.bind(payload.tls_enable)
|
||||||
|
.bind(clean(&payload.remark))
|
||||||
|
.bind(payload.sort)
|
||||||
|
.bind(payload.pinned)
|
||||||
|
.bind(payload.enabled)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
tx.rollback().await?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若编辑时启用了该服务器,则关闭其它服务器的启用状态
|
||||||
|
if payload.enabled {
|
||||||
|
disable_others(&mut *tx, id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Some(fetch_by_id(pool, id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除服务器
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn delete_server(
|
||||||
|
state: State<'_, SqlitePool>,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<ApiResponse<()>, String> {
|
||||||
|
Ok(
|
||||||
|
match sqlx::query("DELETE FROM servers WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&*state)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) if result.rows_affected() == 0 => ApiResponse::error(404, "服务器不存在"),
|
||||||
|
Ok(_) => ApiResponse::success_empty(),
|
||||||
|
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启用 / 禁用服务器(启用时自动关闭其它服务器)
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn set_server_enabled(
|
||||||
|
state: State<'_, SqlitePool>,
|
||||||
|
id: i64,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<ApiResponse<Server>, String> {
|
||||||
|
Ok(match toggle_enabled(&state, id, enabled).await {
|
||||||
|
Ok(Some(server)) => ApiResponse::success(server),
|
||||||
|
Ok(None) => ApiResponse::error(404, "服务器不存在"),
|
||||||
|
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn toggle_enabled(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: i64,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<Option<Server>, sqlx::Error> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
"UPDATE servers SET enabled = ?, updated_at = datetime('now', 'localtime') WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(enabled)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
tx.rollback().await?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
disable_others(&mut *tx, id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(Some(fetch_by_id(pool, id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 置顶 / 取消置顶服务器
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn set_server_pinned(
|
||||||
|
state: State<'_, SqlitePool>,
|
||||||
|
id: i64,
|
||||||
|
pinned: bool,
|
||||||
|
) -> Result<ApiResponse<Server>, String> {
|
||||||
|
Ok(
|
||||||
|
match sqlx::query(
|
||||||
|
"UPDATE servers SET pinned = ?, updated_at = datetime('now', 'localtime') WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(pinned)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&*state)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) if result.rows_affected() == 0 => ApiResponse::error(404, "服务器不存在"),
|
||||||
|
Ok(_) => match fetch_by_id(&state, id).await {
|
||||||
|
Ok(server) => ApiResponse::success(server),
|
||||||
|
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||||
|
},
|
||||||
|
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_helper::auto_collect_command;
|
||||||
|
|
||||||
|
use crate::models::{ApiResponse, Tunnel, TunnelPayload};
|
||||||
|
|
||||||
|
/// 允许的代理类型(对应 frpc 的 type)
|
||||||
|
const PROXY_TYPES: [&str; 8] = [
|
||||||
|
"tcp", "udp", "http", "https", "stcp", "xtcp", "sudp", "tcpmux",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 允许的 Proxy Protocol 版本
|
||||||
|
const PROXY_PROTOCOL_VERSIONS: [&str; 2] = ["v1", "v2"];
|
||||||
|
|
||||||
|
/// 允许的健康检查类型
|
||||||
|
const HEALTH_CHECK_TYPES: [&str; 2] = ["tcp", "http"];
|
||||||
|
|
||||||
|
/// 统一查询字段,使用 concat! 保证结果为 'static str(sqlx 0.9 要求)
|
||||||
|
macro_rules! select_fields {
|
||||||
|
() => {
|
||||||
|
"id, name, proxy_type, local_ip, local_port, remote_port, custom_domains, subdomain, locations, secret_key, use_encryption, use_compression, bandwidth_limit, proxy_protocol_version, health_check_type, health_check_timeout, health_check_max_failed, health_check_interval, health_check_path, load_balancer_group, load_balancer_group_key, remark, sort, pinned, enabled, created_at, updated_at"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验提交数据,返回错误信息(None 表示通过)
|
||||||
|
fn validate(payload: &TunnelPayload) -> Option<String> {
|
||||||
|
if payload.name.trim().is_empty() {
|
||||||
|
return Some("隧道名称不能为空".to_string());
|
||||||
|
}
|
||||||
|
if payload.local_ip.trim().is_empty() {
|
||||||
|
return Some("本地 IP 不能为空".to_string());
|
||||||
|
}
|
||||||
|
if payload.local_port < 1 || payload.local_port > 65535 {
|
||||||
|
return Some("本地端口必须在 1 - 65535 之间".to_string());
|
||||||
|
}
|
||||||
|
if let Some(remote_port) = payload.remote_port {
|
||||||
|
if !(1..=65535).contains(&remote_port) {
|
||||||
|
return Some("远程端口必须在 1 - 65535 之间".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 归一化代理类型,非法值回退为 tcp
|
||||||
|
fn normalize_proxy_type(raw: &str) -> String {
|
||||||
|
let value = raw.trim().to_ascii_lowercase();
|
||||||
|
if PROXY_TYPES.contains(&value.as_str()) {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
"tcp".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空字符串转 None,避免写入无意义的空串
|
||||||
|
fn clean(value: &Option<String>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.as_ref()
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 归一化枚举值:合法则返回清理后的值,否则返回 None
|
||||||
|
fn normalize_choice(value: &Option<String>, allowed: &[&str]) -> Option<String> {
|
||||||
|
clean(value)
|
||||||
|
.map(|v| v.to_ascii_lowercase())
|
||||||
|
.filter(|v| allowed.contains(&v.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅保留正整数,非法值(<= 0)返回 None
|
||||||
|
fn positive(value: Option<i32>) -> Option<i32> {
|
||||||
|
value.filter(|v| *v > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_all(pool: &SqlitePool) -> Result<Vec<Tunnel>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Tunnel>(concat!(
|
||||||
|
"SELECT ",
|
||||||
|
select_fields!(),
|
||||||
|
" FROM tunnels ORDER BY pinned DESC, sort ASC, id ASC"
|
||||||
|
))
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_by_id(pool: &SqlitePool, id: i64) -> Result<Tunnel, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Tunnel>(concat!(
|
||||||
|
"SELECT ",
|
||||||
|
select_fields!(),
|
||||||
|
" FROM tunnels WHERE id = ?"
|
||||||
|
))
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取隧道列表
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn get_tunnel_list(
|
||||||
|
state: State<'_, SqlitePool>,
|
||||||
|
) -> Result<ApiResponse<Vec<Tunnel>>, String> {
|
||||||
|
Ok(match fetch_all(&state).await {
|
||||||
|
Ok(list) => ApiResponse::success(list),
|
||||||
|
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新增隧道
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn create_tunnel(
|
||||||
|
state: State<'_, SqlitePool>,
|
||||||
|
payload: TunnelPayload,
|
||||||
|
) -> Result<ApiResponse<Tunnel>, String> {
|
||||||
|
if let Some(msg) = validate(&payload) {
|
||||||
|
return Ok(ApiResponse::error(400, &msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(match insert_tunnel(&state, &payload).await {
|
||||||
|
Ok(tunnel) => ApiResponse::success(tunnel),
|
||||||
|
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_tunnel(pool: &SqlitePool, payload: &TunnelPayload) -> Result<Tunnel, sqlx::Error> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"INSERT INTO tunnels (name, proxy_type, local_ip, local_port, remote_port, custom_domains, \
|
||||||
|
subdomain, locations, secret_key, use_encryption, use_compression, bandwidth_limit, \
|
||||||
|
proxy_protocol_version, health_check_type, health_check_timeout, health_check_max_failed, \
|
||||||
|
health_check_interval, health_check_path, load_balancer_group, load_balancer_group_key, \
|
||||||
|
remark, sort, pinned, enabled) \
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(payload.name.trim())
|
||||||
|
.bind(normalize_proxy_type(&payload.proxy_type))
|
||||||
|
.bind(payload.local_ip.trim())
|
||||||
|
.bind(payload.local_port)
|
||||||
|
.bind(payload.remote_port)
|
||||||
|
.bind(clean(&payload.custom_domains))
|
||||||
|
.bind(clean(&payload.subdomain))
|
||||||
|
.bind(clean(&payload.locations))
|
||||||
|
.bind(clean(&payload.secret_key))
|
||||||
|
.bind(payload.use_encryption)
|
||||||
|
.bind(payload.use_compression)
|
||||||
|
.bind(clean(&payload.bandwidth_limit))
|
||||||
|
.bind(normalize_choice(
|
||||||
|
&payload.proxy_protocol_version,
|
||||||
|
&PROXY_PROTOCOL_VERSIONS,
|
||||||
|
))
|
||||||
|
.bind(normalize_choice(
|
||||||
|
&payload.health_check_type,
|
||||||
|
&HEALTH_CHECK_TYPES,
|
||||||
|
))
|
||||||
|
.bind(positive(payload.health_check_timeout))
|
||||||
|
.bind(positive(payload.health_check_max_failed))
|
||||||
|
.bind(positive(payload.health_check_interval))
|
||||||
|
.bind(clean(&payload.health_check_path))
|
||||||
|
.bind(clean(&payload.load_balancer_group))
|
||||||
|
.bind(clean(&payload.load_balancer_group_key))
|
||||||
|
.bind(clean(&payload.remark))
|
||||||
|
.bind(payload.sort)
|
||||||
|
.bind(payload.pinned)
|
||||||
|
.bind(payload.enabled)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let id = result.last_insert_rowid();
|
||||||
|
fetch_by_id(pool, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编辑隧道
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn update_tunnel(
|
||||||
|
state: State<'_, SqlitePool>,
|
||||||
|
payload: TunnelPayload,
|
||||||
|
) -> Result<ApiResponse<Tunnel>, String> {
|
||||||
|
let Some(id) = payload.id else {
|
||||||
|
return Ok(ApiResponse::error(400, "缺少隧道 ID"));
|
||||||
|
};
|
||||||
|
if let Some(msg) = validate(&payload) {
|
||||||
|
return Ok(ApiResponse::error(400, &msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(match update_tunnel_row(&state, id, &payload).await {
|
||||||
|
Ok(Some(tunnel)) => ApiResponse::success(tunnel),
|
||||||
|
Ok(None) => ApiResponse::error(404, "隧道不存在"),
|
||||||
|
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_tunnel_row(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: i64,
|
||||||
|
payload: &TunnelPayload,
|
||||||
|
) -> Result<Option<Tunnel>, sqlx::Error> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"UPDATE tunnels SET name = ?, proxy_type = ?, local_ip = ?, local_port = ?, remote_port = ?, \
|
||||||
|
custom_domains = ?, subdomain = ?, locations = ?, secret_key = ?, use_encryption = ?, \
|
||||||
|
use_compression = ?, bandwidth_limit = ?, proxy_protocol_version = ?, health_check_type = ?, \
|
||||||
|
health_check_timeout = ?, health_check_max_failed = ?, health_check_interval = ?, \
|
||||||
|
health_check_path = ?, load_balancer_group = ?, load_balancer_group_key = ?, remark = ?, \
|
||||||
|
sort = ?, pinned = ?, enabled = ?, updated_at = datetime('now', 'localtime') \
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(payload.name.trim())
|
||||||
|
.bind(normalize_proxy_type(&payload.proxy_type))
|
||||||
|
.bind(payload.local_ip.trim())
|
||||||
|
.bind(payload.local_port)
|
||||||
|
.bind(payload.remote_port)
|
||||||
|
.bind(clean(&payload.custom_domains))
|
||||||
|
.bind(clean(&payload.subdomain))
|
||||||
|
.bind(clean(&payload.locations))
|
||||||
|
.bind(clean(&payload.secret_key))
|
||||||
|
.bind(payload.use_encryption)
|
||||||
|
.bind(payload.use_compression)
|
||||||
|
.bind(clean(&payload.bandwidth_limit))
|
||||||
|
.bind(normalize_choice(
|
||||||
|
&payload.proxy_protocol_version,
|
||||||
|
&PROXY_PROTOCOL_VERSIONS,
|
||||||
|
))
|
||||||
|
.bind(normalize_choice(
|
||||||
|
&payload.health_check_type,
|
||||||
|
&HEALTH_CHECK_TYPES,
|
||||||
|
))
|
||||||
|
.bind(positive(payload.health_check_timeout))
|
||||||
|
.bind(positive(payload.health_check_max_failed))
|
||||||
|
.bind(positive(payload.health_check_interval))
|
||||||
|
.bind(clean(&payload.health_check_path))
|
||||||
|
.bind(clean(&payload.load_balancer_group))
|
||||||
|
.bind(clean(&payload.load_balancer_group_key))
|
||||||
|
.bind(clean(&payload.remark))
|
||||||
|
.bind(payload.sort)
|
||||||
|
.bind(payload.pinned)
|
||||||
|
.bind(payload.enabled)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(fetch_by_id(pool, id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除隧道
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn delete_tunnel(
|
||||||
|
state: State<'_, SqlitePool>,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<ApiResponse<()>, String> {
|
||||||
|
Ok(
|
||||||
|
match sqlx::query("DELETE FROM tunnels WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&*state)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) if result.rows_affected() == 0 => ApiResponse::error(404, "隧道不存在"),
|
||||||
|
Ok(_) => ApiResponse::success_empty(),
|
||||||
|
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启用 / 禁用隧道
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn set_tunnel_enabled(
|
||||||
|
state: State<'_, SqlitePool>,
|
||||||
|
id: i64,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<ApiResponse<Tunnel>, String> {
|
||||||
|
Ok(match toggle_enabled(&state, id, enabled).await {
|
||||||
|
Ok(Some(tunnel)) => ApiResponse::success(tunnel),
|
||||||
|
Ok(None) => ApiResponse::error(404, "隧道不存在"),
|
||||||
|
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn toggle_enabled(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: i64,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<Option<Tunnel>, sqlx::Error> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"UPDATE tunnels SET enabled = ?, updated_at = datetime('now', 'localtime') WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(enabled)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(fetch_by_id(pool, id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 置顶 / 取消置顶隧道
|
||||||
|
#[tauri::command]
|
||||||
|
#[auto_collect_command]
|
||||||
|
pub async fn set_tunnel_pinned(
|
||||||
|
state: State<'_, SqlitePool>,
|
||||||
|
id: i64,
|
||||||
|
pinned: bool,
|
||||||
|
) -> Result<ApiResponse<Tunnel>, String> {
|
||||||
|
Ok(
|
||||||
|
match sqlx::query(
|
||||||
|
"UPDATE tunnels SET pinned = ?, updated_at = datetime('now', 'localtime') WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(pinned)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&*state)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) if result.rows_affected() == 0 => ApiResponse::error(404, "隧道不存在"),
|
||||||
|
Ok(_) => match fetch_by_id(&state, id).await {
|
||||||
|
Ok(tunnel) => ApiResponse::success(tunnel),
|
||||||
|
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||||
|
},
|
||||||
|
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+32
-6
@@ -1,14 +1,40 @@
|
|||||||
// 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::{
|
||||||
|
create_server, delete_server, get_server_list, set_server_enabled, set_server_pinned,
|
||||||
|
update_server,
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
|
||||||
#[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,189 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 服务器配置(对应 SQLite 中的 servers 表)
|
||||||
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Server {
|
||||||
|
pub id: i64,
|
||||||
|
/// 名称(仅用于界面展示)
|
||||||
|
pub name: String,
|
||||||
|
/// 服务器地址,对应 frpc 的 serverAddr,可为 IP 或域名
|
||||||
|
pub addr: String,
|
||||||
|
/// 服务器端口,对应 frpc 的 serverPort,默认 7000
|
||||||
|
pub port: i32,
|
||||||
|
/// 鉴权 Token,对应 auth.token
|
||||||
|
pub token: Option<String>,
|
||||||
|
/// 传输协议,对应 transport.protocol:tcp / kcp / quic / websocket
|
||||||
|
pub protocol: String,
|
||||||
|
/// 是否启用 TLS,对应 transport.tls.enable
|
||||||
|
pub tls_enable: bool,
|
||||||
|
/// 备注
|
||||||
|
pub remark: Option<String>,
|
||||||
|
/// 排序(越小越靠前)
|
||||||
|
pub sort: i32,
|
||||||
|
/// 是否置顶
|
||||||
|
pub pinned: bool,
|
||||||
|
/// 是否启用(全局同时只能启用一个)
|
||||||
|
pub enabled: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新增 / 编辑服务器的提交数据
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ServerPayload {
|
||||||
|
/// 编辑时携带,新增时为空
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub name: String,
|
||||||
|
pub addr: String,
|
||||||
|
pub port: i32,
|
||||||
|
pub token: Option<String>,
|
||||||
|
pub protocol: String,
|
||||||
|
pub tls_enable: bool,
|
||||||
|
pub remark: Option<String>,
|
||||||
|
pub sort: i32,
|
||||||
|
pub pinned: bool,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隧道代理配置(对应 SQLite 中的 tunnels 表)
|
||||||
|
///
|
||||||
|
/// 字段命名参考 frpc 的 proxy 配置,其中 `proxy_type` 序列化为 `type`,
|
||||||
|
/// 与前端类型保持一致(`type` 是 Rust 关键字,故字段名做映射)。
|
||||||
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Tunnel {
|
||||||
|
pub id: i64,
|
||||||
|
/// 隧道名称,对应 frpc 的 proxy name
|
||||||
|
pub name: String,
|
||||||
|
/// 代理类型,对应 frpc 的 type:tcp / udp / http / https / stcp / xtcp / sudp / tcpmux
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub proxy_type: String,
|
||||||
|
/// 本地 IP,对应 localIP
|
||||||
|
pub local_ip: String,
|
||||||
|
/// 本地端口,对应 localPort
|
||||||
|
pub local_port: i32,
|
||||||
|
/// 远程端口,对应 remotePort(tcp / udp 必填)
|
||||||
|
pub remote_port: Option<i32>,
|
||||||
|
/// 自定义域名,对应 customDomains(多个以英文逗号分隔)
|
||||||
|
pub custom_domains: Option<String>,
|
||||||
|
/// 子域名,对应 subdomain
|
||||||
|
pub subdomain: Option<String>,
|
||||||
|
/// URL 前缀,对应 locations(多个以英文逗号分隔)
|
||||||
|
pub locations: Option<String>,
|
||||||
|
/// 密钥,对应 secretKey(stcp / xtcp / sudp)
|
||||||
|
pub secret_key: Option<String>,
|
||||||
|
/// 传输加密,对应 transport.useEncryption
|
||||||
|
pub use_encryption: bool,
|
||||||
|
/// 传输压缩,对应 transport.useCompression
|
||||||
|
pub use_compression: bool,
|
||||||
|
/// 带宽限制,对应 transport.bandwidthLimit
|
||||||
|
pub bandwidth_limit: Option<String>,
|
||||||
|
/// Proxy Protocol 版本,对应 transport.proxyProtocolVersion
|
||||||
|
pub proxy_protocol_version: Option<String>,
|
||||||
|
/// 健康检查类型,对应 healthCheck.type:tcp / http
|
||||||
|
pub health_check_type: Option<String>,
|
||||||
|
/// 健康检查超时(秒),对应 healthCheck.timeoutSeconds
|
||||||
|
pub health_check_timeout: Option<i32>,
|
||||||
|
/// 健康检查最大失败次数,对应 healthCheck.maxFailed
|
||||||
|
pub health_check_max_failed: Option<i32>,
|
||||||
|
/// 健康检查间隔(秒),对应 healthCheck.intervalSeconds
|
||||||
|
pub health_check_interval: Option<i32>,
|
||||||
|
/// 健康检查路径,对应 healthCheck.path
|
||||||
|
pub health_check_path: Option<String>,
|
||||||
|
/// 负载均衡组,对应 loadBalancer.group
|
||||||
|
pub load_balancer_group: Option<String>,
|
||||||
|
/// 负载均衡组密钥,对应 loadBalancer.groupKey
|
||||||
|
pub load_balancer_group_key: Option<String>,
|
||||||
|
/// 备注
|
||||||
|
pub remark: Option<String>,
|
||||||
|
/// 排序(越小越靠前)
|
||||||
|
pub sort: i32,
|
||||||
|
/// 是否置顶
|
||||||
|
pub pinned: bool,
|
||||||
|
/// 是否启用(可同时启用多个)
|
||||||
|
pub enabled: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新增 / 编辑隧道的提交数据
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct TunnelPayload {
|
||||||
|
/// 编辑时携带,新增时为空
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub proxy_type: String,
|
||||||
|
pub local_ip: String,
|
||||||
|
pub local_port: i32,
|
||||||
|
pub remote_port: Option<i32>,
|
||||||
|
pub custom_domains: Option<String>,
|
||||||
|
pub subdomain: Option<String>,
|
||||||
|
pub locations: Option<String>,
|
||||||
|
pub secret_key: Option<String>,
|
||||||
|
pub use_encryption: bool,
|
||||||
|
pub use_compression: bool,
|
||||||
|
pub bandwidth_limit: Option<String>,
|
||||||
|
pub proxy_protocol_version: Option<String>,
|
||||||
|
pub health_check_type: Option<String>,
|
||||||
|
pub health_check_timeout: Option<i32>,
|
||||||
|
pub health_check_max_failed: Option<i32>,
|
||||||
|
pub health_check_interval: Option<i32>,
|
||||||
|
pub health_check_path: Option<String>,
|
||||||
|
pub load_balancer_group: Option<String>,
|
||||||
|
pub load_balancer_group_key: Option<String>,
|
||||||
|
pub remark: Option<String>,
|
||||||
|
pub sort: i32,
|
||||||
|
pub pinned: bool,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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("frpilot.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?;
|
||||||
|
|
||||||
|
// 初始化表结构
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
addr TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL DEFAULT 7000,
|
||||||
|
token TEXT,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'tcp',
|
||||||
|
tls_enable INTEGER NOT NULL DEFAULT 0,
|
||||||
|
remark TEXT,
|
||||||
|
sort INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 隧道表(字段参考 frpc 的 proxy 配置)
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS tunnels (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
proxy_type TEXT NOT NULL DEFAULT 'tcp',
|
||||||
|
local_ip TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||||
|
local_port INTEGER NOT NULL,
|
||||||
|
remote_port INTEGER,
|
||||||
|
custom_domains TEXT,
|
||||||
|
subdomain TEXT,
|
||||||
|
locations TEXT,
|
||||||
|
secret_key TEXT,
|
||||||
|
use_encryption INTEGER NOT NULL DEFAULT 0,
|
||||||
|
use_compression INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bandwidth_limit TEXT,
|
||||||
|
proxy_protocol_version TEXT,
|
||||||
|
health_check_type TEXT,
|
||||||
|
health_check_timeout INTEGER,
|
||||||
|
health_check_max_failed INTEGER,
|
||||||
|
health_check_interval INTEGER,
|
||||||
|
health_check_path TEXT,
|
||||||
|
load_balancer_group TEXT,
|
||||||
|
load_balancer_group_key TEXT,
|
||||||
|
remark TEXT,
|
||||||
|
sort INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.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": ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
|
import type { ApiResponse, Server, ServerPayload } from "@/types/server"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用后端命令并统一解包 ApiResponse。
|
||||||
|
*
|
||||||
|
* 后端所有服务器相关命令均返回 `ApiResponse<T>`,业务失败时
|
||||||
|
* `success === false`,此处统一抛出异常,交由调用方提示。
|
||||||
|
*/
|
||||||
|
async function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
const res = await invoke<ApiResponse<T>>(command, args)
|
||||||
|
if (!res?.success) {
|
||||||
|
throw new Error(res?.msg || "操作失败")
|
||||||
|
}
|
||||||
|
return res.data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取服务器列表 */
|
||||||
|
export function getServerList() {
|
||||||
|
return call<Server[]>("get_server_list")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增服务器 */
|
||||||
|
export function createServer(payload: ServerPayload) {
|
||||||
|
return call<Server>("create_server", { payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑服务器 */
|
||||||
|
export function updateServer(payload: ServerPayload) {
|
||||||
|
return call<Server>("update_server", { payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除服务器 */
|
||||||
|
export function deleteServer(id: number) {
|
||||||
|
return call<null>("delete_server", { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启用 / 禁用服务器(启用时后端会自动关闭其它服务器) */
|
||||||
|
export function setServerEnabled(id: number, enabled: boolean) {
|
||||||
|
return call<Server>("set_server_enabled", { id, enabled })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 置顶 / 取消置顶服务器 */
|
||||||
|
export function setServerPinned(id: number, pinned: boolean) {
|
||||||
|
return call<Server>("set_server_pinned", { id, pinned })
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
|
import type { ApiResponse } from "@/types/api"
|
||||||
|
import type { Tunnel, TunnelPayload } from "@/types/tunnel"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用后端命令并统一解包 ApiResponse。
|
||||||
|
*
|
||||||
|
* 后端所有隧道相关命令均返回 `ApiResponse<T>`,业务失败时
|
||||||
|
* `success === false`,此处统一抛出异常,交由调用方提示。
|
||||||
|
*/
|
||||||
|
async function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
const res = await invoke<ApiResponse<T>>(command, args)
|
||||||
|
if (!res?.success) {
|
||||||
|
throw new Error(res?.msg || "操作失败")
|
||||||
|
}
|
||||||
|
return res.data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取隧道列表 */
|
||||||
|
export function getTunnelList() {
|
||||||
|
return call<Tunnel[]>("get_tunnel_list")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增隧道 */
|
||||||
|
export function createTunnel(payload: TunnelPayload) {
|
||||||
|
return call<Tunnel>("create_tunnel", { payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑隧道 */
|
||||||
|
export function updateTunnel(payload: TunnelPayload) {
|
||||||
|
return call<Tunnel>("update_tunnel", { payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除隧道 */
|
||||||
|
export function deleteTunnel(id: number) {
|
||||||
|
return call<null>("delete_tunnel", { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启用 / 禁用隧道 */
|
||||||
|
export function setTunnelEnabled(id: number, enabled: boolean) {
|
||||||
|
return call<Tunnel>("set_tunnel_enabled", { id, enabled })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 置顶 / 取消置顶隧道 */
|
||||||
|
export function setTunnelPinned(id: number, pinned: boolean) {
|
||||||
|
return call<Tunnel>("set_tunnel_pinned", { id, pinned })
|
||||||
|
}
|
||||||
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 |
+81
-1
@@ -11,17 +11,68 @@ body,
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 背景统一绘制在 <html> 上:
|
||||||
|
* 根元素的背景会被绘制到整个画布(canvas)上,铺满窗口的每一个像素,
|
||||||
|
* 不会出现“元素高度(100vh) 与实际窗口差几个像素”导致的上下留白。
|
||||||
|
*/
|
||||||
|
html {
|
||||||
|
background-color: #eef2ff;
|
||||||
|
background-image:
|
||||||
|
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%);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
background-color: #0b1220;
|
||||||
|
background-image:
|
||||||
|
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%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中自定义背景图时替换为图片:等比缩放铺满,不拉伸变形 */
|
||||||
|
html.has-bg-image {
|
||||||
|
background-image: var(--app-bg-image, none);
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-family: 'Inter', 'HarmonyOS Sans SC', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
|
font-family: 'Inter', 'HarmonyOS Sans SC', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
/* 背景交由 <html> 绘制,body 保持透明避免覆盖画布背景 */
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Antd 按钮:图标与文字按 flex 垂直居中(Tailwind preflight 会把 svg 设为 block,导致行内对齐偏移) */
|
||||||
|
.ant-btn {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主内容区卡片:去掉投影,避免上下卡片之间的阴影透过半透明背景叠加成灰色遮罩 */
|
||||||
|
.glass-panel.glass-panel-flat,
|
||||||
|
.dark .glass-panel.glass-panel-flat {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 窗口圆角固定 8px,与 Windows 11 系统默认圆角保持一致 */
|
||||||
|
#app {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 悬浮磨砂玻璃面板 */
|
/* 悬浮磨砂玻璃面板 */
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||||
background: rgba(255, 255, 255, 0.55);
|
background: rgba(255, 255, 255, 0.35);
|
||||||
backdrop-filter: blur(18px) saturate(160%);
|
backdrop-filter: blur(18px) saturate(160%);
|
||||||
-webkit-backdrop-filter: blur(18px) saturate(160%);
|
-webkit-backdrop-filter: blur(18px) saturate(160%);
|
||||||
box-shadow: 0 8px 32px rgba(15, 23, 42, 0.12);
|
box-shadow: 0 8px 32px rgba(15, 23, 42, 0.12);
|
||||||
@@ -33,6 +84,35 @@ body {
|
|||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 表单弹窗通用布局:
|
||||||
|
* 1. 弹窗整体高度不超过窗口的 80%;
|
||||||
|
* 2. 内容过多时仅弹窗主体(body)滚动,标题与底部按钮固定可见;
|
||||||
|
* 3. 只保留纵向滚动条,避免出现横向滚动条。
|
||||||
|
*/
|
||||||
|
.form-modal-wrap .ant-modal {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-modal-wrap .ant-modal-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-modal-wrap .ant-modal-header,
|
||||||
|
.form-modal-wrap .ant-modal-footer {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-modal-wrap .ant-modal-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* 细滚动条 */
|
/* 细滚动条 */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<a-card class="server-card" :body-style="{ padding: '16px' }" hoverable>
|
||||||
|
<!-- 头部:名称 + 状态标签 + 操作 -->
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="truncate text-base font-semibold">{{ server.name }}</span>
|
||||||
|
<a-tag v-if="server.pinned" color="gold" class="!me-0">{{ t("server.pinned") }}</a-tag>
|
||||||
|
<a-tag v-if="server.enabled" color="green" class="!me-0">{{ t("server.enabled") }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 truncate font-mono text-sm opacity-70">
|
||||||
|
{{ server.addr }}:{{ server.port }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<a-tooltip :title="t('server.edit')">
|
||||||
|
<a-button type="text" size="small" :icon="h(EditOutlined)" @click="emit('edit')" />
|
||||||
|
</a-tooltip>
|
||||||
|
<a-popconfirm
|
||||||
|
:title="t('server.deleteConfirm', { name: server.name })"
|
||||||
|
:ok-text="t('common.confirm')"
|
||||||
|
:cancel-text="t('common.cancel')"
|
||||||
|
@confirm="emit('delete')"
|
||||||
|
>
|
||||||
|
<a-button type="text" size="small" danger :icon="h(DeleteOutlined)" />
|
||||||
|
</a-popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 元信息 -->
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<a-tag color="blue" class="!me-0 uppercase">{{ server.protocol }}</a-tag>
|
||||||
|
<a-tag v-if="server.tls_enable" color="cyan" class="!me-0">TLS</a-tag>
|
||||||
|
<a-tag v-if="server.token" color="purple" class="!me-0">Token</a-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="server.remark" class="mt-2 line-clamp-2 text-xs opacity-60">{{ server.remark }}</p>
|
||||||
|
|
||||||
|
<a-divider class="!my-3" />
|
||||||
|
|
||||||
|
<!-- 底部开关 -->
|
||||||
|
<div class="flex items-center justify-end gap-6">
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 text-xs">
|
||||||
|
<a-switch
|
||||||
|
size="small"
|
||||||
|
:checked="server.pinned"
|
||||||
|
@change="onPinnedChange"
|
||||||
|
/>
|
||||||
|
<span>{{ t("server.pinned") }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 text-xs">
|
||||||
|
<a-switch
|
||||||
|
size="small"
|
||||||
|
:checked="server.enabled"
|
||||||
|
@change="onEnabledChange"
|
||||||
|
/>
|
||||||
|
<span>{{ t("server.enabled") }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { h } from "vue"
|
||||||
|
import { DeleteOutlined, EditOutlined } from "@ant-design/icons-vue"
|
||||||
|
import { useI18n } from "vue-i18n"
|
||||||
|
import type { Server } from "@/types/server"
|
||||||
|
|
||||||
|
defineProps<{ server: Server }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
edit: []
|
||||||
|
delete: []
|
||||||
|
toggleEnabled: [value: boolean]
|
||||||
|
togglePinned: [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
type SwitchValue = boolean | string | number
|
||||||
|
|
||||||
|
function onPinnedChange(checked: SwitchValue) {
|
||||||
|
emit("togglePinned", Boolean(checked))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnabledChange(checked: SwitchValue) {
|
||||||
|
emit("toggleEnabled", Boolean(checked))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.server-card {
|
||||||
|
border-color: rgba(15, 23, 42, 0.08);
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .server-card {
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
:title="isEdit ? t('server.form.editTitle') : t('server.form.addTitle')"
|
||||||
|
:confirm-loading="saving"
|
||||||
|
:mask-closable="false"
|
||||||
|
centered
|
||||||
|
wrap-class-name="form-modal-wrap"
|
||||||
|
destroy-on-close
|
||||||
|
:ok-text="t('common.save')"
|
||||||
|
:cancel-text="t('common.cancel')"
|
||||||
|
@ok="handleOk"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<a-form ref="formRef" :model="form" :rules="rules" :disabled="saving" layout="vertical">
|
||||||
|
<a-form-item :label="t('server.name')" name="name">
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.name"
|
||||||
|
:placeholder="t('server.namePlaceholder')"
|
||||||
|
:maxlength="50"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-row :gutter="12">
|
||||||
|
<a-col :span="16">
|
||||||
|
<a-form-item :label="t('server.addr')" name="addr">
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.addr"
|
||||||
|
:placeholder="t('server.addrPlaceholder')"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-form-item :label="t('server.port')" name="port">
|
||||||
|
<a-input-number v-model:value="form.port" :min="1" :max="65535" class="w-full" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item :label="t('server.token')" name="token">
|
||||||
|
<a-input-password
|
||||||
|
v-model:value="form.token"
|
||||||
|
:placeholder="t('server.tokenPlaceholder')"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-row :gutter="12">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('server.protocol')" name="protocol">
|
||||||
|
<a-select v-model:value="form.protocol" :options="protocolOptions" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('server.tls')" name="tls_enable">
|
||||||
|
<a-switch v-model:checked="form.tls_enable" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item :label="t('server.remark')" name="remark">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="form.remark"
|
||||||
|
:rows="2"
|
||||||
|
:maxlength="200"
|
||||||
|
show-count
|
||||||
|
:placeholder="t('server.remarkPlaceholder')"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-row :gutter="12">
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-form-item :label="t('server.sort')" name="sort">
|
||||||
|
<a-input-number v-model:value="form.sort" class="w-full" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-form-item :label="t('server.pinned')" name="pinned">
|
||||||
|
<a-switch v-model:checked="form.pinned" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-form-item
|
||||||
|
:label="t('server.enabled')"
|
||||||
|
name="enabled"
|
||||||
|
:tooltip="t('server.enabledTip')"
|
||||||
|
>
|
||||||
|
<a-switch v-model:checked="form.enabled" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { message } from "ant-design-vue"
|
||||||
|
import type { FormInstance, FormProps } from "ant-design-vue"
|
||||||
|
import { useI18n } from "vue-i18n"
|
||||||
|
import { createServer, updateServer } from "@/api/server"
|
||||||
|
import {
|
||||||
|
SERVER_PROTOCOLS,
|
||||||
|
type Server,
|
||||||
|
type ServerPayload,
|
||||||
|
type ServerProtocol,
|
||||||
|
} from "@/types/server"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
/** 编辑时传入,新增时为 null */
|
||||||
|
server?: Server | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:open": [value: boolean]
|
||||||
|
saved: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string
|
||||||
|
addr: string
|
||||||
|
port: number
|
||||||
|
token: string
|
||||||
|
protocol: ServerProtocol
|
||||||
|
tls_enable: boolean
|
||||||
|
remark: string
|
||||||
|
sort: number
|
||||||
|
pinned: boolean
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const protocolOptions = SERVER_PROTOCOLS
|
||||||
|
|
||||||
|
const form = reactive<FormState>({
|
||||||
|
name: "",
|
||||||
|
addr: "",
|
||||||
|
port: 7000,
|
||||||
|
token: "",
|
||||||
|
protocol: "tcp",
|
||||||
|
tls_enable: false,
|
||||||
|
remark: "",
|
||||||
|
sort: 0,
|
||||||
|
pinned: false,
|
||||||
|
enabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEdit = computed(() => props.server?.id != null)
|
||||||
|
|
||||||
|
/** 是否为合法的 IPv4 或域名 */
|
||||||
|
function isValidAddr(value: string): boolean {
|
||||||
|
const addr = value.trim()
|
||||||
|
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/
|
||||||
|
if (ipv4.test(addr)) {
|
||||||
|
return addr.split(".").every((part) => Number(part) >= 0 && Number(part) <= 255)
|
||||||
|
}
|
||||||
|
const hostname = /^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
|
||||||
|
return hostname.test(addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules: FormProps["rules"] = {
|
||||||
|
name: [{ required: true, message: t("server.form.nameRequired"), trigger: "blur" }],
|
||||||
|
addr: [
|
||||||
|
{ required: true, message: t("server.form.addrRequired"), trigger: "blur" },
|
||||||
|
{
|
||||||
|
trigger: "blur",
|
||||||
|
validator: async (_rule, value: string) => {
|
||||||
|
if (value && !isValidAddr(value)) {
|
||||||
|
throw new Error(t("server.form.addrInvalid"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
port: [
|
||||||
|
{ required: true, message: t("server.form.portRequired"), trigger: "change" },
|
||||||
|
{
|
||||||
|
trigger: "change",
|
||||||
|
validator: async (_rule, value: number | null) => {
|
||||||
|
if (value != null && (value < 1 || value > 65535)) {
|
||||||
|
throw new Error(t("server.form.portRange"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function errMsg(e: unknown) {
|
||||||
|
return e instanceof Error ? e.message : String(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据编辑对象或默认值重置表单 */
|
||||||
|
function resetForm() {
|
||||||
|
const server = props.server
|
||||||
|
form.name = server?.name ?? ""
|
||||||
|
form.addr = server?.addr ?? ""
|
||||||
|
form.port = server?.port ?? 7000
|
||||||
|
form.token = server?.token ?? ""
|
||||||
|
form.protocol = server?.protocol ?? "tcp"
|
||||||
|
form.tls_enable = server?.tls_enable ?? false
|
||||||
|
form.remark = server?.remark ?? ""
|
||||||
|
form.sort = server?.sort ?? 0
|
||||||
|
form.pinned = server?.pinned ?? false
|
||||||
|
form.enabled = server?.enabled ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
if (!open) return
|
||||||
|
resetForm()
|
||||||
|
nextTick(() => formRef.value?.clearValidate())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit("update:open", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOk() {
|
||||||
|
try {
|
||||||
|
await formRef.value?.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload: ServerPayload = {
|
||||||
|
id: props.server?.id,
|
||||||
|
name: form.name.trim(),
|
||||||
|
addr: form.addr.trim(),
|
||||||
|
port: Number(form.port ?? 7000),
|
||||||
|
token: form.token.trim() || null,
|
||||||
|
protocol: form.protocol,
|
||||||
|
tls_enable: form.tls_enable,
|
||||||
|
remark: form.remark.trim() || null,
|
||||||
|
sort: Number(form.sort ?? 0),
|
||||||
|
pinned: form.pinned,
|
||||||
|
enabled: form.enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit.value) {
|
||||||
|
await updateServer(payload)
|
||||||
|
} else {
|
||||||
|
await createServer(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success(t("server.saveSuccess"))
|
||||||
|
emit("saved")
|
||||||
|
emit("update:open", false)
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<a-card class="tunnel-card" :body-style="{ padding: '16px' }" hoverable>
|
||||||
|
<!-- 头部:名称 + 状态标签 + 操作 -->
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="truncate text-base font-semibold">{{ tunnel.name }}</span>
|
||||||
|
<a-tag v-if="tunnel.pinned" color="gold" class="!me-0">{{ t("tunnel.pinned") }}</a-tag>
|
||||||
|
<a-tag v-if="tunnel.enabled" color="green" class="!me-0">{{ t("tunnel.enabled") }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 truncate font-mono text-sm opacity-70">
|
||||||
|
{{ tunnel.local_ip }}:{{ tunnel.local_port }}
|
||||||
|
<template v-if="tunnel.remote_port != null">
|
||||||
|
<span class="mx-1 opacity-50">→</span>{{ tunnel.remote_port }}
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<a-tooltip :title="t('tunnel.edit')">
|
||||||
|
<a-button type="text" size="small" :icon="h(EditOutlined)" @click="emit('edit')" />
|
||||||
|
</a-tooltip>
|
||||||
|
<a-popconfirm
|
||||||
|
:title="t('tunnel.deleteConfirm', { name: tunnel.name })"
|
||||||
|
:ok-text="t('common.confirm')"
|
||||||
|
:cancel-text="t('common.cancel')"
|
||||||
|
@confirm="emit('delete')"
|
||||||
|
>
|
||||||
|
<a-button type="text" size="small" danger :icon="h(DeleteOutlined)" />
|
||||||
|
</a-popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 元信息 -->
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<a-tag :color="typeColor" class="!me-0 uppercase">{{ typeLabel }}</a-tag>
|
||||||
|
<a-tag v-if="domainText" color="blue" class="!me-0 max-w-full truncate">{{ domainText }}</a-tag>
|
||||||
|
<a-tag v-if="tunnel.use_encryption" color="cyan" class="!me-0">
|
||||||
|
{{ t("tunnel.useEncryption") }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-if="tunnel.use_compression" color="purple" class="!me-0">
|
||||||
|
{{ t("tunnel.useCompression") }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-if="tunnel.secret_key" color="volcano" class="!me-0">Key</a-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="tunnel.remark" class="mt-2 line-clamp-2 text-xs opacity-60">{{ tunnel.remark }}</p>
|
||||||
|
|
||||||
|
<a-divider class="!my-3" />
|
||||||
|
|
||||||
|
<!-- 底部开关 -->
|
||||||
|
<div class="flex items-center justify-end gap-6">
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 text-xs">
|
||||||
|
<a-switch size="small" :checked="tunnel.pinned" @change="onPinnedChange" />
|
||||||
|
<span>{{ t("tunnel.pinned") }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 text-xs">
|
||||||
|
<a-switch size="small" :checked="tunnel.enabled" @change="onEnabledChange" />
|
||||||
|
<span>{{ t("tunnel.enabled") }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { h } from "vue"
|
||||||
|
import { DeleteOutlined, EditOutlined } from "@ant-design/icons-vue"
|
||||||
|
import { useI18n } from "vue-i18n"
|
||||||
|
import type { Tunnel, TunnelType } from "@/types/tunnel"
|
||||||
|
|
||||||
|
const props = defineProps<{ tunnel: Tunnel }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
edit: []
|
||||||
|
delete: []
|
||||||
|
toggleEnabled: [value: boolean]
|
||||||
|
togglePinned: [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
/** 代理类型对应的标签配色 */
|
||||||
|
const TYPE_COLORS: Record<TunnelType, string> = {
|
||||||
|
tcp: "blue",
|
||||||
|
udp: "geekblue",
|
||||||
|
http: "green",
|
||||||
|
https: "cyan",
|
||||||
|
stcp: "purple",
|
||||||
|
xtcp: "magenta",
|
||||||
|
sudp: "volcano",
|
||||||
|
tcpmux: "gold",
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeColor = computed(() => TYPE_COLORS[props.tunnel.type] ?? "default")
|
||||||
|
const typeLabel = computed(() => t(`tunnel.types.${props.tunnel.type}`))
|
||||||
|
|
||||||
|
/** 自定义域名 / 子域名摘要 */
|
||||||
|
const domainText = computed(() => {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (props.tunnel.custom_domains) parts.push(props.tunnel.custom_domains)
|
||||||
|
if (props.tunnel.subdomain) parts.push(`*.${props.tunnel.subdomain}`)
|
||||||
|
return parts.join(", ")
|
||||||
|
})
|
||||||
|
|
||||||
|
type SwitchValue = boolean | string | number
|
||||||
|
|
||||||
|
function onPinnedChange(checked: SwitchValue) {
|
||||||
|
emit("togglePinned", Boolean(checked))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnabledChange(checked: SwitchValue) {
|
||||||
|
emit("toggleEnabled", Boolean(checked))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.tunnel-card {
|
||||||
|
border-color: rgba(15, 23, 42, 0.08);
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tunnel-card {
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
:title="isEdit ? t('tunnel.form.editTitle') : t('tunnel.form.addTitle')"
|
||||||
|
:confirm-loading="saving"
|
||||||
|
:mask-closable="false"
|
||||||
|
:width="640"
|
||||||
|
centered
|
||||||
|
wrap-class-name="form-modal-wrap"
|
||||||
|
destroy-on-close
|
||||||
|
:ok-text="t('common.save')"
|
||||||
|
:cancel-text="t('common.cancel')"
|
||||||
|
@ok="handleOk"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<a-form ref="formRef" :model="form" :rules="rules" :disabled="saving" layout="vertical">
|
||||||
|
<!-- 基础信息 -->
|
||||||
|
<a-form-item :label="t('tunnel.name')" name="name">
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.name"
|
||||||
|
:placeholder="t('tunnel.namePlaceholder')"
|
||||||
|
:maxlength="50"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-row :gutter="12">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('tunnel.type')" name="type">
|
||||||
|
<a-select v-model:value="form.type" :options="typeOptions" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('tunnel.localIp')" name="localIp">
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.localIp"
|
||||||
|
:placeholder="t('tunnel.localIpPlaceholder')"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="12">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('tunnel.localPort')" name="localPort">
|
||||||
|
<a-input-number v-model:value="form.localPort" :min="1" :max="65535" class="w-full" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item
|
||||||
|
:label="t('tunnel.remotePort')"
|
||||||
|
name="remotePort"
|
||||||
|
:tooltip="t('tunnel.remotePortTip')"
|
||||||
|
>
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="form.remotePort"
|
||||||
|
:min="1"
|
||||||
|
:max="65535"
|
||||||
|
class="w-full"
|
||||||
|
:placeholder="showRemotePort ? '' : t('tunnel.remotePortTip')"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item :label="t('tunnel.remark')" name="remark">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="form.remark"
|
||||||
|
:rows="2"
|
||||||
|
:maxlength="200"
|
||||||
|
show-count
|
||||||
|
:placeholder="t('tunnel.remarkPlaceholder')"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-row :gutter="12">
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-form-item :label="t('tunnel.sort')" name="sort">
|
||||||
|
<a-input-number v-model:value="form.sort" class="w-full" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-form-item :label="t('tunnel.pinned')" name="pinned">
|
||||||
|
<a-switch v-model:checked="form.pinned" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-form-item
|
||||||
|
:label="t('tunnel.enabled')"
|
||||||
|
name="enabled"
|
||||||
|
:tooltip="t('tunnel.enabledTip')"
|
||||||
|
>
|
||||||
|
<a-switch v-model:checked="form.enabled" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<!-- 高级设置(折叠面板) -->
|
||||||
|
<a-collapse v-model:active-key="activeKeys" ghost>
|
||||||
|
<a-collapse-panel key="advanced" :header="t('tunnel.advanced')">
|
||||||
|
<!-- 域名 / 密钥(按代理类型展示) -->
|
||||||
|
<a-row v-if="showDomain" :gutter="12">
|
||||||
|
<a-col :span="showSubdomain ? 12 : 24">
|
||||||
|
<a-form-item :label="t('tunnel.customDomains')" name="customDomains">
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.customDomains"
|
||||||
|
mode="tags"
|
||||||
|
:open="false"
|
||||||
|
:token-separators="[',']"
|
||||||
|
:placeholder="t('tunnel.customDomainsPlaceholder')"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col v-if="showSubdomain" :span="12">
|
||||||
|
<a-form-item :label="t('tunnel.subdomain')" name="subdomain">
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.subdomain"
|
||||||
|
:placeholder="t('tunnel.subdomainPlaceholder')"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item v-if="showLocations" :label="t('tunnel.locations')" name="locations">
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.locations"
|
||||||
|
mode="tags"
|
||||||
|
:open="false"
|
||||||
|
:token-separators="[',']"
|
||||||
|
:placeholder="t('tunnel.locationsPlaceholder')"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="showSecret" :label="t('tunnel.secretKey')" name="secretKey">
|
||||||
|
<a-input-password
|
||||||
|
v-model:value="form.secretKey"
|
||||||
|
:placeholder="t('tunnel.secretKeyPlaceholder')"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<!-- 传输配置 -->
|
||||||
|
<a-row :gutter="12">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('tunnel.useEncryption')" name="useEncryption">
|
||||||
|
<a-switch v-model:checked="form.useEncryption" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('tunnel.useCompression')" name="useCompression">
|
||||||
|
<a-switch v-model:checked="form.useCompression" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="12">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('tunnel.bandwidthLimit')" name="bandwidthLimit">
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.bandwidthLimit"
|
||||||
|
:placeholder="t('tunnel.bandwidthLimitPlaceholder')"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item
|
||||||
|
:label="t('tunnel.proxyProtocolVersion')"
|
||||||
|
name="proxyProtocolVersion"
|
||||||
|
>
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.proxyProtocolVersion"
|
||||||
|
:options="proxyProtocolOptions"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<!-- 健康检查 -->
|
||||||
|
<a-divider orientation="left" class="!my-2 !text-xs">
|
||||||
|
{{ t("tunnel.healthCheck") }}
|
||||||
|
</a-divider>
|
||||||
|
|
||||||
|
<a-row :gutter="12">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('tunnel.healthCheckType')" name="healthCheckType">
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.healthCheckType"
|
||||||
|
:options="healthCheckTypeOptions"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('tunnel.healthCheckTimeout')" name="healthCheckTimeout">
|
||||||
|
<a-input-number v-model:value="form.healthCheckTimeout" :min="1" class="w-full" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="12">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('tunnel.healthCheckInterval')" name="healthCheckInterval">
|
||||||
|
<a-input-number v-model:value="form.healthCheckInterval" :min="1" class="w-full" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('tunnel.healthCheckMaxFailed')" name="healthCheckMaxFailed">
|
||||||
|
<a-input-number v-model:value="form.healthCheckMaxFailed" :min="1" class="w-full" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item :label="t('tunnel.healthCheckPath')" name="healthCheckPath">
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.healthCheckPath"
|
||||||
|
:placeholder="t('tunnel.healthCheckPathPlaceholder')"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<!-- 负载均衡 -->
|
||||||
|
<a-divider orientation="left" class="!my-2 !text-xs">
|
||||||
|
{{ t("tunnel.loadBalancer") }}
|
||||||
|
</a-divider>
|
||||||
|
|
||||||
|
<a-row :gutter="12">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('tunnel.loadBalancerGroup')" name="loadBalancerGroup">
|
||||||
|
<a-input
|
||||||
|
v-model:value="form.loadBalancerGroup"
|
||||||
|
:placeholder="t('tunnel.loadBalancerGroupPlaceholder')"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('tunnel.loadBalancerGroupKey')" name="loadBalancerGroupKey">
|
||||||
|
<a-input v-model:value="form.loadBalancerGroupKey" allow-clear />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-collapse-panel>
|
||||||
|
</a-collapse>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { message } from "ant-design-vue"
|
||||||
|
import type { FormInstance, FormProps } from "ant-design-vue"
|
||||||
|
import { useI18n } from "vue-i18n"
|
||||||
|
import { createTunnel, updateTunnel } from "@/api/tunnel"
|
||||||
|
import {
|
||||||
|
TUNNEL_TYPES,
|
||||||
|
TYPES_WITH_DOMAIN,
|
||||||
|
TYPES_WITH_REMOTE_PORT,
|
||||||
|
TYPES_WITH_SECRET,
|
||||||
|
type Tunnel,
|
||||||
|
type TunnelPayload,
|
||||||
|
type TunnelType,
|
||||||
|
} from "@/types/tunnel"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
/** 编辑时传入,新增时为 null */
|
||||||
|
tunnel?: Tunnel | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:open": [value: boolean]
|
||||||
|
saved: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string
|
||||||
|
type: TunnelType
|
||||||
|
localIp: string
|
||||||
|
localPort: number | undefined
|
||||||
|
remotePort: number | undefined
|
||||||
|
customDomains: string[]
|
||||||
|
subdomain: string
|
||||||
|
locations: string[]
|
||||||
|
secretKey: string
|
||||||
|
useEncryption: boolean
|
||||||
|
useCompression: boolean
|
||||||
|
bandwidthLimit: string
|
||||||
|
proxyProtocolVersion: string
|
||||||
|
healthCheckType: string
|
||||||
|
healthCheckTimeout: number | undefined
|
||||||
|
healthCheckMaxFailed: number | undefined
|
||||||
|
healthCheckInterval: number | undefined
|
||||||
|
healthCheckPath: string
|
||||||
|
loadBalancerGroup: string
|
||||||
|
loadBalancerGroupKey: string
|
||||||
|
remark: string
|
||||||
|
sort: number
|
||||||
|
pinned: boolean
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const saving = ref(false)
|
||||||
|
const activeKeys = ref<string[]>([])
|
||||||
|
|
||||||
|
const form = reactive<FormState>({
|
||||||
|
name: "",
|
||||||
|
type: "tcp",
|
||||||
|
localIp: "127.0.0.1",
|
||||||
|
localPort: undefined,
|
||||||
|
remotePort: undefined,
|
||||||
|
customDomains: [],
|
||||||
|
subdomain: "",
|
||||||
|
locations: [],
|
||||||
|
secretKey: "",
|
||||||
|
useEncryption: false,
|
||||||
|
useCompression: false,
|
||||||
|
bandwidthLimit: "",
|
||||||
|
proxyProtocolVersion: "",
|
||||||
|
healthCheckType: "",
|
||||||
|
healthCheckTimeout: undefined,
|
||||||
|
healthCheckMaxFailed: undefined,
|
||||||
|
healthCheckInterval: undefined,
|
||||||
|
healthCheckPath: "",
|
||||||
|
loadBalancerGroup: "",
|
||||||
|
loadBalancerGroupKey: "",
|
||||||
|
remark: "",
|
||||||
|
sort: 0,
|
||||||
|
pinned: false,
|
||||||
|
enabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEdit = computed(() => props.tunnel?.id != null)
|
||||||
|
|
||||||
|
/** 依据代理类型决定字段的显隐 */
|
||||||
|
const showRemotePort = computed(() => TYPES_WITH_REMOTE_PORT.includes(form.type))
|
||||||
|
const showDomain = computed(() => TYPES_WITH_DOMAIN.includes(form.type))
|
||||||
|
const showSubdomain = computed(() => form.type === "http" || form.type === "https")
|
||||||
|
const showLocations = computed(() => form.type === "http")
|
||||||
|
const showSecret = computed(() => TYPES_WITH_SECRET.includes(form.type))
|
||||||
|
|
||||||
|
const typeOptions = computed(() =>
|
||||||
|
TUNNEL_TYPES.map((value) => ({ value, label: t(`tunnel.types.${value}`) })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const proxyProtocolOptions = computed(() => [
|
||||||
|
{ value: "", label: t("tunnel.proxyProtocolVersionDefault") },
|
||||||
|
{ value: "v1", label: "v1" },
|
||||||
|
{ value: "v2", label: "v2" },
|
||||||
|
])
|
||||||
|
|
||||||
|
const healthCheckTypeOptions = computed(() => [
|
||||||
|
{ value: "", label: t("tunnel.healthCheckTypeDefault") },
|
||||||
|
{ value: "tcp", label: "TCP" },
|
||||||
|
{ value: "http", label: "HTTP" },
|
||||||
|
])
|
||||||
|
|
||||||
|
/** 是否为合法的 IPv4 或域名 */
|
||||||
|
function isValidHost(value: string): boolean {
|
||||||
|
const host = value.trim()
|
||||||
|
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/
|
||||||
|
if (ipv4.test(host)) {
|
||||||
|
return host.split(".").every((part) => Number(part) >= 0 && Number(part) <= 255)
|
||||||
|
}
|
||||||
|
const hostname =
|
||||||
|
/^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
|
||||||
|
return hostname.test(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 端口范围校验器 */
|
||||||
|
async function portValidator(_rule: unknown, value: number | null) {
|
||||||
|
if (value != null && (value < 1 || value > 65535)) {
|
||||||
|
throw new Error(t("tunnel.form.portRange"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 依据代理类型动态调整校验规则(远程端口仅 tcp / udp 必填)
|
||||||
|
const rules = computed<FormProps["rules"]>(() => ({
|
||||||
|
name: [{ required: true, message: t("tunnel.form.nameRequired"), trigger: "blur" }],
|
||||||
|
type: [{ required: true, message: t("tunnel.form.typeRequired"), trigger: "change" }],
|
||||||
|
localIp: [
|
||||||
|
{ required: true, message: t("tunnel.form.localIpRequired"), trigger: "blur" },
|
||||||
|
{
|
||||||
|
trigger: "blur",
|
||||||
|
validator: async (_rule: unknown, value: string) => {
|
||||||
|
if (value && !isValidHost(value)) {
|
||||||
|
throw new Error(t("tunnel.form.localIpInvalid"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
localPort: [
|
||||||
|
{ required: true, message: t("tunnel.form.localPortRequired"), trigger: "change" },
|
||||||
|
{ trigger: "change", validator: portValidator },
|
||||||
|
],
|
||||||
|
remotePort: showRemotePort.value
|
||||||
|
? [
|
||||||
|
{ required: true, message: t("tunnel.form.remotePortRequired"), trigger: "change" },
|
||||||
|
{ trigger: "change", validator: portValidator },
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
function errMsg(e: unknown) {
|
||||||
|
return e instanceof Error ? e.message : String(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 英文逗号分隔字符串 → 数组 */
|
||||||
|
function splitList(value?: string | null): string[] {
|
||||||
|
if (!value) return []
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 数组 → 英文逗号分隔字符串(空数组返回 null) */
|
||||||
|
function joinList(value: string[]): string | null {
|
||||||
|
const list = value.map((item) => item.trim()).filter(Boolean)
|
||||||
|
return list.length ? list.join(",") : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空字符串转 null */
|
||||||
|
function textOrNull(value: string): string | null {
|
||||||
|
return value.trim() || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据编辑对象或默认值重置表单 */
|
||||||
|
function resetForm() {
|
||||||
|
const tunnel = props.tunnel
|
||||||
|
form.name = tunnel?.name ?? ""
|
||||||
|
form.type = tunnel?.type ?? "tcp"
|
||||||
|
form.localIp = tunnel?.local_ip ?? "127.0.0.1"
|
||||||
|
form.localPort = tunnel?.local_port ?? undefined
|
||||||
|
form.remotePort = tunnel?.remote_port ?? undefined
|
||||||
|
form.customDomains = splitList(tunnel?.custom_domains)
|
||||||
|
form.subdomain = tunnel?.subdomain ?? ""
|
||||||
|
form.locations = splitList(tunnel?.locations)
|
||||||
|
form.secretKey = tunnel?.secret_key ?? ""
|
||||||
|
form.useEncryption = tunnel?.use_encryption ?? false
|
||||||
|
form.useCompression = tunnel?.use_compression ?? false
|
||||||
|
form.bandwidthLimit = tunnel?.bandwidth_limit ?? ""
|
||||||
|
form.proxyProtocolVersion = tunnel?.proxy_protocol_version ?? ""
|
||||||
|
form.healthCheckType = tunnel?.health_check_type ?? ""
|
||||||
|
form.healthCheckTimeout = tunnel?.health_check_timeout ?? undefined
|
||||||
|
form.healthCheckMaxFailed = tunnel?.health_check_max_failed ?? undefined
|
||||||
|
form.healthCheckInterval = tunnel?.health_check_interval ?? undefined
|
||||||
|
form.healthCheckPath = tunnel?.health_check_path ?? ""
|
||||||
|
form.loadBalancerGroup = tunnel?.load_balancer_group ?? ""
|
||||||
|
form.loadBalancerGroupKey = tunnel?.load_balancer_group_key ?? ""
|
||||||
|
form.remark = tunnel?.remark ?? ""
|
||||||
|
form.sort = tunnel?.sort ?? 0
|
||||||
|
form.pinned = tunnel?.pinned ?? false
|
||||||
|
form.enabled = tunnel?.enabled ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
if (!open) return
|
||||||
|
activeKeys.value = []
|
||||||
|
resetForm()
|
||||||
|
nextTick(() => formRef.value?.clearValidate())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit("update:open", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOk() {
|
||||||
|
try {
|
||||||
|
await formRef.value?.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload: TunnelPayload = {
|
||||||
|
id: props.tunnel?.id,
|
||||||
|
name: form.name.trim(),
|
||||||
|
type: form.type,
|
||||||
|
local_ip: form.localIp.trim(),
|
||||||
|
local_port: Number(form.localPort ?? 0),
|
||||||
|
remote_port: showRemotePort.value ? (form.remotePort ?? null) : null,
|
||||||
|
custom_domains: showDomain.value ? joinList(form.customDomains) : null,
|
||||||
|
subdomain: showSubdomain.value ? textOrNull(form.subdomain) : null,
|
||||||
|
locations: showLocations.value ? joinList(form.locations) : null,
|
||||||
|
secret_key: showSecret.value ? textOrNull(form.secretKey) : null,
|
||||||
|
use_encryption: form.useEncryption,
|
||||||
|
use_compression: form.useCompression,
|
||||||
|
bandwidth_limit: textOrNull(form.bandwidthLimit),
|
||||||
|
proxy_protocol_version: form.proxyProtocolVersion || null,
|
||||||
|
health_check_type: form.healthCheckType || null,
|
||||||
|
health_check_timeout: form.healthCheckTimeout ?? null,
|
||||||
|
health_check_max_failed: form.healthCheckMaxFailed ?? null,
|
||||||
|
health_check_interval: form.healthCheckInterval ?? null,
|
||||||
|
health_check_path: textOrNull(form.healthCheckPath),
|
||||||
|
load_balancer_group: textOrNull(form.loadBalancerGroup),
|
||||||
|
load_balancer_group_key: textOrNull(form.loadBalancerGroupKey),
|
||||||
|
remark: textOrNull(form.remark),
|
||||||
|
sort: Number(form.sort ?? 0),
|
||||||
|
pinned: form.pinned,
|
||||||
|
enabled: form.enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit.value) {
|
||||||
|
await updateTunnel(payload)
|
||||||
|
} else {
|
||||||
|
await createTunnel(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success(t("tunnel.saveSuccess"))
|
||||||
|
emit("saved")
|
||||||
|
emit("update:open", false)
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
@@ -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 ?? ""
|
||||||
|
}
|
||||||
+132
-3
@@ -22,7 +22,8 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"servers": "Servers",
|
"servers": "Servers",
|
||||||
"tunnels": "Tunnels",
|
"tunnels": "Tunnels",
|
||||||
"download": "Download frpc"
|
"download": "Download frpc",
|
||||||
|
"setting": "Settings"
|
||||||
},
|
},
|
||||||
"system": {
|
"system": {
|
||||||
"cpu": "CPU usage",
|
"cpu": "CPU usage",
|
||||||
@@ -34,9 +35,137 @@
|
|||||||
},
|
},
|
||||||
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"add": "Add server",
|
||||||
|
"empty": "No servers yet. Click Add in the top right corner.",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteConfirm": "Are you sure you want to delete server \"{name}\"?",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Enter a server name",
|
||||||
|
"addr": "Server address",
|
||||||
|
"addrPlaceholder": "IP or domain, e.g. 127.0.0.1 / frp.example.com",
|
||||||
|
"port": "Port",
|
||||||
|
"token": "Auth token",
|
||||||
|
"tokenPlaceholder": "Corresponds to frpc auth.token, optional",
|
||||||
|
"protocol": "Transport protocol",
|
||||||
|
"tls": "Enable TLS",
|
||||||
|
"remark": "Remark",
|
||||||
|
"remarkPlaceholder": "Optional remark",
|
||||||
|
"sort": "Sort",
|
||||||
|
"pinned": "Pinned",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"enabledTip": "Only one server can be enabled at a time",
|
||||||
|
"saveSuccess": "Saved successfully",
|
||||||
|
"deleteSuccess": "Deleted successfully",
|
||||||
|
"form": {
|
||||||
|
"addTitle": "Add server",
|
||||||
|
"editTitle": "Edit server",
|
||||||
|
"nameRequired": "Please enter the server name",
|
||||||
|
"addrRequired": "Please enter the server address",
|
||||||
|
"addrInvalid": "Please enter a valid IP address or domain",
|
||||||
|
"portRequired": "Please enter the port",
|
||||||
|
"portRange": "Port must be between 1 and 65535"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tunnel": {
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"add": "Add tunnel",
|
||||||
|
"empty": "No tunnels yet. Click Add in the top right corner.",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteConfirm": "Are you sure you want to delete tunnel \"{name}\"?",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Enter a tunnel name",
|
||||||
|
"type": "Proxy type",
|
||||||
|
"localIp": "Local IP",
|
||||||
|
"localIpPlaceholder": "e.g. 127.0.0.1",
|
||||||
|
"localPort": "Local port",
|
||||||
|
"remotePort": "Remote port",
|
||||||
|
"remotePortTip": "Required for tcp / udp",
|
||||||
|
"pinned": "Pinned",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"enabledTip": "Multiple tunnels can be enabled at once",
|
||||||
|
"remark": "Remark",
|
||||||
|
"remarkPlaceholder": "Optional remark",
|
||||||
|
"sort": "Sort",
|
||||||
|
"saveSuccess": "Saved successfully",
|
||||||
|
"deleteSuccess": "Deleted successfully",
|
||||||
|
"advanced": "Advanced settings",
|
||||||
|
"customDomains": "Custom domains",
|
||||||
|
"customDomainsPlaceholder": "Type a domain and press Enter to add more",
|
||||||
|
"subdomain": "Subdomain",
|
||||||
|
"subdomainPlaceholder": "e.g. test",
|
||||||
|
"locations": "URL prefixes",
|
||||||
|
"locationsPlaceholder": "Type a URL prefix and press Enter to add more",
|
||||||
|
"secretKey": "Secret key",
|
||||||
|
"secretKeyPlaceholder": "Secret key for stcp / xtcp / sudp",
|
||||||
|
"useEncryption": "Enable encryption",
|
||||||
|
"useCompression": "Enable compression",
|
||||||
|
"bandwidthLimit": "Bandwidth limit",
|
||||||
|
"bandwidthLimitPlaceholder": "e.g. 1MB / 1KB",
|
||||||
|
"proxyProtocolVersion": "Proxy Protocol version",
|
||||||
|
"proxyProtocolVersionDefault": "Disabled",
|
||||||
|
"healthCheck": "Health check",
|
||||||
|
"healthCheckType": "Check type",
|
||||||
|
"healthCheckTypeDefault": "Disabled",
|
||||||
|
"healthCheckTimeout": "Timeout (s)",
|
||||||
|
"healthCheckMaxFailed": "Max failed",
|
||||||
|
"healthCheckInterval": "Interval (s)",
|
||||||
|
"healthCheckPath": "Check path",
|
||||||
|
"healthCheckPathPlaceholder": "e.g. /healthz",
|
||||||
|
"loadBalancer": "Load balancer",
|
||||||
|
"loadBalancerGroup": "Group",
|
||||||
|
"loadBalancerGroupPlaceholder": "Proxies in the same group are load balanced",
|
||||||
|
"loadBalancerGroupKey": "Group key",
|
||||||
|
"local": "Local",
|
||||||
|
"remote": "Remote",
|
||||||
|
"types": {
|
||||||
|
"tcp": "TCP",
|
||||||
|
"udp": "UDP",
|
||||||
|
"http": "HTTP",
|
||||||
|
"https": "HTTPS",
|
||||||
|
"stcp": "STCP (secure TCP)",
|
||||||
|
"xtcp": "XTCP (P2P)",
|
||||||
|
"sudp": "SUDP (secure UDP)",
|
||||||
|
"tcpmux": "TCPMUX"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"addTitle": "Add tunnel",
|
||||||
|
"editTitle": "Edit tunnel",
|
||||||
|
"nameRequired": "Please enter the tunnel name",
|
||||||
|
"typeRequired": "Please select the proxy type",
|
||||||
|
"localIpRequired": "Please enter the local IP",
|
||||||
|
"localIpInvalid": "Please enter a valid IP address or domain",
|
||||||
|
"localPortRequired": "Please enter the local port",
|
||||||
|
"remotePortRequired": "Please enter the remote port",
|
||||||
|
"portRange": "Port must be between 1 and 65535"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"developing": "This feature is under construction"
|
"developing": "This feature is under construction",
|
||||||
|
"confirm": "OK",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+132
-3
@@ -22,7 +22,8 @@
|
|||||||
"dashboard": "仪表盘",
|
"dashboard": "仪表盘",
|
||||||
"servers": "服务器",
|
"servers": "服务器",
|
||||||
"tunnels": "隧道列表",
|
"tunnels": "隧道列表",
|
||||||
"download": "Frpc 下载"
|
"download": "Frpc 下载",
|
||||||
|
"setting": "设置"
|
||||||
},
|
},
|
||||||
"system": {
|
"system": {
|
||||||
"cpu": "CPU 占用",
|
"cpu": "CPU 占用",
|
||||||
@@ -34,9 +35,137 @@
|
|||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "设置",
|
"title": "设置",
|
||||||
"placeholder": "设置页面开发中,敬请期待。"
|
"placeholder": "设置页面开发中,敬请期待。",
|
||||||
|
"menu": {
|
||||||
|
"appearance": "外观设置"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "外观设置",
|
||||||
|
"subtitle": "自定义窗口的主题模式与背景图片",
|
||||||
|
"themeMode": "主题模式",
|
||||||
|
"themeModeTip": "切换窗口的明暗显示模式",
|
||||||
|
"light": "浅色",
|
||||||
|
"dark": "深色",
|
||||||
|
"background": "背景图片",
|
||||||
|
"backgroundTip": "选择一张图片作为窗口背景,图片保持原始比例自适应,不会变形拉伸",
|
||||||
|
"none": "默认",
|
||||||
|
"bg1": "背景一",
|
||||||
|
"bg2": "背景二",
|
||||||
|
"bg3": "背景三"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"refresh": "刷新",
|
||||||
|
"add": "新增服务器",
|
||||||
|
"empty": "暂无服务器,点击右上角新增",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除",
|
||||||
|
"deleteConfirm": "确定要删除服务器「{name}」吗?",
|
||||||
|
"name": "名称",
|
||||||
|
"namePlaceholder": "请输入服务器名称",
|
||||||
|
"addr": "服务器地址",
|
||||||
|
"addrPlaceholder": "IP 地址或域名,如 127.0.0.1 / frp.example.com",
|
||||||
|
"port": "端口",
|
||||||
|
"token": "鉴权 Token",
|
||||||
|
"tokenPlaceholder": "对应 frpc 的 auth.token,可留空",
|
||||||
|
"protocol": "传输协议",
|
||||||
|
"tls": "启用 TLS",
|
||||||
|
"remark": "备注",
|
||||||
|
"remarkPlaceholder": "可选备注信息",
|
||||||
|
"sort": "排序",
|
||||||
|
"pinned": "置顶",
|
||||||
|
"enabled": "启用",
|
||||||
|
"enabledTip": "同时只能启用一个服务器",
|
||||||
|
"saveSuccess": "保存成功",
|
||||||
|
"deleteSuccess": "删除成功",
|
||||||
|
"form": {
|
||||||
|
"addTitle": "新增服务器",
|
||||||
|
"editTitle": "编辑服务器",
|
||||||
|
"nameRequired": "请输入服务器名称",
|
||||||
|
"addrRequired": "请输入服务器地址",
|
||||||
|
"addrInvalid": "请输入合法的 IP 地址或域名",
|
||||||
|
"portRequired": "请输入端口号",
|
||||||
|
"portRange": "端口号必须在 1 - 65535 之间"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tunnel": {
|
||||||
|
"refresh": "刷新",
|
||||||
|
"add": "新增隧道",
|
||||||
|
"empty": "暂无隧道,点击右上角新增",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除",
|
||||||
|
"deleteConfirm": "确定要删除隧道「{name}」吗?",
|
||||||
|
"name": "名称",
|
||||||
|
"namePlaceholder": "请输入隧道名称",
|
||||||
|
"type": "代理类型",
|
||||||
|
"localIp": "本地 IP",
|
||||||
|
"localIpPlaceholder": "如 127.0.0.1",
|
||||||
|
"localPort": "本地端口",
|
||||||
|
"remotePort": "远程端口",
|
||||||
|
"remotePortTip": "tcp / udp 类型必填",
|
||||||
|
"pinned": "置顶",
|
||||||
|
"enabled": "启用",
|
||||||
|
"enabledTip": "可同时启用多个隧道",
|
||||||
|
"remark": "备注",
|
||||||
|
"remarkPlaceholder": "可选备注信息",
|
||||||
|
"sort": "排序",
|
||||||
|
"saveSuccess": "保存成功",
|
||||||
|
"deleteSuccess": "删除成功",
|
||||||
|
"advanced": "高级设置",
|
||||||
|
"customDomains": "自定义域名",
|
||||||
|
"customDomainsPlaceholder": "输入域名后回车,可添加多个",
|
||||||
|
"subdomain": "子域名",
|
||||||
|
"subdomainPlaceholder": "如 test",
|
||||||
|
"locations": "URL 前缀",
|
||||||
|
"locationsPlaceholder": "输入 URL 前缀后回车,可添加多个",
|
||||||
|
"secretKey": "密钥",
|
||||||
|
"secretKeyPlaceholder": "stcp / xtcp / sudp 所需密钥",
|
||||||
|
"useEncryption": "启用加密",
|
||||||
|
"useCompression": "启用压缩",
|
||||||
|
"bandwidthLimit": "带宽限制",
|
||||||
|
"bandwidthLimitPlaceholder": "如 1MB / 1KB",
|
||||||
|
"proxyProtocolVersion": "Proxy Protocol 版本",
|
||||||
|
"proxyProtocolVersionDefault": "不启用",
|
||||||
|
"healthCheck": "健康检查",
|
||||||
|
"healthCheckType": "检查类型",
|
||||||
|
"healthCheckTypeDefault": "不启用",
|
||||||
|
"healthCheckTimeout": "超时(秒)",
|
||||||
|
"healthCheckMaxFailed": "最大失败次数",
|
||||||
|
"healthCheckInterval": "检查间隔(秒)",
|
||||||
|
"healthCheckPath": "检查路径",
|
||||||
|
"healthCheckPathPlaceholder": "如 /healthz",
|
||||||
|
"loadBalancer": "负载均衡",
|
||||||
|
"loadBalancerGroup": "负载均衡组",
|
||||||
|
"loadBalancerGroupPlaceholder": "同一组内的代理会做负载均衡",
|
||||||
|
"loadBalancerGroupKey": "组密钥",
|
||||||
|
"local": "本地",
|
||||||
|
"remote": "远程",
|
||||||
|
"types": {
|
||||||
|
"tcp": "TCP",
|
||||||
|
"udp": "UDP",
|
||||||
|
"http": "HTTP",
|
||||||
|
"https": "HTTPS",
|
||||||
|
"stcp": "STCP(安全 TCP)",
|
||||||
|
"xtcp": "XTCP(点对点)",
|
||||||
|
"sudp": "SUDP(安全 UDP)",
|
||||||
|
"tcpmux": "TCPMUX"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"addTitle": "新增隧道",
|
||||||
|
"editTitle": "编辑隧道",
|
||||||
|
"nameRequired": "请输入隧道名称",
|
||||||
|
"typeRequired": "请选择代理类型",
|
||||||
|
"localIpRequired": "请输入本地 IP",
|
||||||
|
"localIpInvalid": "请输入合法的 IP 地址或域名",
|
||||||
|
"localPortRequired": "请输入本地端口",
|
||||||
|
"remotePortRequired": "请输入远程端口",
|
||||||
|
"portRange": "端口号必须在 1 - 65535 之间"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"developing": "功能开发中,敬请期待"
|
"developing": "功能开发中,敬请期待",
|
||||||
|
"confirm": "确定",
|
||||||
|
"cancel": "取消",
|
||||||
|
"save": "保存"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-33
@@ -1,51 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 应用整体:背景渐变 + 悬浮面板 -->
|
<!-- 应用整体:背景由 <html> 统一绘制,此处只负责布局 -->
|
||||||
<div class="app-shell flex h-screen w-screen flex-col overflow-hidden">
|
<div class="app-shell flex h-full w-full flex-col overflow-hidden">
|
||||||
<!-- 顶部自定义 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">
|
||||||
<Aside />
|
<Aside />
|
||||||
|
|
||||||
<main class="glass-panel min-w-0 flex-1 overflow-y-auto">
|
<main class="min-w-0 flex-1 overflow-hidden">
|
||||||
<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"
|
||||||
|
|
||||||
const { t } = useI18n()
|
const router = useRouter()
|
||||||
const settingsOpen = ref(false)
|
|
||||||
|
/** 点击标题栏“设置”按钮 → 跳转设置页 */
|
||||||
|
function goToSetting() {
|
||||||
|
router.push("/setting")
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.app-shell {
|
|
||||||
background:
|
|
||||||
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%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .app-shell {
|
|
||||||
background:
|
|
||||||
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%);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
+51
-37
@@ -3,51 +3,48 @@
|
|||||||
<aside class="glass-panel flex w-56 shrink-0 flex-col overflow-hidden">
|
<aside class="glass-panel flex w-56 shrink-0 flex-col overflow-hidden">
|
||||||
<!-- 菜单区 -->
|
<!-- 菜单区 -->
|
||||||
<div class="flex-1 overflow-y-auto p-2">
|
<div class="flex-1 overflow-y-auto p-2">
|
||||||
<a-menu
|
<a-menu class="aside-menu" mode="inline" :selected-keys="selectedKeys" :items="menuItems" @click="onMenuClick" />
|
||||||
class="aside-menu"
|
|
||||||
mode="inline"
|
|
||||||
:selected-keys="selectedKeys"
|
|
||||||
:items="menuItems"
|
|
||||||
@click="onMenuClick"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部系统信息区(与菜单之间有间隔) -->
|
<!-- 底部系统信息区(与菜单之间有间隔) -->
|
||||||
<div class="system-panel mt-2 border-t p-3">
|
<div class="system-panel mt-2 border-t px-2 pb-2 pt-3">
|
||||||
<!-- CPU -->
|
<div class="px-1">
|
||||||
<div class="mb-2">
|
<!-- CPU -->
|
||||||
<div class="mb-1 flex items-center justify-between text-xs">
|
<div class="mb-2">
|
||||||
<span class="opacity-70">{{ t("system.cpu") }}</span>
|
<div class="mb-1 flex items-center justify-between text-xs">
|
||||||
<span class="font-medium tabular-nums">{{ cpu }}%</span>
|
<span class="opacity-70">{{ t("system.cpu") }}</span>
|
||||||
|
<span class="font-medium tabular-nums">{{ cpu }}%</span>
|
||||||
|
</div>
|
||||||
|
<a-progress :percent="cpu" :show-info="false" :stroke-color="barColor(cpu)" size="small" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内存 -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="mb-1 flex items-center justify-between text-xs">
|
||||||
|
<span class="opacity-70">{{ t("system.memory") }}</span>
|
||||||
|
<span class="font-medium tabular-nums">{{ memory }}%</span>
|
||||||
|
</div>
|
||||||
|
<a-progress :percent="memory" :show-info="false" :stroke-color="barColor(memory)" size="small" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 运行状态 -->
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<span class="h-2 w-2 shrink-0 rounded-full" :class="statusDotClass" />
|
||||||
|
<span class="opacity-70">{{ t("system.status") }}</span>
|
||||||
|
<span class="font-medium">{{ statusText }}</span>
|
||||||
</div>
|
</div>
|
||||||
<a-progress
|
|
||||||
:percent="cpu"
|
|
||||||
:show-info="false"
|
|
||||||
:stroke-color="barColor(cpu)"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 内存 -->
|
<!-- 设置入口(位于运行状态下方) -->
|
||||||
<div class="mb-3">
|
<div class="system-divider mt-3 border-t pt-2">
|
||||||
<div class="mb-1 flex items-center justify-between text-xs">
|
<a-menu
|
||||||
<span class="opacity-70">{{ t("system.memory") }}</span>
|
class="aside-menu"
|
||||||
<span class="font-medium tabular-nums">{{ memory }}%</span>
|
mode="inline"
|
||||||
</div>
|
:selected-keys="settingSelectedKeys"
|
||||||
<a-progress
|
:items="settingItems"
|
||||||
:percent="memory"
|
@click="onMenuClick"
|
||||||
:show-info="false"
|
|
||||||
:stroke-color="barColor(memory)"
|
|
||||||
size="small"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 运行状态 -->
|
|
||||||
<div class="flex items-center gap-2 text-xs">
|
|
||||||
<span class="h-2 w-2 shrink-0 rounded-full" :class="statusDotClass" />
|
|
||||||
<span class="opacity-70">{{ t("system.status") }}</span>
|
|
||||||
<span class="font-medium">{{ statusText }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
@@ -59,6 +56,7 @@ import {
|
|||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
|
SettingOutlined,
|
||||||
} from "@ant-design/icons-vue"
|
} from "@ant-design/icons-vue"
|
||||||
import { useI18n } from "vue-i18n"
|
import { useI18n } from "vue-i18n"
|
||||||
import type { MenuProps } from "ant-design-vue"
|
import type { MenuProps } from "ant-design-vue"
|
||||||
@@ -74,6 +72,9 @@ const { cpu, memory } = useSystemStats()
|
|||||||
|
|
||||||
const selectedKeys = computed(() => [route.path])
|
const selectedKeys = computed(() => [route.path])
|
||||||
|
|
||||||
|
/** 设置项在运行状态下方独立渲染,子路由(如 /setting/appearance)同样高亮 */
|
||||||
|
const settingSelectedKeys = computed(() => (route.path.startsWith("/setting") ? ["/setting"] : []))
|
||||||
|
|
||||||
const menuItems = computed<MenuProps["items"]>(() => [
|
const menuItems = computed<MenuProps["items"]>(() => [
|
||||||
{ key: "/dashboard", icon: () => h(DashboardOutlined), label: t("menu.dashboard") },
|
{ key: "/dashboard", icon: () => h(DashboardOutlined), label: t("menu.dashboard") },
|
||||||
{ key: "/servers", icon: () => h(CloudServerOutlined), label: t("menu.servers") },
|
{ key: "/servers", icon: () => h(CloudServerOutlined), label: t("menu.servers") },
|
||||||
@@ -81,6 +82,10 @@ const menuItems = computed<MenuProps["items"]>(() => [
|
|||||||
{ key: "/download", icon: () => h(DownloadOutlined), label: t("menu.download") },
|
{ key: "/download", icon: () => h(DownloadOutlined), label: t("menu.download") },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const settingItems = computed<MenuProps["items"]>(() => [
|
||||||
|
{ key: "/setting", icon: () => h(SettingOutlined), label: t("menu.setting") },
|
||||||
|
])
|
||||||
|
|
||||||
const onMenuClick: MenuProps["onClick"] = ({ key }) => {
|
const onMenuClick: MenuProps["onClick"] = ({ key }) => {
|
||||||
router.push(String(key))
|
router.push(String(key))
|
||||||
}
|
}
|
||||||
@@ -124,6 +129,15 @@ const statusText = computed(() => {
|
|||||||
border-color: rgba(255, 255, 255, 0.08);
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 系统信息与设置入口之间的分隔线 */
|
||||||
|
.system-divider {
|
||||||
|
border-color: rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .system-divider {
|
||||||
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
/* 让 Antd 菜单透明化,融入磨砂玻璃面板 */
|
/* 让 Antd 菜单透明化,融入磨砂玻璃面板 */
|
||||||
.aside-menu {
|
.aside-menu {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
+30
-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,23 @@ 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))
|
||||||
|
|
||||||
|
// 把背景图同步到 <html>(整窗背景,避免元素高度误差造成的上下留白)
|
||||||
|
watchEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
const url = backgroundUrl.value
|
||||||
|
if (url) {
|
||||||
|
root.style.setProperty("--app-bg-image", `url('${url}')`)
|
||||||
|
root.classList.add("has-bg-image")
|
||||||
|
} else {
|
||||||
|
root.classList.remove("has-bg-image")
|
||||||
|
root.style.removeProperty("--app-bg-image")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Frpc 运行状态(后续由后端驱动)
|
// Frpc 运行状态(后续由后端驱动)
|
||||||
const runStatus = ref<RunStatus>("stopped")
|
const runStatus = ref<RunStatus>("stopped")
|
||||||
|
|
||||||
@@ -44,13 +66,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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* 与 Rust 端统一响应结构 `models::ApiResponse<T>` 一一对应的通用类型。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: T | null
|
||||||
|
}
|
||||||
Vendored
+24
-5
@@ -12,23 +12,42 @@ 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']
|
||||||
|
ACol: typeof import('ant-design-vue/es')['Col']
|
||||||
|
ACollapse: typeof import('ant-design-vue/es')['Collapse']
|
||||||
|
ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
|
||||||
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']
|
AEmpty: typeof import('ant-design-vue/es')['Empty']
|
||||||
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
|
AForm: typeof import('ant-design-vue/es')['Form']
|
||||||
ALayoutFooter: typeof import('ant-design-vue/es')['LayoutFooter']
|
AFormItem: typeof import('ant-design-vue/es')['FormItem']
|
||||||
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
|
AInput: typeof import('ant-design-vue/es')['Input']
|
||||||
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
|
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
|
||||||
|
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
|
||||||
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']
|
AModal: typeof import('ant-design-vue/es')['Modal']
|
||||||
|
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
|
||||||
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']
|
||||||
|
ARow: typeof import('ant-design-vue/es')['Row']
|
||||||
|
ASegmented: typeof import('ant-design-vue/es')['Segmented']
|
||||||
|
ASelect: typeof import('ant-design-vue/es')['Select']
|
||||||
ASpace: typeof import('ant-design-vue/es')['Space']
|
ASpace: typeof import('ant-design-vue/es')['Space']
|
||||||
|
ASpin: typeof import('ant-design-vue/es')['Spin']
|
||||||
|
ASwitch: typeof import('ant-design-vue/es')['Switch']
|
||||||
|
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||||
|
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||||
|
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||||
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']
|
||||||
|
ServerCard: typeof import('./../components/server/ServerCard.vue')['default']
|
||||||
|
ServerFormModal: typeof import('./../components/server/ServerFormModal.vue')['default']
|
||||||
TitleBarActions: typeof import('./../components/TitleBarActions.vue')['default']
|
TitleBarActions: typeof import('./../components/TitleBarActions.vue')['default']
|
||||||
|
TunnelCard: typeof import('./../components/tunnel/TunnelCard.vue')['default']
|
||||||
|
TunnelFormModal: typeof import('./../components/tunnel/TunnelFormModal.vue')['default']
|
||||||
WindowsTitleBar: typeof import('./../components/WindowsTitleBar.vue')['default']
|
WindowsTitleBar: typeof import('./../components/WindowsTitleBar.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 服务器相关的类型定义,字段与 Rust 端 `models::Server` / `models::ServerPayload` 一一对应。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type { ApiResponse } from "./api"
|
||||||
|
|
||||||
|
/** 传输协议,对应 frpc 的 transport.protocol */
|
||||||
|
export type ServerProtocol = "tcp" | "kcp" | "quic" | "websocket"
|
||||||
|
|
||||||
|
/** 服务器配置 */
|
||||||
|
export interface Server {
|
||||||
|
id: number
|
||||||
|
/** 名称(仅用于界面展示) */
|
||||||
|
name: string
|
||||||
|
/** 服务器地址 serverAddr,IP 或域名 */
|
||||||
|
addr: string
|
||||||
|
/** 服务器端口 serverPort */
|
||||||
|
port: number
|
||||||
|
/** 鉴权 Token auth.token */
|
||||||
|
token: string | null
|
||||||
|
/** 传输协议 transport.protocol */
|
||||||
|
protocol: ServerProtocol
|
||||||
|
/** 是否启用 TLS transport.tls.enable */
|
||||||
|
tls_enable: boolean
|
||||||
|
/** 备注 */
|
||||||
|
remark: string | null
|
||||||
|
/** 排序(越小越靠前) */
|
||||||
|
sort: number
|
||||||
|
/** 是否置顶 */
|
||||||
|
pinned: boolean
|
||||||
|
/** 是否启用(全局同时只能启用一个) */
|
||||||
|
enabled: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增 / 编辑服务器提交数据 */
|
||||||
|
export interface ServerPayload {
|
||||||
|
/** 编辑时携带,新增时省略 */
|
||||||
|
id?: number
|
||||||
|
name: string
|
||||||
|
addr: string
|
||||||
|
port: number
|
||||||
|
token?: string | null
|
||||||
|
protocol: ServerProtocol
|
||||||
|
tls_enable: boolean
|
||||||
|
remark?: string | null
|
||||||
|
sort: number
|
||||||
|
pinned: boolean
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 协议下拉选项 */
|
||||||
|
export const SERVER_PROTOCOLS: { value: ServerProtocol; label: string }[] = [
|
||||||
|
{ value: "tcp", label: "TCP" },
|
||||||
|
{ value: "kcp", label: "KCP" },
|
||||||
|
{ value: "quic", label: "QUIC" },
|
||||||
|
{ value: "websocket", label: "WebSocket" },
|
||||||
|
]
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* 隧道相关的类型定义,字段与 Rust 端 `models::Tunnel` / `models::TunnelPayload` 一一对应。
|
||||||
|
* 字段命名参考 frpc 的 proxy 配置(如 localIP / remotePort / customDomains 等)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 代理类型,对应 frpc 的 proxy type */
|
||||||
|
export type TunnelType =
|
||||||
|
| "tcp"
|
||||||
|
| "udp"
|
||||||
|
| "http"
|
||||||
|
| "https"
|
||||||
|
| "stcp"
|
||||||
|
| "xtcp"
|
||||||
|
| "sudp"
|
||||||
|
| "tcpmux"
|
||||||
|
|
||||||
|
/** 隧道配置 */
|
||||||
|
export interface Tunnel {
|
||||||
|
id: number
|
||||||
|
/** 隧道名称,对应 frpc 的 proxy name */
|
||||||
|
name: string
|
||||||
|
/** 代理类型,对应 frpc 的 type */
|
||||||
|
type: TunnelType
|
||||||
|
/** 本地 IP,对应 localIP */
|
||||||
|
local_ip: string
|
||||||
|
/** 本地端口,对应 localPort */
|
||||||
|
local_port: number
|
||||||
|
/** 远程端口,对应 remotePort(tcp / udp 必填) */
|
||||||
|
remote_port: number | null
|
||||||
|
/** 自定义域名,对应 customDomains(多个以英文逗号分隔) */
|
||||||
|
custom_domains: string | null
|
||||||
|
/** 子域名,对应 subdomain */
|
||||||
|
subdomain: string | null
|
||||||
|
/** URL 前缀,对应 locations(多个以英文逗号分隔) */
|
||||||
|
locations: string | null
|
||||||
|
/** 密钥,对应 secretKey(stcp / xtcp / sudp) */
|
||||||
|
secret_key: string | null
|
||||||
|
/** 传输加密,对应 transport.useEncryption */
|
||||||
|
use_encryption: boolean
|
||||||
|
/** 传输压缩,对应 transport.useCompression */
|
||||||
|
use_compression: boolean
|
||||||
|
/** 带宽限制,对应 transport.bandwidthLimit */
|
||||||
|
bandwidth_limit: string | null
|
||||||
|
/** Proxy Protocol 版本,对应 transport.proxyProtocolVersion */
|
||||||
|
proxy_protocol_version: string | null
|
||||||
|
/** 健康检查类型,对应 healthCheck.type:tcp / http */
|
||||||
|
health_check_type: string | null
|
||||||
|
/** 健康检查超时(秒),对应 healthCheck.timeoutSeconds */
|
||||||
|
health_check_timeout: number | null
|
||||||
|
/** 健康检查最大失败次数,对应 healthCheck.maxFailed */
|
||||||
|
health_check_max_failed: number | null
|
||||||
|
/** 健康检查间隔(秒),对应 healthCheck.intervalSeconds */
|
||||||
|
health_check_interval: number | null
|
||||||
|
/** 健康检查路径,对应 healthCheck.path */
|
||||||
|
health_check_path: string | null
|
||||||
|
/** 负载均衡组,对应 loadBalancer.group */
|
||||||
|
load_balancer_group: string | null
|
||||||
|
/** 负载均衡组密钥,对应 loadBalancer.groupKey */
|
||||||
|
load_balancer_group_key: string | null
|
||||||
|
/** 备注 */
|
||||||
|
remark: string | null
|
||||||
|
/** 排序(越小越靠前) */
|
||||||
|
sort: number
|
||||||
|
/** 是否置顶 */
|
||||||
|
pinned: boolean
|
||||||
|
/** 是否启用(可同时启用多个) */
|
||||||
|
enabled: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增 / 编辑隧道提交数据 */
|
||||||
|
export interface TunnelPayload {
|
||||||
|
/** 编辑时携带,新增时省略 */
|
||||||
|
id?: number
|
||||||
|
name: string
|
||||||
|
type: TunnelType
|
||||||
|
local_ip: string
|
||||||
|
local_port: number
|
||||||
|
remote_port?: number | null
|
||||||
|
custom_domains?: string | null
|
||||||
|
subdomain?: string | null
|
||||||
|
locations?: string | null
|
||||||
|
secret_key?: string | null
|
||||||
|
use_encryption: boolean
|
||||||
|
use_compression: boolean
|
||||||
|
bandwidth_limit?: string | null
|
||||||
|
proxy_protocol_version?: string | null
|
||||||
|
health_check_type?: string | null
|
||||||
|
health_check_timeout?: number | null
|
||||||
|
health_check_max_failed?: number | null
|
||||||
|
health_check_interval?: number | null
|
||||||
|
health_check_path?: string | null
|
||||||
|
load_balancer_group?: string | null
|
||||||
|
load_balancer_group_key?: string | null
|
||||||
|
remark?: string | null
|
||||||
|
sort: number
|
||||||
|
pinned: boolean
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 代理类型列表 */
|
||||||
|
export const TUNNEL_TYPES: TunnelType[] = [
|
||||||
|
"tcp",
|
||||||
|
"udp",
|
||||||
|
"http",
|
||||||
|
"https",
|
||||||
|
"stcp",
|
||||||
|
"xtcp",
|
||||||
|
"sudp",
|
||||||
|
"tcpmux",
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 需要填写远程端口的代理类型 */
|
||||||
|
export const TYPES_WITH_REMOTE_PORT: TunnelType[] = ["tcp", "udp"]
|
||||||
|
|
||||||
|
/** 需要填写自定义域名 / 子域名的代理类型 */
|
||||||
|
export const TYPES_WITH_DOMAIN: TunnelType[] = ["http", "https", "tcpmux"]
|
||||||
|
|
||||||
|
/** 需要填写密钥的代理类型 */
|
||||||
|
export const TYPES_WITH_SECRET: TunnelType[] = ["stcp", "xtcp", "sudp"]
|
||||||
+10
-3
@@ -1,7 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col p-6">
|
<div class="flex h-full flex-col gap-3">
|
||||||
<h2 class="mb-4 text-lg font-semibold">{{ t("menu.dashboard") }}</h2>
|
<!-- 顶部操作栏(独立磨砂卡片) -->
|
||||||
<a-result status="info" :sub-title="t('common.developing')" />
|
<div class="glass-panel glass-panel-flat flex shrink-0 items-center justify-between gap-3 px-5 py-3">
|
||||||
|
<h2 class="text-lg font-semibold">{{ t("menu.dashboard") }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区(独立磨砂卡片) -->
|
||||||
|
<div class="glass-panel glass-panel-flat min-h-0 flex-1 overflow-y-auto p-5">
|
||||||
|
<a-result status="info" :sub-title="t('common.developing')" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -1,7 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col p-6">
|
<div class="flex h-full flex-col gap-3">
|
||||||
<h2 class="mb-4 text-lg font-semibold">{{ t("menu.download") }}</h2>
|
<!-- 顶部操作栏(独立磨砂卡片) -->
|
||||||
<a-result status="info" :sub-title="t('common.developing')" />
|
<div class="glass-panel glass-panel-flat flex shrink-0 items-center justify-between gap-3 px-5 py-3">
|
||||||
|
<h2 class="text-lg font-semibold">{{ t("menu.download") }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区(独立磨砂卡片) -->
|
||||||
|
<div class="glass-panel glass-panel-flat min-h-0 flex-1 overflow-y-auto p-5">
|
||||||
|
<a-result status="info" :sub-title="t('common.developing')" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
+110
-3
@@ -1,12 +1,119 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col p-6">
|
<div class="flex h-full flex-col gap-3">
|
||||||
<h2 class="mb-4 text-lg font-semibold">{{ t("menu.servers") }}</h2>
|
<!-- 顶部操作栏(独立磨砂卡片) -->
|
||||||
<a-result status="info" :sub-title="t('common.developing')" />
|
<div class="glass-panel glass-panel-flat flex shrink-0 items-center justify-between gap-3 px-5 py-3">
|
||||||
|
<h2 class="text-lg font-semibold">{{ t("menu.servers") }}</h2>
|
||||||
|
<a-space>
|
||||||
|
<a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()">
|
||||||
|
{{ t("server.refresh") }}
|
||||||
|
</a-button>
|
||||||
|
<a-button type="primary" :icon="h(PlusOutlined)" @click="openCreate">
|
||||||
|
{{ t("server.add") }}
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区(独立磨砂卡片,内部滚动) -->
|
||||||
|
<div class="glass-panel glass-panel-flat min-h-0 flex-1 overflow-y-auto p-5">
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<a-empty v-if="!servers.length" class="mt-20" :description="t('server.empty')" />
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<ServerCard v-for="item in servers" :key="item.id" :server="item" @edit="openEdit(item)"
|
||||||
|
@delete="onDelete(item)" @toggle-enabled="(value) => onToggleEnabled(item, value)"
|
||||||
|
@toggle-pinned="(value) => onTogglePinned(item, value)" />
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增 / 编辑弹窗 -->
|
||||||
|
<ServerFormModal v-model:open="modalOpen" :server="editing" @saved="onSaved" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { h } from "vue"
|
||||||
|
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons-vue"
|
||||||
|
import { message } from "ant-design-vue"
|
||||||
import { useI18n } from "vue-i18n"
|
import { useI18n } from "vue-i18n"
|
||||||
|
import { deleteServer, getServerList, setServerEnabled, setServerPinned } from "@/api/server"
|
||||||
|
import ServerCard from "@/components/server/ServerCard.vue"
|
||||||
|
import ServerFormModal from "@/components/server/ServerFormModal.vue"
|
||||||
|
import type { Server } from "@/types/server"
|
||||||
|
import { isTauri } from "@/utils/window"
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const servers = ref<Server[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const modalOpen = ref(false)
|
||||||
|
const editing = ref<Server | null>(null)
|
||||||
|
|
||||||
|
function errMsg(e: unknown) {
|
||||||
|
return e instanceof Error ? e.message : String(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取服务器列表,silent 为 true 时静默刷新(不显示 loading) */
|
||||||
|
async function refresh(silent = false) {
|
||||||
|
if (!isTauri()) return
|
||||||
|
if (!silent) loading.value = true
|
||||||
|
try {
|
||||||
|
servers.value = (await getServerList()) ?? []
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e))
|
||||||
|
} finally {
|
||||||
|
if (!silent) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null
|
||||||
|
modalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(server: Server) {
|
||||||
|
editing.value = server
|
||||||
|
modalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSaved() {
|
||||||
|
refresh(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(server: Server) {
|
||||||
|
try {
|
||||||
|
await deleteServer(server.id)
|
||||||
|
message.success(t("server.deleteSuccess"))
|
||||||
|
refresh(true)
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTogglePinned(server: Server, value: boolean) {
|
||||||
|
try {
|
||||||
|
await setServerPinned(server.id, value)
|
||||||
|
server.pinned = value
|
||||||
|
refresh(true)
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e))
|
||||||
|
refresh(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggleEnabled(server: Server, value: boolean) {
|
||||||
|
try {
|
||||||
|
await setServerEnabled(server.id, value)
|
||||||
|
// 启用互斥:本地同步其它服务器状态
|
||||||
|
servers.value.forEach((item) => {
|
||||||
|
item.enabled = item.id === server.id ? value : false
|
||||||
|
})
|
||||||
|
refresh(true)
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e))
|
||||||
|
refresh(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => refresh())
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="glass-panel 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>
|
||||||
+113
-3
@@ -1,12 +1,122 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col p-6">
|
<div class="flex h-full flex-col gap-3">
|
||||||
<h2 class="mb-4 text-lg font-semibold">{{ t("menu.tunnels") }}</h2>
|
<!-- 顶部操作栏(独立磨砂卡片) -->
|
||||||
<a-result status="info" :sub-title="t('common.developing')" />
|
<div class="glass-panel glass-panel-flat flex shrink-0 items-center justify-between gap-3 px-5 py-3">
|
||||||
|
<h2 class="text-lg font-semibold">{{ t("menu.tunnels") }}</h2>
|
||||||
|
<a-space>
|
||||||
|
<a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()">
|
||||||
|
{{ t("tunnel.refresh") }}
|
||||||
|
</a-button>
|
||||||
|
<a-button type="primary" :icon="h(PlusOutlined)" @click="openCreate">
|
||||||
|
{{ t("tunnel.add") }}
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区(独立磨砂卡片,内部滚动) -->
|
||||||
|
<div class="glass-panel glass-panel-flat min-h-0 flex-1 overflow-y-auto p-5">
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<a-empty v-if="!tunnels.length" class="mt-20" :description="t('tunnel.empty')" />
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<TunnelCard
|
||||||
|
v-for="item in tunnels"
|
||||||
|
:key="item.id"
|
||||||
|
:tunnel="item"
|
||||||
|
@edit="openEdit(item)"
|
||||||
|
@delete="onDelete(item)"
|
||||||
|
@toggle-enabled="(value) => onToggleEnabled(item, value)"
|
||||||
|
@toggle-pinned="(value) => onTogglePinned(item, value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增 / 编辑弹窗 -->
|
||||||
|
<TunnelFormModal v-model:open="modalOpen" :tunnel="editing" @saved="onSaved" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { h } from "vue"
|
||||||
|
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons-vue"
|
||||||
|
import { message } from "ant-design-vue"
|
||||||
import { useI18n } from "vue-i18n"
|
import { useI18n } from "vue-i18n"
|
||||||
|
import { deleteTunnel, getTunnelList, setTunnelEnabled, setTunnelPinned } from "@/api/tunnel"
|
||||||
|
import TunnelCard from "@/components/tunnel/TunnelCard.vue"
|
||||||
|
import TunnelFormModal from "@/components/tunnel/TunnelFormModal.vue"
|
||||||
|
import type { Tunnel } from "@/types/tunnel"
|
||||||
|
import { isTauri } from "@/utils/window"
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const tunnels = ref<Tunnel[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const modalOpen = ref(false)
|
||||||
|
const editing = ref<Tunnel | null>(null)
|
||||||
|
|
||||||
|
function errMsg(e: unknown) {
|
||||||
|
return e instanceof Error ? e.message : String(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取隧道列表,silent 为 true 时静默刷新(不显示 loading) */
|
||||||
|
async function refresh(silent = false) {
|
||||||
|
if (!isTauri()) return
|
||||||
|
if (!silent) loading.value = true
|
||||||
|
try {
|
||||||
|
tunnels.value = (await getTunnelList()) ?? []
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e))
|
||||||
|
} finally {
|
||||||
|
if (!silent) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null
|
||||||
|
modalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(tunnel: Tunnel) {
|
||||||
|
editing.value = tunnel
|
||||||
|
modalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSaved() {
|
||||||
|
refresh(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(tunnel: Tunnel) {
|
||||||
|
try {
|
||||||
|
await deleteTunnel(tunnel.id)
|
||||||
|
message.success(t("tunnel.deleteSuccess"))
|
||||||
|
refresh(true)
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTogglePinned(tunnel: Tunnel, value: boolean) {
|
||||||
|
try {
|
||||||
|
await setTunnelPinned(tunnel.id, value)
|
||||||
|
tunnel.pinned = value
|
||||||
|
refresh(true)
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e))
|
||||||
|
refresh(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggleEnabled(tunnel: Tunnel, value: boolean) {
|
||||||
|
try {
|
||||||
|
await setTunnelEnabled(tunnel.id, value)
|
||||||
|
tunnel.enabled = value
|
||||||
|
refresh(true)
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e))
|
||||||
|
refresh(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => refresh())
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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