feat: 新增隧道管理功能,前后端完整实现

- 新增统一 ApiResponse 类型定义,与 Rust 端响应结构对齐
- 新增 Rust 后端隧道数据库表、模型、命令和 API 接口
- 新增前端隧道类型定义、API 调用、卡片组件和表单弹窗
- 新增中英双语隧道相关国际化文案
- 更新服务器类型定义,引入共享的 ApiResponse
This commit is contained in:
Yuhang Wu
2026-10-13 14:59:16 +08:00
parent 30ddf583e5
commit 3b153e32c9
15 changed files with 1556 additions and 10 deletions
+12 -1
View File
@@ -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 开发隧道
#### 3.2 开发隧道列表功能
- Tunnels.vue页面右上方一个增加和刷新按钮,点击增加,弹窗填写隧道表单信息
- 隧道信息有名称、代理类型、本地IP、本地端口、远程端口、是否置顶、是否启用等,这些是我之前使用Frpc的时候常用的配置字段,你需要按照Frpc配置的需要,决定是否增加新的字段,表单需要做必填检验,如果你增加的是不常用的字段或者是更高级别的设置,可以用折叠面板作为高级设置,将这些更高级的字段放入
- 页面展示已经配置的隧道列表,列表隧道以卡片形式展示,卡片有开关可以直接操作是否置顶和是否启用,隧道可同时启用多个,另外还需要有编辑和删除,新增和编辑使用同一个表单
- 隧道信息在Rust端使用SQLite存储,Rust已经加入相关的sqlx依赖等,后端操作返回前端务必使用ApiResponse统一返回格式,前端也需要做好对应的类型匹配
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
+326
View File
@@ -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()),
},
)
}
+4
View File
@@ -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)]
+92
View File
@@ -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<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,
}
+37
View File
@@ -39,5 +39,42 @@ pub async fn init_pool(app: &AppHandle) -> Result<SqlitePool, sqlx::Error> {
.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)
}
+47
View File
@@ -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 })
}
+125
View File
@@ -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>
+524
View File
@@ -0,0 +1,524 @@
<template>
<a-modal
:open="open"
:title="isEdit ? t('tunnel.form.editTitle') : t('tunnel.form.addTitle')"
:confirm-loading="saving"
:mask-closable="false"
:width="640"
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>
+74
View File
@@ -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",
+74
View File
@@ -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": "确定",
+10
View File
@@ -0,0 +1,10 @@
/**
* 与 Rust 端统一响应结构 `models::ApiResponse<T>` 一一对应的通用类型。
*/
export interface ApiResponse<T = unknown> {
success: boolean
code: number
msg: string
data: T | null
}
+4
View File
@@ -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']
}
}
+1 -7
View File
@@ -2,13 +2,7 @@
* 服务器相关的类型定义,字段与 Rust 端 `models::Server` / `models::ServerPayload` 一一对应。
*/
/** 统一响应结构,对应 Rust 端 `models::ApiResponse<T>` */
export interface ApiResponse<T = unknown> {
success: boolean
code: number
msg: string
data: T | null
}
export type { ApiResponse } from "./api"
/** 传输协议,对应 frpc 的 transport.protocol */
export type ServerProtocol = "tcp" | "kcp" | "quic" | "websocket"
+121
View File
@@ -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"]
+105 -2
View File
@@ -3,17 +3,120 @@
<!-- 顶部操作栏(独立磨砂卡片) -->
<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-result status="info" :sub-title="t('common.developing')" />
<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>
</template>
<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 { 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 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>