From 3b153e32c9b3e58a5ed8c2c90c001a0e9b0eee9d Mon Sep 17 00:00:00 2001 From: Yuhang Wu Date: Tue, 13 Oct 2026 14:59:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=9A=A7=E9=81=93?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=89=8D=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增统一 ApiResponse 类型定义,与 Rust 端响应结构对齐 - 新增 Rust 后端隧道数据库表、模型、命令和 API 接口 - 新增前端隧道类型定义、API 调用、卡片组件和表单弹窗 - 新增中英双语隧道相关国际化文案 - 更新服务器类型定义,引入共享的 ApiResponse --- docs/Frpilot开发提示词.md | 13 +- src-tauri/src/commands/tunnel.rs | 326 ++++++++++++++ src-tauri/src/lib.rs | 4 + src-tauri/src/models.rs | 92 ++++ src-tauri/src/services/db.rs | 37 ++ src/api/tunnel.ts | 47 ++ src/components/tunnel/TunnelCard.vue | 125 ++++++ src/components/tunnel/TunnelFormModal.vue | 524 ++++++++++++++++++++++ src/i18n/locales/en-US.json | 74 +++ src/i18n/locales/zh-CN.json | 74 +++ src/types/api.ts | 10 + src/types/components.d.ts | 4 + src/types/server.ts | 8 +- src/types/tunnel.ts | 121 +++++ src/views/Tunnels.vue | 107 ++++- 15 files changed, 1556 insertions(+), 10 deletions(-) create mode 100644 src/api/tunnel.ts create mode 100644 src/components/tunnel/TunnelCard.vue create mode 100644 src/components/tunnel/TunnelFormModal.vue create mode 100644 src/types/api.ts create mode 100644 src/types/tunnel.ts diff --git a/docs/Frpilot开发提示词.md b/docs/Frpilot开发提示词.md index b10c861..749eb51 100644 --- a/docs/Frpilot开发提示词.md +++ b/docs/Frpilot开发提示词.md @@ -45,6 +45,10 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下: - 而是在每一个页面单独设置,现在先以服务器配置页面为例,按照我图片中红色框选部分作为区域分割 - 左侧菜单栏已经是glass-panel样式,现在需要在main区域设置成圆角卡片磨砂玻璃效果 +#### 2.3 弹窗修改 + +现在的弹窗里面内容比较多的时候会出现弹窗高度超出窗口高度,然后整个弹窗可以滚动,我想要改成弹窗高度不能超过窗口高度的80%,然后弹窗内容较多的时候,滚动仅限于弹窗里面可以进行滚动,按照我的要求修改一下 + ### 3. 功能开发 #### 3.1 开发服务器配置功能 @@ -56,4 +60,11 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下: 开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发 -3.2 开发隧道 \ No newline at end of file +#### 3.2 开发隧道列表功能 + +- Tunnels.vue页面右上方一个增加和刷新按钮,点击增加,弹窗填写隧道表单信息 +- 隧道信息有名称、代理类型、本地IP、本地端口、远程端口、是否置顶、是否启用等,这些是我之前使用Frpc的时候常用的配置字段,你需要按照Frpc配置的需要,决定是否增加新的字段,表单需要做必填检验,如果你增加的是不常用的字段或者是更高级别的设置,可以用折叠面板作为高级设置,将这些更高级的字段放入 +- 页面展示已经配置的隧道列表,列表隧道以卡片形式展示,卡片有开关可以直接操作是否置顶和是否启用,隧道可同时启用多个,另外还需要有编辑和删除,新增和编辑使用同一个表单 +- 隧道信息在Rust端使用SQLite存储,Rust已经加入相关的sqlx依赖等,后端操作返回前端务必使用ApiResponse统一返回格式,前端也需要做好对应的类型匹配 + +开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发 \ No newline at end of file diff --git a/src-tauri/src/commands/tunnel.rs b/src-tauri/src/commands/tunnel.rs index e69de29..1548d87 100644 --- a/src-tauri/src/commands/tunnel.rs +++ b/src-tauri/src/commands/tunnel.rs @@ -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 { + 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) -> Option { + value + .as_ref() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +/// 归一化枚举值:合法则返回清理后的值,否则返回 None +fn normalize_choice(value: &Option, allowed: &[&str]) -> Option { + clean(value) + .map(|v| v.to_ascii_lowercase()) + .filter(|v| allowed.contains(&v.as_str())) +} + +/// 仅保留正整数,非法值(<= 0)返回 None +fn positive(value: Option) -> Option { + value.filter(|v| *v > 0) +} + +async fn fetch_all(pool: &SqlitePool) -> Result, 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 { + 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>, 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, 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 { + 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, 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, 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, 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, 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, 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, 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()), + }, + ) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 09a08af..f44c0a6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,10 @@ 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)] diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 11e7ed7..bb78bb5 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -95,3 +95,95 @@ pub struct ServerPayload { 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, + /// 自定义域名,对应 customDomains(多个以英文逗号分隔) + pub custom_domains: Option, + /// 子域名,对应 subdomain + pub subdomain: Option, + /// URL 前缀,对应 locations(多个以英文逗号分隔) + pub locations: Option, + /// 密钥,对应 secretKey(stcp / xtcp / sudp) + pub secret_key: Option, + /// 传输加密,对应 transport.useEncryption + pub use_encryption: bool, + /// 传输压缩,对应 transport.useCompression + pub use_compression: bool, + /// 带宽限制,对应 transport.bandwidthLimit + pub bandwidth_limit: Option, + /// Proxy Protocol 版本,对应 transport.proxyProtocolVersion + pub proxy_protocol_version: Option, + /// 健康检查类型,对应 healthCheck.type:tcp / http + pub health_check_type: Option, + /// 健康检查超时(秒),对应 healthCheck.timeoutSeconds + pub health_check_timeout: Option, + /// 健康检查最大失败次数,对应 healthCheck.maxFailed + pub health_check_max_failed: Option, + /// 健康检查间隔(秒),对应 healthCheck.intervalSeconds + pub health_check_interval: Option, + /// 健康检查路径,对应 healthCheck.path + pub health_check_path: Option, + /// 负载均衡组,对应 loadBalancer.group + pub load_balancer_group: Option, + /// 负载均衡组密钥,对应 loadBalancer.groupKey + pub load_balancer_group_key: Option, + /// 备注 + pub remark: Option, + /// 排序(越小越靠前) + 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, + pub name: String, + #[serde(rename = "type")] + pub proxy_type: String, + pub local_ip: String, + pub local_port: i32, + pub remote_port: Option, + pub custom_domains: Option, + pub subdomain: Option, + pub locations: Option, + pub secret_key: Option, + pub use_encryption: bool, + pub use_compression: bool, + pub bandwidth_limit: Option, + pub proxy_protocol_version: Option, + pub health_check_type: Option, + pub health_check_timeout: Option, + pub health_check_max_failed: Option, + pub health_check_interval: Option, + pub health_check_path: Option, + pub load_balancer_group: Option, + pub load_balancer_group_key: Option, + pub remark: Option, + pub sort: i32, + pub pinned: bool, + pub enabled: bool, +} + diff --git a/src-tauri/src/services/db.rs b/src-tauri/src/services/db.rs index 752dfd6..081690c 100644 --- a/src-tauri/src/services/db.rs +++ b/src-tauri/src/services/db.rs @@ -39,5 +39,42 @@ pub async fn init_pool(app: &AppHandle) -> Result { .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) } diff --git a/src/api/tunnel.ts b/src/api/tunnel.ts new file mode 100644 index 0000000..f3404ee --- /dev/null +++ b/src/api/tunnel.ts @@ -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`,业务失败时 + * `success === false`,此处统一抛出异常,交由调用方提示。 + */ +async function call(command: string, args?: Record): Promise { + const res = await invoke>(command, args) + if (!res?.success) { + throw new Error(res?.msg || "操作失败") + } + return res.data as T +} + +/** 获取隧道列表 */ +export function getTunnelList() { + return call("get_tunnel_list") +} + +/** 新增隧道 */ +export function createTunnel(payload: TunnelPayload) { + return call("create_tunnel", { payload }) +} + +/** 编辑隧道 */ +export function updateTunnel(payload: TunnelPayload) { + return call("update_tunnel", { payload }) +} + +/** 删除隧道 */ +export function deleteTunnel(id: number) { + return call("delete_tunnel", { id }) +} + +/** 启用 / 禁用隧道 */ +export function setTunnelEnabled(id: number, enabled: boolean) { + return call("set_tunnel_enabled", { id, enabled }) +} + +/** 置顶 / 取消置顶隧道 */ +export function setTunnelPinned(id: number, pinned: boolean) { + return call("set_tunnel_pinned", { id, pinned }) +} diff --git a/src/components/tunnel/TunnelCard.vue b/src/components/tunnel/TunnelCard.vue new file mode 100644 index 0000000..c1b320e --- /dev/null +++ b/src/components/tunnel/TunnelCard.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/src/components/tunnel/TunnelFormModal.vue b/src/components/tunnel/TunnelFormModal.vue new file mode 100644 index 0000000..64aee62 --- /dev/null +++ b/src/components/tunnel/TunnelFormModal.vue @@ -0,0 +1,524 @@ + + + diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index b4b1051..2bdb3e2 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -88,6 +88,80 @@ "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": { "developing": "This feature is under construction", "confirm": "OK", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 26e85a9..317ddf2 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -88,6 +88,80 @@ "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": { "developing": "功能开发中,敬请期待", "confirm": "确定", diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..4235212 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,10 @@ +/** + * 与 Rust 端统一响应结构 `models::ApiResponse` 一一对应的通用类型。 + */ + +export interface ApiResponse { + success: boolean + code: number + msg: string + data: T | null +} diff --git a/src/types/components.d.ts b/src/types/components.d.ts index 1b1708a..7df223c 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -14,6 +14,8 @@ declare module 'vue' { 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'] ADivider: typeof import('ant-design-vue/es')['Divider'] ADropdown: typeof import('ant-design-vue/es')['Dropdown'] @@ -44,6 +46,8 @@ declare module 'vue' { ServerCard: typeof import('./../components/server/ServerCard.vue')['default'] ServerFormModal: typeof import('./../components/server/ServerFormModal.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'] } } diff --git a/src/types/server.ts b/src/types/server.ts index 407bf14..8b028d7 100644 --- a/src/types/server.ts +++ b/src/types/server.ts @@ -2,13 +2,7 @@ * 服务器相关的类型定义,字段与 Rust 端 `models::Server` / `models::ServerPayload` 一一对应。 */ -/** 统一响应结构,对应 Rust 端 `models::ApiResponse` */ -export interface ApiResponse { - success: boolean - code: number - msg: string - data: T | null -} +export type { ApiResponse } from "./api" /** 传输协议,对应 frpc 的 transport.protocol */ export type ServerProtocol = "tcp" | "kcp" | "quic" | "websocket" diff --git a/src/types/tunnel.ts b/src/types/tunnel.ts new file mode 100644 index 0000000..cb8de96 --- /dev/null +++ b/src/types/tunnel.ts @@ -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"] diff --git a/src/views/Tunnels.vue b/src/views/Tunnels.vue index 2e86f99..6744891 100644 --- a/src/views/Tunnels.vue +++ b/src/views/Tunnels.vue @@ -3,17 +3,120 @@

{{ t("menu.tunnels") }}

+ + + {{ t("tunnel.refresh") }} + + + {{ t("tunnel.add") }} + +
- +
- + + + +
+ +
+
+ + +