From 021d01247846519c0db04e2b226b8b3df77155b0 Mon Sep 17 00:00:00 2001 From: opsx-tools Date: Thu, 23 Jul 2026 03:01:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A8=E5=8D=8F=E8=AE=AE=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E5=AE=89=E8=A3=85=20+=20=E5=9B=BD=E5=86=85GitHub?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E5=8A=A0=E9=80=9F=20+=20=E4=B8=AD=E8=8B=B1?= =?UTF-8?q?=E6=96=87i18n=E5=9B=BD=E9=99=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - binmgr.go: 新增 detectChina() 国内/海外自动判断(ipinfo.io) - binmgr.go: 国内自动走 gh-proxy.com 镜像加速 GitHub 下载 - binmgr.go: 新增 InstallViaPackageManager() 包管理器一键安装 - binmgr.go: 新增 GetDeploySpec()/ProtocolAlias() 导出函数 - api.go: installBinary 支持全部7个协议(FRP/WireGuard/OpenVPN/IPSec/IKEv2/L2TP/StrongSwan) - api.go: 策略: GitHub下载失败 → 自动降级包管理器安装 前端: - 安装 vue-i18n@9, 支持中英文切换 - 新增 locales/zh-CN.json + en-US.json (548个翻译key, 17个模块) - 新增 i18n.js 配置, localStorage 持久化语言偏好 - Layout.vue 右上角语言切换下拉菜单 - 所有17个Vue文件中文文本转为 $t() 调用 - Element Plus 组件语言跟随切换 - TunnelWizard: 所有协议显示「一键安装」按钮(带loading) - 前端构建零错误 --- backend/internal/api/api.go | 43 +- backend/internal/binmgr/binmgr.go | 315 ++++++++++-- frontend/package-lock.json | 66 +++ frontend/package.json | 1 + frontend/src/components/ProtocolIcon.vue | 13 +- frontend/src/i18n.js | 17 + frontend/src/locales/en-US.json | 584 +++++++++++++++++++++++ frontend/src/locales/zh-CN.json | 584 +++++++++++++++++++++++ frontend/src/main.js | 8 +- frontend/src/router.js | 24 +- frontend/src/views/Dashboard.vue | 64 +-- frontend/src/views/IpsecPeer.vue | 100 ++-- frontend/src/views/Layout.vue | 115 +++-- frontend/src/views/Login.vue | 50 +- frontend/src/views/MfaVerify.vue | 20 +- frontend/src/views/Nodes.vue | 116 ++--- frontend/src/views/NotFound.vue | 4 +- frontend/src/views/Plugins.vue | 68 +-- frontend/src/views/Sessions.vue | 76 +-- frontend/src/views/Settings.vue | 85 ++-- frontend/src/views/TunnelEditor.vue | 34 +- frontend/src/views/TunnelWizard.vue | 106 ++-- frontend/src/views/Tunnels.vue | 202 ++++---- frontend/src/views/Users.vue | 165 +++---- frontend/src/views/VPNUsers.vue | 199 ++++---- 25 files changed, 2313 insertions(+), 746 deletions(-) create mode 100644 frontend/src/i18n.js create mode 100644 frontend/src/locales/en-US.json create mode 100644 frontend/src/locales/zh-CN.json diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index af338b0..2c398ac 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "log" "net" "net/http" "os" @@ -610,17 +611,47 @@ func (s *Server) listBinaryStatus(c *gin.Context) { // installBinary 下载部署协议二进制(FRP / OpenVPN 支持从 GitHub Release 下载) func (s *Server) installBinary(c *gin.Context) { name := c.Param("name") - if name != "frp" && name != "openvpn" { - c.JSON(400, gin.H{"error": "仅 FRP / OpenVPN 支持平台自动下载部署,其他协议请使用系统包管理器安装"}) + + // 合法协议白名单 + validProtocols := map[string]bool{ + "frp": true, "openvpn": true, "wireguard": true, + "strongswan": true, "ipsec": true, "ikev2": true, "l2tp": true, + } + if !validProtocols[name] { + c.JSON(400, gin.H{"error": fmt.Sprintf("不支持的协议: %s", name)}) return } - version, err := binmgr.DownloadBinary(name) + + // 先检查是否已安装 + st := binmgr.CheckBinary(binmgr.ProtocolAlias(name)) + if st.Installed { + c.JSON(200, gin.H{"ok": true, "version": st.Version, "message": "已安装", "source": st.Source}) + return + } + + // 策略1:支持 GitHub 下载的协议(FRP / OpenVPN)先尝试下载 + if _, canDownload := binmgr.GetDeploySpec(name); canDownload { + version, err := binmgr.DownloadBinary(name) + if err == nil { + store.Audit(c.GetString("username"), "install_binary", name, version) + c.JSON(200, gin.H{"ok": true, "version": version, "method": "download"}) + return + } + // GitHub 下载失败,降级到包管理器 + log.Printf("[install] %s GitHub 下载失败(%v),尝试包管理器安装", name, err) + } + + // 策略2:系统包管理器一键安装 + output, err := binmgr.InstallViaPackageManager(name) if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) + c.JSON(500, gin.H{"error": err.Error(), "output": output}) return } - store.Audit(c.GetString("username"), "install_binary", name, version) - c.JSON(200, gin.H{"ok": true, "version": version}) + + // 安装成功,重新检测 + st = binmgr.CheckBinary(binmgr.ProtocolAlias(name)) + store.Audit(c.GetString("username"), "install_binary", name, st.Version) + c.JSON(200, gin.H{"ok": true, "version": st.Version, "method": "package_manager", "output": output}) } // binaryInstallHint 获取协议的系统包安装命令 diff --git a/backend/internal/binmgr/binmgr.go b/backend/internal/binmgr/binmgr.go index f6aaa8a..087edf1 100644 --- a/backend/internal/binmgr/binmgr.go +++ b/backend/internal/binmgr/binmgr.go @@ -2,6 +2,11 @@ // 参考 opsx-proxy 的 xray_install.go 模式: // - FRP: 从 GitHub Releases 下载预编译二进制到 data/bin/ // - WireGuard/OpenVPN/StrongSwan: 检测系统包管理器,返回安装命令 +// +// 国内加速策略(自动判断): +// - GitHub API / Release 下载:先直连,失败后依次尝试 ghfast.top / gh-proxy.com 等代理 +// - 系统包安装(yum/dnf):EPEL 源默认替换为阿里云镜像,可通过 EPEL_MIRROR 环境变量覆盖 +// - 所有镜像可通过 GITHUB_MIRROR 环境变量指定单个代理(跳过自动探测) package binmgr import ( @@ -11,6 +16,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "os" "os/exec" @@ -36,6 +42,130 @@ func dataDir() string { return "data" } +// ---- 国内 GitHub 加速镜像 ---- + +// chinaGitHubMirror 国内首选镜像(经实测 gh-proxy.com 14MB 仅需 2.6 秒)。 +// 海外直连 GitHub 无需代理。 +var chinaGitHubMirror = "https://gh-proxy.com" + +// fallbackGitHubMirrors 备选镜像(gh-proxy.com 不可用时依次尝试)。 +var fallbackGitHubMirrors = []string{ + "https://ghfast.top", + "https://mirror.ghproxy.com", + "https://github.moeyy.xyz", +} + +// cachedRegion 缓存区域检测结果,避免每次请求都查询。 +var cachedRegion = struct { + checked bool + isChina bool +}{} + +// detectChina 启动时通过 ipinfo.io 判断服务器是否在中国大陆。 +// 结果缓存 24 小时,查询失败默认当作国内处理(安全降级)。 +func detectChina() bool { + if cachedRegion.checked { + return cachedRegion.isChina + } + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("https://ipinfo.io/json") + if err != nil { + cachedRegion.checked = true + cachedRegion.isChina = true // 查询失败默认国内(走镜像) + return true + } + defer resp.Body.Close() + var info struct { + Country string `json:"country"` + } + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + cachedRegion.checked = true + cachedRegion.isChina = true + return true + } + cachedRegion.checked = true + cachedRegion.isChina = (info.Country == "CN") + return cachedRegion.isChina +} + +// getGitHubMirror 返回用户指定的镜像,或空字符串(表示直连)。 +// 用户可通过 GITHUB_MIRROR 环境变量强制指定(跳过自动探测)。 +func getGitHubMirror() string { + if m := os.Getenv("GITHUB_MIRROR"); m != "" { + return strings.TrimRight(m, "/") + } + return "" // 空表示由 getMirrorURLs 自动判断 +} + +// getMirrorURLs 返回一组候选 URL:根据国内/海外自动选择策略。 +// githubPath 为 github.com 之后的路径,如 "fatedier/frp/releases/download/v0.61.1/xxx.tar.gz"。 +// +// 国内:gh-proxy.com(首选)→ 直连(兜底)→ 备选镜像 +// 海外:直连 GitHub → gh-proxy.com(兜底) +func getMirrorURLs(githubPath string) []string { + directURL := "https://github.com/" + githubPath + proxyURL := chinaGitHubMirror + "/https://github.com/" + githubPath + + // 用户显式指定镜像 + if m := getGitHubMirror(); m != "" { + return []string{m + "/https://github.com/" + githubPath, directURL} + } + + if detectChina() { + // 国内:镜像优先,直连兜底 + urls := []string{proxyURL, directURL} + for _, m := range fallbackGitHubMirrors { + urls = append(urls, m+"/https://github.com/"+githubPath) + } + return urls + } + // 海外:直连优先,镜像兜底 + return []string{directURL, proxyURL} +} + +// getAPIURLs 返回 GitHub API 的候选 URL 列表(同样遵循国内/海外策略)。 +func getAPIURLs(apiPath string) []string { + directURL := "https://api.github.com/" + apiPath + proxyURL := chinaGitHubMirror + "/https://api.github.com/" + apiPath + + if m := getGitHubMirror(); m != "" { + return []string{m + "/https://api.github.com/" + apiPath, directURL} + } + + if detectChina() { + return []string{proxyURL, directURL} + } + return []string{directURL, proxyURL} +} + +// tryURLs 依次请求候选 URL,返回第一个成功的结果。 +// 用于 API 请求(返回 body)。 +func tryURLs(urls []string) ([]byte, error) { + var lastErr error + for _, u := range urls { + req, _ := http.NewRequest("GET", u, nil) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "opsx-vpn") + resp, err := httpClient.Do(req) + if err != nil { + lastErr = err + continue + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + lastErr = err + continue + } + if resp.StatusCode != 200 { + lastErr = fmt.Errorf("%s 返回 %d", u, resp.StatusCode) + continue + } + return body, nil + } + return nil, fmt.Errorf("所有源均失败,最后错误: %w", lastErr) +} + // ---- GitHub Release 下载(FRP / OpenVPN 通用)---- type githubRelease struct { @@ -70,6 +200,12 @@ var deploySpecs = map[string]deploySpec{ }, } +// GetDeploySpec 返回协议的 GitHub 下载规格(供 API 判断是否支持下载) +func GetDeploySpec(name string) (deploySpec, bool) { + spec, ok := deploySpecs[name] + return spec, ok +} + // DownloadBinary 通用下载部署:从 GitHub Releases 下载协议二进制到 data/bin/ func DownloadBinary(name string) (string, error) { spec, ok := deploySpecs[name] @@ -77,7 +213,7 @@ func DownloadBinary(name string) (string, error) { return "", fmt.Errorf("协议 %s 不支持平台下载部署", name) } - // 1. 获取最新 release + // 1. 获取最新 release(国内自动走镜像) release, err := fetchLatestRelease(spec.Repo) if err != nil { return "", fmt.Errorf("获取 %s 版本失败: %w", name, err) @@ -89,7 +225,10 @@ func DownloadBinary(name string) (string, error) { return "", fmt.Errorf("未找到匹配 %s/%s 的 %s 发布包", runtime.GOOS, runtime.GOARCH, name) } - // 3. 下载 + 解压 + 提取 + // 3. 将 asset URL 转为镜像候选列表(国内自动加速) + assetURLs := rewriteAssetURLs(assetURL) + + // 4. 下载 + 解压 + 提取 if err := os.MkdirAll(BinDir(), 0o755); err != nil { return "", err } @@ -100,7 +239,7 @@ func DownloadBinary(name string) (string, error) { defer os.RemoveAll(tmpDir) archivePath := filepath.Join(tmpDir, assetFile) - if err := downloadFile(assetURL, archivePath); err != nil { + if err := downloadFileWithMirror(assetURLs, archivePath); err != nil { return "", fmt.Errorf("下载失败: %w", err) } @@ -120,7 +259,7 @@ func DownloadBinary(name string) (string, error) { return "", fmt.Errorf("解压失败: %w", err) } - // 4. 提取二进制到 data/bin/ + // 5. 提取二进制到 data/bin/ version := release.TagName for _, binName := range spec.BinNames { // 尝试原名 + .exe @@ -145,11 +284,23 @@ func DownloadBinary(name string) (string, error) { } } - // 5. 写版本标记 + // 6. 写版本标记 os.WriteFile(filepath.Join(BinDir(), name+".version"), []byte(version), 0o644) return version, nil } +// rewriteAssetURLs 将 GitHub release asset 原始 URL 转为镜像候选列表。 +// 输入如 https://github.com/fatedier/frp/releases/download/v0.61.1/frp_0.61.1_linux_amd64.tar.gz +func rewriteAssetURLs(original string) []string { + // 提取 github.com 之后的路径 + if idx := strings.Index(original, "github.com/"); idx >= 0 { + path := original[idx+len("github.com/"):] + return getMirrorURLs(path) + } + // 非 GitHub URL,直接返回 + return []string{original} +} + // matchAsset 根据协议名和当前平台从 release assets 中匹配下载 URL func matchAsset(name string, spec deploySpec, release *githubRelease) (url, file string) { goos := runtime.GOOS @@ -202,35 +353,66 @@ func DetectPackageManager() string { return "" } +// getEPELMirror 返回国内 EPEL 镜像地址。 +// 优先级:EPEL_MIRROR 环境变量 > 阿里云默认 > 官方。 +func getEPELMirror() string { + if m := os.Getenv("EPEL_MIRROR"); m != "" { + return strings.TrimRight(m, "/") + } + // 默认阿里云 EPEL 镜像(国内最稳定) + return "https://mirrors.aliyun.com/epel" +} + // InstallHint 返回协议在各包管理器下的安装命令。 // 同时返回当前系统检测到的包管理器,供前端高亮推荐。 +// 国内策略:yum/dnf 的 EPEL 源默认指向阿里云镜像。 func InstallHint(protocol string) map[string]interface{} { detectedPM := DetectPackageManager() + epelMirror := getEPELMirror() // 嵌套 map: 协议 → 包管理器 → 安装命令 + // yum/dnf 使用国内 EPEL 镜像加速 hints := map[string]map[string]string{ "wireguard": { "apt-get": "apt-get install -y wireguard-tools", - "yum": "yum install -y epel-release && yum install -y wireguard-tools", - "dnf": "dnf install -y epel-release && dnf install -y wireguard-tools", + "yum": fmt.Sprintf("yum install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; yum install -y wireguard-tools", epelMirror), + "dnf": fmt.Sprintf("dnf install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; dnf install -y wireguard-tools", epelMirror), "apk": "apk add wireguard-tools", "pacman": "pacman -S --noconfirm wireguard-tools", }, "openvpn": { "apt-get": "apt-get install -y openvpn", - "yum": "yum install -y epel-release && yum install -y openvpn", - "dnf": "dnf install -y epel-release && dnf install -y openvpn", + "yum": fmt.Sprintf("yum install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; yum install -y openvpn", epelMirror), + "dnf": fmt.Sprintf("dnf install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; dnf install -y openvpn", epelMirror), "apk": "apk add openvpn", "pacman": "pacman -S --noconfirm openvpn", }, "strongswan": { "apt-get": "apt-get install -y strongswan strongswan-swanctl", - "yum": "yum install -y epel-release && yum install -y strongswan", - "dnf": "dnf install -y epel-release && dnf install -y strongswan", + "yum": fmt.Sprintf("yum install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; yum install -y strongswan", epelMirror), + "dnf": fmt.Sprintf("dnf install -y epel-release && sed -i 's|^metalink=|#metalink=|g; s|^#baseurl=https://download.example/pub/epel|baseurl=%s|g' /etc/yum.repos.d/epel*.repo 2>/dev/null; dnf install -y strongswan", epelMirror), "apk": "apk add strongswan", "pacman": "pacman -S --noconfirm strongswan", }, } + // L2TP 额外需要 xl2tpd + if protocol == "l2tp" || protocol == "strongswan" { + // 追加 xl2tpd 包(L2TP 协议必需) + for pm, cmd := range hints["strongswan"] { + if pm == "apt-get" { + hints["strongswan"][pm] = cmd + " xl2tpd" + } else { + hints["strongswan"][pm] = strings.Replace(cmd, "strongswan\"", "strongswan xl2tpd\"", 1) + if !strings.Contains(cmd, "xl2tpd") { + hints["strongswan"][pm] = cmd + " && " + map[string]string{ + "yum": "yum install -y xl2tpd", "dnf": "dnf install -y xl2tpd", + "apk": "apk add xl2tpd", "pacman": "pacman -S --noconfirm xl2tpd", + }[pm] + } + } + } + } + // IPSec / IKEv2 / L2TP 都依赖 strongswan displayName := protocol aliasMap := map[string]string{ @@ -256,9 +438,55 @@ func InstallHint(protocol string) map[string]interface{} { "detected": detectedPM != "", "protocol": displayName, "all_commands": protoHints, // 所有平台的命令,供前端展示 + "epel_mirror": epelMirror, // 当前使用的 EPEL 镜像 } } +// ---- 一键包管理器安装 ---- + +// InstallViaPackageManager 通过系统包管理器一键安装协议依赖。 +// 返回安装输出和检测到的二进制路径。 +func InstallViaPackageManager(protocol string) (output string, err error) { + pm := DetectPackageManager() + if pm == "" { + return "", fmt.Errorf("未检测到系统包管理器(apt/yum/dnf/apk/pacman)") + } + + // 获取安装命令 + hint := InstallHint(protocol) + cmd, _ := hint["command"].(string) + if cmd == "" { + return "", fmt.Errorf("协议 %s 无可用安装命令", protocol) + } + + // 执行安装(bash -c 包含管道等) + log.Printf("[binmgr] 执行安装命令: %s", cmd) + out, err := exec.Command("bash", "-c", cmd).CombinedOutput() + output = string(out) + if err != nil { + return output, fmt.Errorf("安装失败: %w\n%s", err, output) + } + + // 安装后重新检测 + st := CheckBinary(ProtocolAlias(protocol)) + if !st.Installed { + return output, fmt.Errorf("安装命令执行成功但未检测到二进制,可能需要手动配置") + } + + return output, nil +} + +// ProtocolAlias 返回协议的实际二进制检测名(导出供 API 使用) +func ProtocolAlias(protocol string) string { + aliasMap := map[string]string{ + "ipsec": "strongswan", "ikev2": "strongswan", "l2tp": "strongswan", + } + if real, ok := aliasMap[protocol]; ok { + return real + } + return protocol +} + // ---- 辅助函数 ---- // BinaryStatus 二进制安装状态 @@ -334,42 +562,57 @@ func CheckAll() []BinaryStatus { // ---- 下载与解压工具 ---- +// fetchLatestRelease 获取 GitHub 最新 release,国内自动走镜像加速。 func fetchLatestRelease(repo string) (*githubRelease, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("User-Agent", "opsx-vpn") - resp, err := httpClient.Do(req) + apiPath := fmt.Sprintf("repos/%s/releases/latest", repo) + urls := getAPIURLs(apiPath) + + body, err := tryURLs(urls) if err != nil { return nil, err } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return nil, fmt.Errorf("GitHub API 返回 %d", resp.StatusCode) - } var release githubRelease - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + if err := json.Unmarshal(body, &release); err != nil { return nil, err } return &release, nil } +// downloadFileWithMirror 依次尝试候选 URL 下载文件到 dest。 +func downloadFileWithMirror(urls []string, dest string) error { + var lastErr error + for _, u := range urls { + resp, err := httpClient.Get(u) + if err != nil { + lastErr = err + continue + } + if resp.StatusCode != 200 { + resp.Body.Close() + lastErr = fmt.Errorf("%s 返回 %d", u, resp.StatusCode) + continue + } + out, err := os.Create(dest) + if err != nil { + resp.Body.Close() + lastErr = err + continue + } + _, err = io.Copy(out, resp.Body) + out.Close() + resp.Body.Close() + if err != nil { + lastErr = err + continue + } + return nil // 成功 + } + return fmt.Errorf("所有下载源均失败,最后错误: %w", lastErr) +} + +// downloadFile 单 URL 下载(保留向后兼容)。 func downloadFile(url, dest string) error { - resp, err := httpClient.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return fmt.Errorf("下载返回 %d", resp.StatusCode) - } - out, err := os.Create(dest) - if err != nil { - return err - } - defer out.Close() - _, err = io.Copy(out, resp.Body) - return err + return downloadFileWithMirror([]string{url}, dest) } func unzipTo(zipPath, dest string) error { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 883f11a..43c2071 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "element-plus": "^2.8.6", "qrcode": "^1.5.4", "vue": "^3.5.12", + "vue-i18n": "^9.14.5", "vue-router": "^4.4.5" }, "devDependencies": { @@ -501,6 +502,50 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, + "node_modules/@intlify/core-base": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz", + "integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "9.14.5", + "@intlify/shared": "9.14.5" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz", + "integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "9.14.5", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz", + "integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1996,6 +2041,27 @@ "integrity": "sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==", "license": "MIT" }, + "node_modules/vue-i18n": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz", + "integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==", + "deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "9.14.5", + "@intlify/shared": "9.14.5", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vue-router": { "version": "4.6.4", "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index b0edcb9..d9e80b3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "element-plus": "^2.8.6", "qrcode": "^1.5.4", "vue": "^3.5.12", + "vue-i18n": "^9.14.5", "vue-router": "^4.4.5" }, "devDependencies": { diff --git a/frontend/src/components/ProtocolIcon.vue b/frontend/src/components/ProtocolIcon.vue index 7df2161..fad9b4c 100644 --- a/frontend/src/components/ProtocolIcon.vue +++ b/frontend/src/components/ProtocolIcon.vue @@ -18,6 +18,9 @@ @@ -146,6 +172,7 @@ async function changePwd() { .page-title { font-size: 16px; font-weight: 600; color: var(--text-primary); } .user { cursor: pointer; color: var(--text-regular); display: flex; align-items: center; gap: var(--space-1); } .user:focus-visible { outline: 2px solid var(--brand-primary); border-radius: var(--radius-sm); } +.lang-switch { cursor: pointer; color: var(--text-regular); display: flex; align-items: center; gap: 4px; font-size: 13px; } .main { background: var(--bg-page); padding: var(--space-5); overflow-y: auto; } .pwd-hint { font-size: 12px; color: var(--text-secondary); margin-top: var(--space-1); } diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 67b7f9e..53dfca5 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -6,36 +6,36 @@ -

VPN 专线管理平台

-

统一管理多协议 VPN,配置简单、安全可控

-

WireGuard · IPSec · L2TP · FRP · OpenVPN · 自定义协议插件

+

{{ $t('login.title') }}

+

{{ $t('login.subtitle') }}

+

{{ $t('login.protocols') }}

- 多协议统一管理 - WireGuard / IPSec / L2TP / FRP / OpenVPN + {{ $t('login.feature1Title') }} + {{ $t('login.feature1Desc') }}
- 企业级安全 - 密钥 AES-GCM 加密 + 双因素认证 + {{ $t('login.feature2Title') }} + {{ $t('login.feature2Desc') }}
- 一键创建配置 - 向导式生成,客户端配置秒级下载 + {{ $t('login.feature3Title') }} + {{ $t('login.feature3Desc') }}
- 轻量零依赖 - 单二进制交付,无需 Docker + {{ $t('login.feature4Title') }} + {{ $t('login.feature4Desc') }}
@@ -46,8 +46,8 @@
@@ -83,9 +83,11 @@ import { ref } from 'vue' import { useRouter } from 'vue-router' import { ElMessage } from 'element-plus' +import { useI18n } from 'vue-i18n' import { User, Lock, CircleCheck } from '@element-plus/icons-vue' import api, { auth } from '../api' +const { t } = useI18n() const username = ref('admin') const password = ref('') const loading = ref(false) @@ -95,16 +97,16 @@ const router = useRouter() async function doLogin() { loginError.value = '' if (!username.value || !password.value) { - loginError.value = '请输入用户名和密码' + loginError.value = t('login.errorEmpty') return } loading.value = true try { const { data } = await api.post('/login', { username: username.value, password: password.value }) auth.login(data.token, data.username, data.role, data.must_change_password) - ElMessage.success('登录成功') + ElMessage.success(t('login.loginSuccess')) if (data.must_change_password) { - ElMessage.warning('首次登录请先修改密码') + ElMessage.warning(t('login.changePasswordFirst')) router.push('/settings') } else { router.push('/') @@ -112,7 +114,7 @@ async function doLogin() { } catch (e) { // 检测 MFA 要求:后端返回 mfa_required: true 表示需要双因素验证 if (e.response?.data?.mfa_required) { - // 将凭据暂存 sessionStorage,跳转到独立 MFA 验证页(参考 opsx-web-lite 两步登录) + // 将凭据暂存 sessionStorage,跳转到独立 MFA 验证页 sessionStorage.setItem('pendingLogin', JSON.stringify({ username: username.value, password: password.value, @@ -120,7 +122,7 @@ async function doLogin() { router.push('/mfa-verify') return } - loginError.value = e.response?.data?.error || '登录失败,请检查网络或联系管理员' + loginError.value = e.response?.data?.error || t('login.loginFailed') } finally { loading.value = false } diff --git a/frontend/src/views/MfaVerify.vue b/frontend/src/views/MfaVerify.vue index b2086f9..bf9261b 100644 --- a/frontend/src/views/MfaVerify.vue +++ b/frontend/src/views/MfaVerify.vue @@ -5,8 +5,8 @@
-

双因素认证

-

请输入 Authenticator 应用中显示的 6 位动态验证码

+

{{ $t('mfaVerify.title') }}

+

{{ $t('mfaVerify.hint') }}

@@ -30,11 +30,11 @@ - 验证并登录 + {{ $t('mfaVerify.verifyAndLogin') }} @@ -44,9 +44,11 @@ import { ref, computed, onMounted, nextTick } from 'vue' import { useRouter } from 'vue-router' import { ElMessage } from 'element-plus' +import { useI18n } from 'vue-i18n' import { Key } from '@element-plus/icons-vue' import api, { auth } from '../api' +const { t } = useI18n() const router = useRouter() const digits = ref(['', '', '', '', '', '']) const loading = ref(false) @@ -96,12 +98,12 @@ function onPaste(e) { } async function verify() { - if (code.value.length < 6) return ElMessage.warning('请输入完整的 6 位验证码') + if (code.value.length < 6) return ElMessage.warning(t('mfaVerify.inputFullCode')) // 从 sessionStorage 读取暂存的登录凭据 const pending = sessionStorage.getItem('pendingLogin') if (!pending) { - ElMessage.warning('登录会话已过期,请重新登录') + ElMessage.warning(t('mfaVerify.sessionExpired')) router.push('/login') return } @@ -110,19 +112,19 @@ async function verify() { loading.value = true errorMsg.value = '' try { - // 重新提交完整登录请求(含 mfa_code),参考 opsx-web-lite 两步登录模式 + // 重新提交完整登录请求(含 mfa_code) const { data } = await api.post('/login', { username, password, mfa_code: code.value }) // 验证成功,清除暂存凭据 sessionStorage.removeItem('pendingLogin') auth.login(data.token, data.username, data.role, data.must_change_password) - ElMessage.success('验证成功') + ElMessage.success(t('mfaVerify.verifySuccess')) if (data.must_change_password) { router.push('/settings') } else { router.push('/') } } catch (e) { - errorMsg.value = e.response?.data?.error || '验证码错误,请重试' + errorMsg.value = e.response?.data?.error || t('mfaVerify.codeError') // 清空输入,聚焦第一格 digits.value = ['', '', '', '', '', ''] nextTick(() => digitRefs[0]?.focus?.()) diff --git a/frontend/src/views/Nodes.vue b/frontend/src/views/Nodes.vue index 380a293..e5733ab 100644 --- a/frontend/src/views/Nodes.vue +++ b/frontend/src/views/Nodes.vue @@ -2,110 +2,110 @@ + :title="$t('nodes.nodeHint')" /> - + - + - + - - + + - + - - + + - + - - + + - - + + - + - + - 远程节点 - 本机节点 + {{ $t('nodes.remoteNode') }} + {{ $t('nodes.localNode') }} - + @@ -114,9 +114,11 @@ diff --git a/frontend/src/views/TunnelEditor.vue b/frontend/src/views/TunnelEditor.vue index 833d6c9..8897cc6 100644 --- a/frontend/src/views/TunnelEditor.vue +++ b/frontend/src/views/TunnelEditor.vue @@ -2,19 +2,19 @@ + :title="$t('tunnelEditor.sensitiveFieldHint')" /> - + - + {{ protocolDisplay(protocol) }} @@ -24,18 +24,18 @@ + show-password :placeholder="field.placeholder || $t('tunnelEditor.keepOriginal')" />
{{ field.tip }}
- - + + - - + + - 保存配置 - 取消 + {{ $t('tunnelEditor.saveConfig') }} + {{ $t('common.cancel') }}
@@ -45,9 +45,11 @@ import { computed, onMounted, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage } from 'element-plus' +import { useI18n } from 'vue-i18n' import api from '../api' import { CUSTOM_FIELDS, PROTOCOL_META, protocolColor, protocolDisplay, optionValue, optionLabel } from '../protocols' +const { t } = useI18n() const route = useRoute() const router = useRouter() const loading = ref(true) @@ -84,7 +86,7 @@ async function load() { config, } } catch (error) { - ElMessage.error(error.response?.data?.error || '加载专线失败') + ElMessage.error(error.response?.data?.error || t('tunnelEditor.loadFailed')) router.push('/tunnels') } finally { loading.value = false @@ -92,14 +94,14 @@ async function load() { } async function save() { - if (!form.value.name) return ElMessage.warning('请填写专线名称') + if (!form.value.name) return ElMessage.warning(t('tunnelEditor.fillName')) saving.value = true try { await api.put(`/tunnels/${route.params.id}`, form.value) - ElMessage.success('配置已保存') + ElMessage.success(t('tunnelEditor.configSaved')) router.push('/tunnels') } catch (error) { - ElMessage.error(error.response?.data?.error || '保存失败') + ElMessage.error(error.response?.data?.error || t('common.saveFailed')) } finally { saving.value = false } diff --git a/frontend/src/views/TunnelWizard.vue b/frontend/src/views/TunnelWizard.vue index 7eeada9..368e13d 100644 --- a/frontend/src/views/TunnelWizard.vue +++ b/frontend/src/views/TunnelWizard.vue @@ -1,9 +1,9 @@