From cdb1296462e0606340641abf525b183aa7d1c51b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Fri, 9 Jan 2026 19:20:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=98=B6=E6=AE=B5=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/webserver/nginx/proxy.go | 5 +- pkg/webserver/types/proxy.go | 8 +- web/package.json | 3 +- web/pnpm-lock.yaml | 18 ++ web/src/views/toolbox/SshView.vue | 2 +- web/src/views/website/EditView.vue | 473 +++++++++++++++++++++++++++++ 6 files changed, 503 insertions(+), 6 deletions(-) diff --git a/pkg/webserver/nginx/proxy.go b/pkg/webserver/nginx/proxy.go index 742b766e..658dbfe5 100644 --- a/pkg/webserver/nginx/proxy.go +++ b/pkg/webserver/nginx/proxy.go @@ -76,6 +76,7 @@ func parseProxyFile(filePath string) (*types.Proxy, error) { proxy := &types.Proxy{ Location: strings.TrimSpace(matches[1]), + Resolver: []string{}, Replaces: make(map[string]string), } @@ -240,8 +241,10 @@ func generateProxyConfig(proxy types.Proxy) string { sb.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n") // SNI 配置 - if proxy.SNI != "" { + if strings.HasPrefix(proxy.Pass, "https") { sb.WriteString(" proxy_ssl_server_name on;\n") + } + if proxy.SNI != "" { sb.WriteString(fmt.Sprintf(" proxy_ssl_name %s;\n", proxy.SNI)) } diff --git a/pkg/webserver/types/proxy.go b/pkg/webserver/types/proxy.go index 0b0cda98..126596b1 100644 --- a/pkg/webserver/types/proxy.go +++ b/pkg/webserver/types/proxy.go @@ -17,7 +17,9 @@ type Proxy struct { // Upstream 上游服务器配置 type Upstream struct { - Servers map[string]string `form:"servers" json:"servers" validate:"required"` // 上游服务器及配置,如: map["server1"] = "weight=5 resolve" - Algo string `form:"algo" json:"algo"` // 负载均衡算法,如: "least_conn", "ip_hash" - Keepalive int `form:"keepalive" json:"keepalive"` // 保持连接数,如: 32 + Servers map[string]string `form:"servers" json:"servers" validate:"required"` // 上游服务器及配置,如: map["server1"] = "weight=5 resolve" + Algo string `form:"algo" json:"algo"` // 负载均衡算法,如: "least_conn", "ip_hash" + Keepalive int `form:"keepalive" json:"keepalive"` // 保持连接数,如: 32 + Resolver []string `form:"resolver" json:"resolver"` // 自定义 DNS 解析器配置,如: ["8.8.8.8", "ipv6=off"] + ResolverTimeout time.Duration `form:"resolver_timeout" json:"resolver_timeout"` // DNS 解析超时时间,如: 5 * time.Second } diff --git a/web/package.json b/web/package.json index 09d11837..387606ad 100644 --- a/web/package.json +++ b/web/package.json @@ -53,7 +53,8 @@ "vue": "^3.5.22", "vue-echarts": "^8.0.1", "vue-router": "^4.6.3", - "vue3-gettext": "4.0.0-beta.1" + "vue3-gettext": "4.0.0-beta.1", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@iconify-json/mdi": "^1.2.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index f9cccc6b..a29607a2 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: vue3-gettext: specifier: 4.0.0-beta.1 version: 4.0.0-beta.1(@vue/compiler-sfc@3.5.26)(vue@3.5.26(typescript@5.9.3)) + vuedraggable: + specifier: ^4.1.0 + version: 4.1.0(vue@3.5.26(typescript@5.9.3)) devDependencies: '@iconify-json/mdi': specifier: ^1.2.3 @@ -2951,6 +2954,9 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} + sortablejs@1.14.0: + resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3373,6 +3379,11 @@ packages: typescript: optional: true + vuedraggable@4.1.0: + resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==} + peerDependencies: + vue: ^3.0.1 + vueuc@0.4.65: resolution: {integrity: sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==} peerDependencies: @@ -6564,6 +6575,8 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + sortablejs@1.14.0: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -7063,6 +7076,11 @@ snapshots: optionalDependencies: typescript: 5.9.3 + vuedraggable@4.1.0(vue@3.5.26(typescript@5.9.3)): + dependencies: + sortablejs: 1.14.0 + vue: 3.5.26(typescript@5.9.3) + vueuc@0.4.65(vue@3.5.26(typescript@5.9.3)): dependencies: '@css-render/vue3-ssr': 0.15.14(vue@3.5.26(typescript@5.9.3)) diff --git a/web/src/views/toolbox/SshView.vue b/web/src/views/toolbox/SshView.vue index fac4b888..1505ce95 100644 --- a/web/src/views/toolbox/SshView.vue +++ b/web/src/views/toolbox/SshView.vue @@ -285,7 +285,7 @@ onMounted(() => { {{ $gettext( - 'It is recommended to use a complex password. Save after modification. Refresh will clear the password field.' + 'It is recommended to use a complex password. Refresh will clear the password field.' ) }} diff --git a/web/src/views/website/EditView.vue b/web/src/views/website/EditView.vue index e6cbd0ef..2563490d 100644 --- a/web/src/views/website/EditView.vue +++ b/web/src/views/website/EditView.vue @@ -6,6 +6,7 @@ defineOptions({ import type { MessageReactive } from 'naive-ui' import { NButton } from 'naive-ui' import { useGettext } from 'vue3-gettext' +import draggable from 'vuedraggable' import cert from '@/api/panel/cert' import home from '@/api/panel/home' @@ -179,6 +180,218 @@ const toggleArg = (args: string[], arg: string, checked: boolean) => { const hasArg = (args: string[], arg: string) => { return args.includes(arg) } + +// ========== Upstreams 相关 ========== +const upstreamAlgoOptions = [ + { label: $gettext('Round Robin (default)'), value: '' }, + { label: 'least_conn', value: 'least_conn' }, + { label: 'ip_hash', value: 'ip_hash' }, + { label: 'hash', value: 'hash' }, + { label: 'random', value: 'random' } +] + +// 添加新的上游 +const addUpstream = () => { + const name = `backend_${Date.now()}` + if (!setting.value.upstreams) { + setting.value.upstreams = {} + } + setting.value.upstreams[name] = { + servers: {}, + algo: '', + keepalive: 32 + } +} + +// 删除上游 +const removeUpstream = (name: string) => { + if (setting.value.upstreams) { + delete setting.value.upstreams[name] + } +} + +// 重命名上游 +const renameUpstream = (oldName: string, newName: string) => { + if (!setting.value.upstreams || oldName === newName) return + if (setting.value.upstreams[newName]) { + window.$message.error($gettext('Upstream name already exists')) + return + } + const upstream = setting.value.upstreams[oldName] + delete setting.value.upstreams[oldName] + setting.value.upstreams[newName] = upstream +} + +// 为上游添加服务器 +const addServerToUpstream = (upstreamName: string) => { + if (!setting.value.upstreams[upstreamName].servers) { + setting.value.upstreams[upstreamName].servers = {} + } + setting.value.upstreams[upstreamName].servers[ + `127.0.0.1:${8080 + Object.keys(setting.value.upstreams[upstreamName].servers).length}` + ] = '' +} + +// ========== Proxies 相关 ========== +// Location 匹配类型选项 +const locationMatchTypes = [ + { label: $gettext('Exact Match (=)'), value: '=' }, + { label: $gettext('Priority Prefix Match (^~)'), value: '^~' }, + { label: $gettext('Prefix Match'), value: '' }, + { label: $gettext('Case-sensitive Regex (~)'), value: '~' }, + { label: $gettext('Case-insensitive Regex (~*)'), value: '~*' } +] + +// 解析 location 字符串,返回匹配类型和表达式 +const parseLocation = (location: string): { type: string; expression: string } => { + if (!location) return { type: '', expression: '/' } + + // 精确匹配 = + if (location.startsWith('= ')) { + return { type: '=', expression: location.slice(2) } + } + // 优先前缀匹配 ^~ + if (location.startsWith('^~ ')) { + return { type: '^~', expression: location.slice(3) } + } + // 不区分大小写正则 ~* + if (location.startsWith('~* ')) { + return { type: '~*', expression: location.slice(3) } + } + // 区分大小写正则 ~ + if (location.startsWith('~ ')) { + return { type: '~', expression: location.slice(2) } + } + // 普通前缀匹配 + return { type: '', expression: location } +} + +// 组合 location 字符串 +const buildLocation = (type: string, expression: string): string => { + if (!expression) expression = '/' + if (type === '') return expression + return `${type} ${expression}` +} + +// 从 URL 提取主机名 +const extractHostFromUrl = (url: string): string => { + try { + const urlObj = new URL(url) + return urlObj.host + } catch { + // 如果不是有效的 URL,尝试简单提取 + const match = url.match(/^https?:\/\/([^/]+)/) + return match ? match[1] : '' + } +} + +// 添加新的代理 +const addProxy = () => { + if (!setting.value.proxies) { + setting.value.proxies = [] + } + setting.value.proxies.push({ + location: '/', + pass: 'http://127.0.0.1:8080', + host: '$host', + sni: '', + cache: false, + buffering: true, + resolver: [], + resolver_timeout: 5 * 1000000000, // 5秒,以纳秒为单位 + replaces: {} + }) +} + +// 删除代理 +const removeProxy = (index: number) => { + if (setting.value.proxies) { + setting.value.proxies.splice(index, 1) + } +} + +// 处理 Proxy Pass 变化,自动更新 Host +const handleProxyPassChange = (proxy: any, value: string) => { + proxy.pass = value + // 如果 host 是 $host 或为空,则自动提取 + if (!proxy.host || proxy.host === '$host') { + const extracted = extractHostFromUrl(value) + // 只有当提取到的不是 IP 地址时才设置 + if ( + extracted && + !/^\d+\.\d+\.\d+\.\d+(:\d+)?$/.test(extracted) && + !/^localhost(:\d+)?$/i.test(extracted) + ) { + proxy.host = extracted + } else { + proxy.host = '$host' + } + } +} + +// 更新 Location 匹配类型 +const updateLocationType = (proxy: any, type: string) => { + const parsed = parseLocation(proxy.location) + proxy.location = buildLocation(type, parsed.expression) +} + +// 更新 Location 表达式 +const updateLocationExpression = (proxy: any, expression: string) => { + const parsed = parseLocation(proxy.location) + proxy.location = buildLocation(parsed.type, expression) +} + +// ========== 时间单位相关 ========== +// Go time.Duration 在 JSON 中以纳秒表示 +const NANOSECOND = 1 +const SECOND = 1000000000 * NANOSECOND +const MINUTE = 60 * SECOND +const HOUR = 60 * MINUTE + +// 时间单位选项 +const timeUnitOptions = [ + { label: $gettext('Seconds'), value: 's' }, + { label: $gettext('Minutes'), value: 'm' }, + { label: $gettext('Hours'), value: 'h' } +] + +// 从纳秒解析为 {value, unit} 格式 +const parseDuration = (ns: number): { value: number; unit: string } => { + if (!ns || ns <= 0) return { value: 5, unit: 's' } + + if (ns >= HOUR && ns % HOUR === 0) { + return { value: ns / HOUR, unit: 'h' } + } + if (ns >= MINUTE && ns % MINUTE === 0) { + return { value: ns / MINUTE, unit: 'm' } + } + return { value: Math.floor(ns / SECOND), unit: 's' } +} + +// 将 {value, unit} 转换为纳秒 +const buildDuration = (value: number, unit: string): number => { + if (!value || value <= 0) value = 5 + switch (unit) { + case 'h': + return value * HOUR + case 'm': + return value * MINUTE + default: + return value * SECOND + } +} + +// 更新超时时间值 +const updateTimeoutValue = (proxy: any, value: number) => { + const parsed = parseDuration(proxy.resolver_timeout) + proxy.resolver_timeout = buildDuration(value, parsed.unit) +} + +// 更新超时时间单位 +const updateTimeoutUnit = (proxy: any, unit: string) => { + const parsed = parseDuration(proxy.resolver_timeout) + proxy.resolver_timeout = buildDuration(parsed.value, unit) +}