feat: 新增服务器配置管理功能
- 新增 SQLite 服务器表结构及 Rust 后端 CRUD 命令 - 新增前端 API 接口、类型定义及国际化文案 - 新增服务器卡片组件及表单弹窗组件 - 实现服务器列表展示、新增、编辑、删除、置顶、启用等功能 - 处理启用服务器互斥逻辑及前端表单校验
This commit is contained in:
@@ -36,3 +36,14 @@
|
|||||||
- 背景图片使用assets/images目录中的bg-1.png、bg-2.jpg、bg-3.png作为窗口背景图片,在设置页面里面可以选择其中一个切换页面背景,注意尽可能保持原图片的比例大小不发生拉伸
|
- 背景图片使用assets/images目录中的bg-1.png、bg-2.jpg、bg-3.png作为窗口背景图片,在设置页面里面可以选择其中一个切换页面背景,注意尽可能保持原图片的比例大小不发生拉伸
|
||||||
|
|
||||||
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||||
|
|
||||||
|
### 3. 功能开发
|
||||||
|
|
||||||
|
#### 3.1 开发服务器配置功能
|
||||||
|
|
||||||
|
- Servers.vue页面右上方一个增加和刷新按钮,点击增加,弹窗填写服务器表单信息
|
||||||
|
- 服务器信息有名称、IP地址、端口号(默认7000)、备注、排序、是否置顶、是否启用等,这些是我能想到的字段,你需要按照Frpc配置的需要,决定是否增加新的字段,表单需要做必填检验
|
||||||
|
- 页面展示已经配置的服务器列表,列表服务器以卡片形式展示,卡片有开关可以直接操作是否置顶和是否启用,是否启用选项只能启用一个,另外还需要有编辑和删除,新增和编辑使用同一个表单
|
||||||
|
- 服务器信息在Rust端使用SQLite存储,Rust已经加入相关的sqlx依赖等,后端操作返回前端务必使用ApiResponse统一返回格式,前端也需要做好对应的类型匹配
|
||||||
|
|
||||||
|
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||||
@@ -2,10 +2,292 @@ use sqlx::SqlitePool;
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
use tauri_helper::auto_collect_command;
|
use tauri_helper::auto_collect_command;
|
||||||
|
|
||||||
use crate::models::{ApiResponse};
|
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]
|
#[tauri::command]
|
||||||
#[auto_collect_command]
|
#[auto_collect_command]
|
||||||
pub async fn get_server_list(state: State<'_, SqlitePool>) -> Result<ApiResponse<()>, String> {
|
pub async fn get_server_list(
|
||||||
Ok(ApiResponse::success_empty())
|
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()),
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,10 @@ mod services;
|
|||||||
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
use crate::commands::server::get_server_list;
|
use crate::commands::server::{
|
||||||
|
create_server, delete_server, get_server_list, set_server_enabled, set_server_pinned,
|
||||||
|
update_server,
|
||||||
|
};
|
||||||
use crate::services::db::init_pool;
|
use crate::services::db::init_pool;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct PageResult<T> {
|
pub struct PageResult<T> {
|
||||||
@@ -48,3 +49,49 @@ impl ApiResponse<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 服务器配置(对应 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,5 +16,28 @@ pub async fn init_pool(app: &AppHandle) -> Result<SqlitePool, sqlx::Error> {
|
|||||||
.connect_with(options)
|
.connect_with(options)
|
||||||
.await?;
|
.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?;
|
||||||
|
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -49,6 +49,13 @@ body {
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Antd 按钮:图标与文字按 flex 垂直居中(Tailwind preflight 会把 svg 设为 block,导致行内对齐偏移) */
|
||||||
|
.ant-btn {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* 窗口圆角固定 8px,与 Windows 11 系统默认圆角保持一致 */
|
/* 窗口圆角固定 8px,与 Windows 11 系统默认圆角保持一致 */
|
||||||
#app {
|
#app {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
@@ -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,261 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
:title="isEdit ? t('server.form.editTitle') : t('server.form.addTitle')"
|
||||||
|
:confirm-loading="saving"
|
||||||
|
:mask-closable="false"
|
||||||
|
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>
|
||||||
@@ -54,7 +54,44 @@
|
|||||||
"bg3": "Background 3"
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"developing": "This feature is under construction"
|
"developing": "This feature is under construction",
|
||||||
|
"confirm": "OK",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,44 @@
|
|||||||
"bg3": "背景三"
|
"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 之间"
|
||||||
|
}
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"developing": "功能开发中,敬请期待"
|
"developing": "功能开发中,敬请期待",
|
||||||
|
"confirm": "确定",
|
||||||
|
"cancel": "取消",
|
||||||
|
"save": "保存"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+19
@@ -13,17 +13,36 @@ 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']
|
ACard: typeof import('ant-design-vue/es')['Card']
|
||||||
|
ACol: typeof import('ant-design-vue/es')['Col']
|
||||||
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
|
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
|
||||||
ADivider: typeof import('ant-design-vue/es')['Divider']
|
ADivider: typeof import('ant-design-vue/es')['Divider']
|
||||||
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
|
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
|
||||||
|
AEmpty: typeof import('ant-design-vue/es')['Empty']
|
||||||
|
AForm: typeof import('ant-design-vue/es')['Form']
|
||||||
|
AFormItem: typeof import('ant-design-vue/es')['FormItem']
|
||||||
|
AInput: typeof import('ant-design-vue/es')['Input']
|
||||||
|
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']
|
||||||
|
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']
|
ASegmented: typeof import('ant-design-vue/es')['Segmented']
|
||||||
|
ASelect: typeof import('ant-design-vue/es')['Select']
|
||||||
|
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']
|
||||||
WindowsTitleBar: typeof import('./../components/WindowsTitleBar.vue')['default']
|
WindowsTitleBar: typeof import('./../components/WindowsTitleBar.vue')['default']
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* 服务器相关的类型定义,字段与 Rust 端 `models::Server` / `models::ServerPayload` 一一对应。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 统一响应结构,对应 Rust 端 `models::ApiResponse<T>` */
|
||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: T | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 传输协议,对应 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" },
|
||||||
|
]
|
||||||
+108
-3
@@ -1,12 +1,117 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col p-6">
|
<div class="flex h-full flex-col gap-4 p-6">
|
||||||
<h2 class="mb-4 text-lg font-semibold">{{ t("menu.servers") }}</h2>
|
<!-- 顶部:标题 + 操作按钮 -->
|
||||||
<a-result status="info" :sub-title="t('common.developing')" />
|
<div class="flex items-center justify-between gap-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>
|
||||||
|
|
||||||
|
<!-- 服务器列表 -->
|
||||||
|
<a-spin :spinning="loading" class="min-h-0 flex-1">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 新增 / 编辑弹窗 -->
|
||||||
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user